release: seal Focus Seed 1.0.0
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const seed = Color(0xFF2E7D6B);
|
||||
static const accent = Color(0xFFF4B740);
|
||||
static const paper = Color(0xFFF7F8F5);
|
||||
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper);
|
||||
return _base(scheme);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
return _base(scheme);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme) {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/shell/focus_seed_home.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
class FocusSeedApp extends StatelessWidget {
|
||||
const FocusSeedApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Focus Seed',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
home: const FocusSeedHome(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/habit.dart';
|
||||
import '../models/reflection_entry.dart';
|
||||
|
||||
String todayKey() => dateKey(DateTime.now());
|
||||
|
||||
String dateKey(DateTime date) {
|
||||
final local = DateTime(date.year, date.month, date.day);
|
||||
return '${local.year.toString().padLeft(4, '0')}-'
|
||||
'${local.month.toString().padLeft(2, '0')}-'
|
||||
'${local.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String readableToday() {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
final now = DateTime.now();
|
||||
return '${months[now.month - 1]} ${now.day}, ${now.year}';
|
||||
}
|
||||
|
||||
List<String> lastSevenDateKeys() {
|
||||
final now = DateTime.now();
|
||||
return List.generate(
|
||||
7,
|
||||
(index) => dateKey(now.subtract(Duration(days: 6 - index))),
|
||||
);
|
||||
}
|
||||
|
||||
int minutesForDate(List<FocusSession> sessions, String key) {
|
||||
return sessions
|
||||
.where((session) => dateKey(session.completedAt) == key)
|
||||
.fold<int>(0, (total, session) => total + session.minutes);
|
||||
}
|
||||
|
||||
int currentStreak(
|
||||
List<Habit> habits,
|
||||
List<FocusSession> sessions,
|
||||
List<ReflectionEntry> reflections,
|
||||
) {
|
||||
var streak = 0;
|
||||
var cursor = DateTime.now();
|
||||
while (true) {
|
||||
final key = dateKey(cursor);
|
||||
final hasHabit = habits.any((habit) => habit.completedDates.contains(key));
|
||||
final hasSession = sessions.any(
|
||||
(session) => dateKey(session.completedAt) == key,
|
||||
);
|
||||
final hasReflection = reflections.any(
|
||||
(reflection) => reflection.dateKey == key,
|
||||
);
|
||||
if (!hasHabit && !hasSession && !hasReflection) {
|
||||
return streak;
|
||||
}
|
||||
streak += 1;
|
||||
cursor = cursor.subtract(const Duration(days: 1));
|
||||
}
|
||||
}
|
||||
|
||||
String formatSeconds(int seconds) {
|
||||
final minutes = seconds ~/ 60;
|
||||
final remaining = seconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:'
|
||||
'${remaining.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
extension FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull {
|
||||
final iterator = this.iterator;
|
||||
if (iterator.moveNext()) {
|
||||
return iterator.current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
String prettyJson(Map<String, dynamic> value) {
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../core/json_utils.dart';
|
||||
import '../models/app_state.dart';
|
||||
|
||||
class FocusSeedStore {
|
||||
static const _storageKey = 'focus_seed_state_v1';
|
||||
|
||||
Future<AppState> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return AppState.starter();
|
||||
}
|
||||
try {
|
||||
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} on Object {
|
||||
return AppState.starter();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(AppState state) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_storageKey, prettyJson(state.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/empty_state.dart';
|
||||
import '../../widgets/habit_row.dart';
|
||||
|
||||
class HabitsScreen extends StatelessWidget {
|
||||
const HabitsScreen({
|
||||
required this.state,
|
||||
required this.onAddHabit,
|
||||
required this.onRenameHabit,
|
||||
required this.onDeleteHabit,
|
||||
required this.onToggleHabit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final ValueChanged<String> onAddHabit;
|
||||
final void Function(Habit habit, String title) onRenameHabit;
|
||||
final ValueChanged<Habit> onDeleteHabit;
|
||||
final ValueChanged<Habit> onToggleHabit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = todayKey();
|
||||
return AppScaffold(
|
||||
title: 'Habits',
|
||||
subtitle: 'Keep the list small and repeatable.',
|
||||
action: IconButton.filledTonal(
|
||||
tooltip: 'Add habit',
|
||||
onPressed: () => showHabitDialog(context, onAddHabit),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
if (state.habits.isEmpty)
|
||||
EmptyState(
|
||||
icon: Icons.checklist_outlined,
|
||||
title: 'No habits yet',
|
||||
action: 'Add habit',
|
||||
onAction: () => showHabitDialog(context, onAddHabit),
|
||||
)
|
||||
else
|
||||
...state.habits.map(
|
||||
(habit) => HabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => onToggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
(title) => onRenameHabit(habit, title),
|
||||
initialValue: habit.title,
|
||||
),
|
||||
onDelete:
|
||||
() =>
|
||||
confirmDeleteHabit(context, () => onDeleteHabit(habit)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/empty_state.dart';
|
||||
import '../../widgets/habit_row.dart';
|
||||
import '../../widgets/inline_editable_text.dart';
|
||||
import '../../widgets/metric_tile.dart';
|
||||
import '../../widgets/section_label.dart';
|
||||
import '../../widgets/seed_card.dart';
|
||||
import '../../widgets/weekly_progress.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({
|
||||
required this.state,
|
||||
required this.onAnchorChanged,
|
||||
required this.onOpenTimer,
|
||||
required this.onToggleHabit,
|
||||
required this.onAddHabit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final ValueChanged<String> onAnchorChanged;
|
||||
final VoidCallback onOpenTimer;
|
||||
final ValueChanged<Habit> onToggleHabit;
|
||||
final ValueChanged<String> onAddHabit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = todayKey();
|
||||
final completed =
|
||||
state.habits
|
||||
.where((habit) => habit.completedDates.contains(today))
|
||||
.length;
|
||||
final focusMinutes = minutesForDate(state.sessions, today);
|
||||
final streak = currentStreak(
|
||||
state.habits,
|
||||
state.sessions,
|
||||
state.reflections,
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
title: 'Focus Seed',
|
||||
subtitle: readableToday(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
SeedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionLabel(
|
||||
icon: Icons.flag_outlined,
|
||||
text: 'Today anchor',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InlineEditableText(
|
||||
value: state.anchor,
|
||||
emptyText: 'Choose one important task',
|
||||
onSubmitted: onAnchorChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenTimer,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Start focus'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MetricTile(
|
||||
label: 'Focus',
|
||||
value: '$focusMinutes',
|
||||
suffix: 'min',
|
||||
icon: Icons.timer_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: MetricTile(
|
||||
label: 'Habits',
|
||||
value: '$completed/${state.habits.length}',
|
||||
suffix: '',
|
||||
icon: Icons.check_circle_outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: MetricTile(
|
||||
label: 'Streak',
|
||||
value: '$streak',
|
||||
suffix: 'days',
|
||||
icon: Icons.local_fire_department_outlined,
|
||||
accent: AppTheme.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Today habits',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Add habit',
|
||||
onPressed: () => showHabitDialog(context, onAddHabit),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (state.habits.isEmpty)
|
||||
EmptyState(
|
||||
icon: Icons.checklist_rtl,
|
||||
title: 'Add one tiny habit',
|
||||
action: 'Add habit',
|
||||
onAction: () => showHabitDialog(context, onAddHabit),
|
||||
)
|
||||
else
|
||||
...state.habits
|
||||
.take(5)
|
||||
.map(
|
||||
(habit) => HabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => onToggleHabit(habit),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
WeeklyProgress(state: state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../core/iterable_extensions.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/mood.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/section_label.dart';
|
||||
import '../../widgets/seed_card.dart';
|
||||
|
||||
class ReflectionScreen extends StatefulWidget {
|
||||
const ReflectionScreen({
|
||||
required this.state,
|
||||
required this.onSave,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final void Function(String note, Mood mood) onSave;
|
||||
|
||||
@override
|
||||
State<ReflectionScreen> createState() => _ReflectionScreenState();
|
||||
}
|
||||
|
||||
class _ReflectionScreenState extends State<ReflectionScreen> {
|
||||
final _controller = TextEditingController();
|
||||
Mood _mood = Mood.clear;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = todayKey();
|
||||
final todayReflection =
|
||||
widget.state.reflections
|
||||
.where((item) => item.dateKey == today)
|
||||
.firstOrNull;
|
||||
if (todayReflection != null && _controller.text.isEmpty) {
|
||||
_controller.text = todayReflection.note;
|
||||
_mood = todayReflection.mood;
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
title: 'Reflection',
|
||||
subtitle: 'One sentence is enough.',
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
SeedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionLabel(
|
||||
icon: Icons.psychology_outlined,
|
||||
text: 'How did it feel?',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children:
|
||||
Mood.values
|
||||
.map(
|
||||
(mood) => ChoiceChip(
|
||||
avatar: Icon(mood.icon, size: 18),
|
||||
label: Text(mood.label),
|
||||
selected: _mood == mood,
|
||||
onSelected: (_) => setState(() => _mood = mood),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
minLines: 4,
|
||||
maxLines: 7,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'What made today a little better?',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: () => widget.onSave(_controller.text, _mood),
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
label: const Text('Save reflection'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text('Recent notes', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.state.reflections.isEmpty)
|
||||
const SeedCard(
|
||||
child: Text(
|
||||
'Your reflections will appear here after you save one.',
|
||||
),
|
||||
)
|
||||
else
|
||||
...widget.state.reflections.reversed
|
||||
.take(7)
|
||||
.map(
|
||||
(entry) => SeedCard(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(entry.mood.icon),
|
||||
title: Text(entry.note),
|
||||
subtitle: Text(entry.dateKey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/app_state.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/seed_card.dart';
|
||||
import '../../widgets/settings_tile.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({
|
||||
required this.state,
|
||||
required this.onImport,
|
||||
required this.onClear,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final ValueChanged<String> onImport;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
title: 'Settings',
|
||||
subtitle: 'Private by default. No account required.',
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
SeedCard(
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsTile(
|
||||
icon: Icons.ios_share,
|
||||
title: 'Export JSON',
|
||||
subtitle: 'Copy your local data as readable JSON.',
|
||||
onTap: () => showExportDialog(context, state),
|
||||
),
|
||||
const Divider(),
|
||||
SettingsTile(
|
||||
icon: Icons.file_upload_outlined,
|
||||
title: 'Import JSON',
|
||||
subtitle: 'Paste a previous Focus Seed export.',
|
||||
onTap: () => showImportDialog(context, onImport),
|
||||
),
|
||||
const Divider(),
|
||||
SettingsTile(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
title: 'Privacy',
|
||||
subtitle:
|
||||
'Your data stays on this device unless you export it.',
|
||||
onTap: () => showPrivacySheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SeedCard(
|
||||
child: SettingsTile(
|
||||
icon: Icons.delete_outline,
|
||||
title: 'Clear local data',
|
||||
subtitle: 'Reset habits, sessions, and reflections.',
|
||||
destructive: true,
|
||||
onTap: () => confirmClearData(context, onClear),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../data/focus_seed_store.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/focus_session.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../models/mood.dart';
|
||||
import '../../models/reflection_entry.dart';
|
||||
import '../../services/analytics_provider.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../habits/habits_screen.dart';
|
||||
import '../home/home_screen.dart';
|
||||
import '../reflection/reflection_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../timer/timer_screen.dart';
|
||||
|
||||
class FocusSeedHome extends StatefulWidget {
|
||||
const FocusSeedHome({super.key});
|
||||
|
||||
@override
|
||||
State<FocusSeedHome> createState() => _FocusSeedHomeState();
|
||||
}
|
||||
|
||||
class _FocusSeedHomeState extends State<FocusSeedHome> {
|
||||
final _store = FocusSeedStore();
|
||||
final _analytics = const NoopAnalyticsProvider();
|
||||
AppState? _state;
|
||||
int _tabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final state = await _store.load();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _state = state);
|
||||
}
|
||||
|
||||
Future<void> _setStateAndSave(AppState state) async {
|
||||
setState(() => _state = state);
|
||||
await _store.save(state);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = _state;
|
||||
return Scaffold(
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child:
|
||||
state == null
|
||||
? const LoadingView()
|
||||
: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _tabIndex,
|
||||
children: [
|
||||
HomeScreen(
|
||||
state: state,
|
||||
onAnchorChanged:
|
||||
(anchor) => _setStateAndSave(
|
||||
state.copyWith(anchor: anchor.trim()),
|
||||
),
|
||||
onOpenTimer: () => setState(() => _tabIndex = 1),
|
||||
onToggleHabit: _toggleHabit,
|
||||
onAddHabit: _addHabit,
|
||||
),
|
||||
TimerScreen(
|
||||
onComplete: _completeFocusSession,
|
||||
onReflect: () => setState(() => _tabIndex = 3),
|
||||
),
|
||||
HabitsScreen(
|
||||
state: state,
|
||||
onAddHabit: _addHabit,
|
||||
onRenameHabit: _renameHabit,
|
||||
onDeleteHabit: _deleteHabit,
|
||||
onToggleHabit: _toggleHabit,
|
||||
),
|
||||
ReflectionScreen(state: state, onSave: _saveReflection),
|
||||
SettingsScreen(
|
||||
state: state,
|
||||
onImport: _importState,
|
||||
onClear: _clearData,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar:
|
||||
state == null
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: _tabIndex,
|
||||
onDestinationSelected: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Today',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.timer_outlined),
|
||||
selectedIcon: Icon(Icons.timer),
|
||||
label: 'Timer',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.check_circle_outline),
|
||||
selectedIcon: Icon(Icons.check_circle),
|
||||
label: 'Habits',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.edit_note_outlined),
|
||||
selectedIcon: Icon(Icons.edit_note),
|
||||
label: 'Reflect',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleHabit(Habit habit) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final today = todayKey();
|
||||
final completedDates = Set<String>.from(habit.completedDates);
|
||||
if (completedDates.contains(today)) {
|
||||
completedDates.remove(today);
|
||||
} else {
|
||||
completedDates.add(today);
|
||||
await HapticFeedback.lightImpact();
|
||||
}
|
||||
final habits =
|
||||
state.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id
|
||||
? habit.copyWith(completedDates: completedDates)
|
||||
: item,
|
||||
)
|
||||
.toList();
|
||||
_analytics.track('habit_toggled');
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _addHabit(String title) async {
|
||||
final state = _state;
|
||||
final clean = title.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final habit = Habit(
|
||||
id: 'habit-${DateTime.now().microsecondsSinceEpoch}',
|
||||
title: clean,
|
||||
createdAt: DateTime.now(),
|
||||
completedDates: {},
|
||||
);
|
||||
_analytics.track('habit_created');
|
||||
await _setStateAndSave(state.copyWith(habits: [...state.habits, habit]));
|
||||
}
|
||||
|
||||
Future<void> _renameHabit(Habit habit, String title) async {
|
||||
final state = _state;
|
||||
final clean = title.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final habits =
|
||||
state.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id ? item.copyWith(title: clean) : item,
|
||||
)
|
||||
.toList();
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _deleteHabit(Habit habit) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final habits = state.habits.where((item) => item.id != habit.id).toList();
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _completeFocusSession(int minutes) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final session = FocusSession(
|
||||
id: 'session-${DateTime.now().microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
completedAt: DateTime.now(),
|
||||
);
|
||||
_analytics.track('focus_completed', {'minutes': minutes});
|
||||
await _setStateAndSave(
|
||||
state.copyWith(sessions: [...state.sessions, session]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveReflection(String note, Mood mood) async {
|
||||
final state = _state;
|
||||
final clean = note.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final today = todayKey();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${DateTime.now().microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: clean,
|
||||
mood: mood,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
final reflections = [
|
||||
...state.reflections.where((item) => item.dateKey != today),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
await _setStateAndSave(state.copyWith(reflections: reflections));
|
||||
if (mounted) {
|
||||
showSnack(context, 'Reflection saved');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importState(String raw) async {
|
||||
try {
|
||||
final imported = AppState.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
await _setStateAndSave(imported);
|
||||
if (mounted) {
|
||||
showSnack(context, 'Data imported');
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
showSnack(context, 'Import failed. Check the JSON and try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearData() async {
|
||||
await _store.clear();
|
||||
await _setStateAndSave(AppState.starter());
|
||||
if (mounted) {
|
||||
showSnack(context, 'Local data cleared');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/seed_card.dart';
|
||||
|
||||
class TimerScreen extends StatefulWidget {
|
||||
const TimerScreen({
|
||||
required this.onComplete,
|
||||
required this.onReflect,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ValueChanged<int> onComplete;
|
||||
final VoidCallback onReflect;
|
||||
|
||||
@override
|
||||
State<TimerScreen> createState() => _TimerScreenState();
|
||||
}
|
||||
|
||||
class _TimerScreenState extends State<TimerScreen> {
|
||||
static const _presets = [15, 25, 45];
|
||||
Timer? _timer;
|
||||
int _selectedMinutes = 25;
|
||||
int _remainingSeconds = 25 * 60;
|
||||
bool _running = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
final progress = 1 - (_remainingSeconds / totalSeconds);
|
||||
return AppScaffold(
|
||||
title: 'Focus timer',
|
||||
subtitle: 'No ads or distractions here.',
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
children: [
|
||||
SeedCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
children:
|
||||
_presets
|
||||
.map(
|
||||
(minutes) => ChoiceChip(
|
||||
label: Text('$minutes min'),
|
||||
selected: _selectedMinutes == minutes,
|
||||
onSelected:
|
||||
_running
|
||||
? null
|
||||
: (_) => setState(() {
|
||||
_selectedMinutes = minutes;
|
||||
_remainingSeconds = minutes * 60;
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: progress.clamp(0, 1)),
|
||||
duration: const Duration(milliseconds: 240),
|
||||
builder:
|
||||
(context, value, child) => SizedBox.square(
|
||||
dimension: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: value,
|
||||
strokeWidth: 14,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child!,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
formatSeconds(_remainingSeconds),
|
||||
style: Theme.of(context).textTheme.displayMedium
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
_running ? 'Focus in progress' : 'Ready when you are',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _running ? _pause : _start,
|
||||
icon: Icon(_running ? Icons.pause : Icons.play_arrow),
|
||||
label: Text(_running ? 'Pause' : 'Start'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
IconButton.filledTonal(
|
||||
tooltip: 'Reset timer',
|
||||
onPressed: _reset,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _completeNow,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Mark session complete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SeedCard(
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.edit_note_outlined),
|
||||
title: const Text('Finish with a one-minute reflection'),
|
||||
subtitle: const Text('Capture what worked while it is fresh.'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: widget.onReflect,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _start() {
|
||||
if (_running) {
|
||||
return;
|
||||
}
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _running = true);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (_remainingSeconds <= 1) {
|
||||
_completeNow();
|
||||
return;
|
||||
}
|
||||
setState(() => _remainingSeconds -= 1);
|
||||
});
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_timer?.cancel();
|
||||
setState(() => _running = false);
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_running = false;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
});
|
||||
}
|
||||
|
||||
void _completeNow() {
|
||||
_timer?.cancel();
|
||||
final completedMinutes = _selectedMinutes;
|
||||
setState(() {
|
||||
_running = false;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
widget.onComplete(completedMinutes);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Session saved'),
|
||||
content: const Text(
|
||||
'Take one minute to write what helped you focus.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
widget.onReflect();
|
||||
},
|
||||
child: const Text('Reflect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'focus_session.dart';
|
||||
import 'habit.dart';
|
||||
import 'reflection_entry.dart';
|
||||
|
||||
class AppState {
|
||||
const AppState({
|
||||
required this.anchor,
|
||||
required this.habits,
|
||||
required this.sessions,
|
||||
required this.reflections,
|
||||
});
|
||||
|
||||
final String anchor;
|
||||
final List<Habit> habits;
|
||||
final List<FocusSession> sessions;
|
||||
final List<ReflectionEntry> reflections;
|
||||
|
||||
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
|
||||
|
||||
AppState copyWith({
|
||||
String? anchor,
|
||||
List<Habit>? habits,
|
||||
List<FocusSession>? sessions,
|
||||
List<ReflectionEntry>? reflections,
|
||||
}) {
|
||||
return AppState(
|
||||
anchor: anchor ?? this.anchor,
|
||||
habits: habits ?? this.habits,
|
||||
sessions: sessions ?? this.sessions,
|
||||
reflections: reflections ?? this.reflections,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'version': 1,
|
||||
'anchor': anchor,
|
||||
'habits': habits.map((habit) => habit.toJson()).toList(),
|
||||
'sessions': sessions.map((session) => session.toJson()).toList(),
|
||||
'reflections':
|
||||
reflections.map((reflection) => reflection.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static AppState fromJson(Map<String, dynamic> json) {
|
||||
return AppState(
|
||||
anchor: (json['anchor'] as String?) ?? '',
|
||||
habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(),
|
||||
sessions:
|
||||
_jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(),
|
||||
reflections:
|
||||
_jsonObjects(
|
||||
json['reflections'],
|
||||
).map(ReflectionEntry.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static Iterable<Map<String, dynamic>> _jsonObjects(Object? value) {
|
||||
if (value is! List<Object?>) {
|
||||
return const Iterable<Map<String, dynamic>>.empty();
|
||||
}
|
||||
return value.whereType<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
static AppState starter() {
|
||||
final now = DateTime.now();
|
||||
return AppState(
|
||||
anchor: '',
|
||||
habits: [
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-1',
|
||||
title: 'Plan one clear task',
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-2',
|
||||
title: 'Focus for 25 minutes',
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-3',
|
||||
title: 'Write a one-line reflection',
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
],
|
||||
sessions: const [],
|
||||
reflections: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
class FocusSession {
|
||||
const FocusSession({
|
||||
required this.id,
|
||||
required this.minutes,
|
||||
required this.completedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int minutes;
|
||||
final DateTime completedAt;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'minutes': minutes,
|
||||
'completedAt': completedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static FocusSession fromJson(Map<String, dynamic> json) {
|
||||
return FocusSession(
|
||||
id: json['id'] as String,
|
||||
minutes: json['minutes'] as int,
|
||||
completedAt: DateTime.parse(json['completedAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
class Habit {
|
||||
const Habit({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.createdAt,
|
||||
required this.completedDates,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final DateTime createdAt;
|
||||
final Set<String> completedDates;
|
||||
|
||||
Habit copyWith({String? title, Set<String>? completedDates}) {
|
||||
return Habit(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
createdAt: createdAt,
|
||||
completedDates: completedDates ?? this.completedDates,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'completedDates': completedDates.toList()..sort(),
|
||||
};
|
||||
}
|
||||
|
||||
static Habit fromJson(Map<String, dynamic> json) {
|
||||
return Habit(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
completedDates:
|
||||
((json['completedDates'] as List?) ?? const [])
|
||||
.map((value) => value.toString())
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum Mood {
|
||||
calm('Calm', Icons.spa_outlined),
|
||||
clear('Clear', Icons.wb_sunny_outlined),
|
||||
proud('Proud', Icons.emoji_events_outlined),
|
||||
tired('Tired', Icons.bedtime_outlined);
|
||||
|
||||
const Mood(this.label, this.icon);
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'mood.dart';
|
||||
|
||||
class ReflectionEntry {
|
||||
const ReflectionEntry({
|
||||
required this.id,
|
||||
required this.dateKey,
|
||||
required this.note,
|
||||
required this.mood,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String dateKey;
|
||||
final String note;
|
||||
final Mood mood;
|
||||
final DateTime createdAt;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'dateKey': dateKey,
|
||||
'note': note,
|
||||
'mood': mood.name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static ReflectionEntry fromJson(Map<String, dynamic> json) {
|
||||
return ReflectionEntry(
|
||||
id: json['id'] as String,
|
||||
dateKey: json['dateKey'] as String,
|
||||
note: json['note'] as String,
|
||||
mood: Mood.values.firstWhere(
|
||||
(mood) => mood.name == json['mood'],
|
||||
orElse: () => Mood.clear,
|
||||
),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
abstract interface class AnalyticsProvider {
|
||||
void track(String event, [Map<String, Object?> properties = const {}]);
|
||||
}
|
||||
|
||||
class NoopAnalyticsProvider implements AnalyticsProvider {
|
||||
const NoopAnalyticsProvider();
|
||||
|
||||
@override
|
||||
void track(String event, [Map<String, Object?> properties = const {}]) {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
enum PremiumState { free, premium }
|
||||
|
||||
enum AdPlacement { statsBottom, reflectionComplete }
|
||||
|
||||
sealed class PurchaseResult {
|
||||
const PurchaseResult();
|
||||
|
||||
static const unavailable = PurchaseUnavailable();
|
||||
}
|
||||
|
||||
class PurchaseUnavailable extends PurchaseResult {
|
||||
const PurchaseUnavailable();
|
||||
}
|
||||
|
||||
abstract interface class MonetizationProvider {
|
||||
bool get adsEnabled;
|
||||
bool get billingEnabled;
|
||||
Stream<PremiumState> premiumState();
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement);
|
||||
Future<PurchaseResult> startPurchase(String productId);
|
||||
Future<void> restorePurchases();
|
||||
}
|
||||
|
||||
class NoopMonetizationProvider implements MonetizationProvider {
|
||||
const NoopMonetizationProvider();
|
||||
|
||||
@override
|
||||
bool get adsEnabled => false;
|
||||
|
||||
@override
|
||||
bool get billingEnabled => false;
|
||||
|
||||
@override
|
||||
Stream<PremiumState> premiumState() => Stream.value(PremiumState.free);
|
||||
|
||||
@override
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement) async {}
|
||||
|
||||
@override
|
||||
Future<PurchaseResult> startPurchase(String productId) async {
|
||||
return PurchaseResult.unavailable;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restorePurchases() async {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppScaffold extends StatelessWidget {
|
||||
const AppScaffold({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
this.action,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/json_utils.dart';
|
||||
import '../models/app_state.dart';
|
||||
|
||||
Future<void> showHabitDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Example: Read 10 pages',
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onSubmit(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> confirmDeleteHabit(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Delete habit?'),
|
||||
content: const Text(
|
||||
'Past completion records for this habit will be removed.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showExportDialog(BuildContext context, AppState state) async {
|
||||
final controller = TextEditingController(text: prettyJson(state.toJson()));
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Export JSON'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: controller.text));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
showSnack(context, 'Export copied');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> showImportDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onImport,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Import JSON'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Paste Focus Seed JSON export',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onImport(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> confirmClearData(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Clear local data?'),
|
||||
content: const Text(
|
||||
'This resets habits, focus sessions, and reflections on this device.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showPrivacySheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder:
|
||||
(context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: const [
|
||||
Text(
|
||||
'Privacy',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Focus Seed stores your anchor task, habits, focus sessions, '
|
||||
'reflections, and settings locally on this device. V1 does not '
|
||||
'include account sign-in, cloud sync, ads, analytics SDKs, or '
|
||||
'billing SDKs.',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'You can export your local data as JSON, import a previous '
|
||||
'export, or clear local data from Settings.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'seed_card.dart';
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.action,
|
||||
required this.onAction,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String action;
|
||||
final VoidCallback onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SeedCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 42, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(action),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/habit.dart';
|
||||
import 'seed_card.dart';
|
||||
|
||||
class HabitRow extends StatelessWidget {
|
||||
const HabitRow({
|
||||
required this.habit,
|
||||
required this.dateKey,
|
||||
required this.onToggle,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Habit habit;
|
||||
final String dateKey;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = habit.completedDates.contains(dateKey);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: SeedCard(
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: done ? 'Mark not done' : 'Mark done',
|
||||
onPressed: onToggle,
|
||||
icon: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: Icon(
|
||||
done ? Icons.check : Icons.circle_outlined,
|
||||
key: ValueKey(done),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
habit.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
decoration: done ? TextDecoration.lineThrough : null,
|
||||
color:
|
||||
done
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onEdit != null)
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Habit options',
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
onEdit?.call();
|
||||
}
|
||||
if (value == 'delete') {
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => const [
|
||||
PopupMenuItem(value: 'edit', child: Text('Rename')),
|
||||
PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class InlineEditableText extends StatefulWidget {
|
||||
const InlineEditableText({
|
||||
required this.value,
|
||||
required this.emptyText,
|
||||
required this.onSubmitted,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String emptyText;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
@override
|
||||
State<InlineEditableText> createState() => _InlineEditableTextState();
|
||||
}
|
||||
|
||||
class _InlineEditableTextState extends State<InlineEditableText> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InlineEditableText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.value != widget.value && _controller.text != widget.value) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
onTapOutside: (_) => widget.onSubmitted(_controller.text),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.emptyText,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'Save anchor',
|
||||
onPressed: () => widget.onSubmitted(_controller.text),
|
||||
icon: const Icon(Icons.check),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LoadingView extends StatelessWidget {
|
||||
const LoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'seed_card.dart';
|
||||
|
||||
class MetricTile extends StatelessWidget {
|
||||
const MetricTile({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.suffix,
|
||||
required this.icon,
|
||||
this.accent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String suffix;
|
||||
final IconData icon;
|
||||
final Color? accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = accent ?? Theme.of(context).colorScheme.primary;
|
||||
return SeedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(height: 12),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (suffix.isNotEmpty) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: Text(
|
||||
suffix,
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionLabel extends StatelessWidget {
|
||||
const SectionLabel({required this.icon, required this.text, super.key});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeedCard extends StatelessWidget {
|
||||
const SeedCard({required this.child, this.margin, super.key});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: margin,
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsTile extends StatelessWidget {
|
||||
const SettingsTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = destructive ? Theme.of(context).colorScheme.error : null;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(title, style: TextStyle(color: color)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/date_keys.dart';
|
||||
import '../models/app_state.dart';
|
||||
import 'section_label.dart';
|
||||
import 'seed_card.dart';
|
||||
|
||||
class WeeklyProgress extends StatelessWidget {
|
||||
const WeeklyProgress({required this.state, super.key});
|
||||
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final days = lastSevenDateKeys();
|
||||
return SeedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionLabel(
|
||||
icon: Icons.insights_outlined,
|
||||
text: '7-day pulse',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children:
|
||||
days.map((day) {
|
||||
final minutes = minutesForDate(state.sessions, day);
|
||||
final habits =
|
||||
state.habits
|
||||
.where((habit) => habit.completedDates.contains(day))
|
||||
.length;
|
||||
final hasReflection = state.reflections.any(
|
||||
(reflection) => reflection.dateKey == day,
|
||||
);
|
||||
final score =
|
||||
(minutes > 0 ? 1 : 0) +
|
||||
(habits > 0 ? 1 : 0) +
|
||||
(hasReflection ? 1 : 0);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 48 + (score * 8),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
score == 0
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(
|
||||
alpha: 0.35 + score * 0.15,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
day.substring(5),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user