checkpoint: focus seed 1.0.1 localized dev switch

This commit is contained in:
2026-06-11 23:35:49 +08:00
parent c51abdfb73
commit d06dc35ee6
72 changed files with 6155 additions and 1390 deletions
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../widgets/dialogs.dart';
import '../today/widgets/compact_habit_row.dart';
Future<void> showHabitManagerSheet(
BuildContext context,
FocusSeedController controller,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => FractionallySizedBox(
heightFactor: 0.86,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final state = controller.state;
if (state == null) {
return const Center(child: CircularProgressIndicator());
}
final l10n = context.l10n;
final today = todayKey();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.habitsTitle,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
l10n.habitsSubtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton.filledTonal(
tooltip: l10n.addHabit,
onPressed:
() =>
showHabitDialog(context, controller.addHabit),
icon: const Icon(Icons.add_rounded),
),
],
),
),
const Divider(),
Expanded(
child:
state.habits.isEmpty
? _EmptyHabits(
onAdd:
() => showHabitDialog(
context,
controller.addHabit,
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
itemCount: state.habits.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (context, index) {
final habit = state.habits[index];
return CompactHabitRow(
habit: habit,
dateKey: today,
onToggle: () => controller.toggleHabit(habit),
onEdit:
() => showHabitDialog(
context,
(value) => controller.renameHabit(
habit,
value,
),
initialValue: habit.title,
),
onDelete:
() => confirmDeleteHabit(
context,
() => controller.deleteHabit(habit),
),
);
},
),
),
],
);
},
),
),
);
}
class _EmptyHabits extends StatelessWidget {
const _EmptyHabits({required this.onAdd});
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.eco_outlined,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
context.l10n.emptyHabitsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: Text(context.l10n.addHabit),
),
],
),
),
);
}
}
@@ -1,69 +0,0 @@
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)),
),
),
],
),
);
}
}
-147
View File
@@ -1,147 +0,0 @@
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,155 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../models/today_progress.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/surface_panel.dart';
import '../reflection/reflection_sheet.dart';
import 'widgets/growth_week_strip.dart';
import 'widgets/journey_timeline.dart';
class JourneyScreen extends StatelessWidget {
const JourneyScreen({required this.controller, super.key});
final FocusSeedController controller;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
final now = DateTime.now();
final progress = TodayProgress.fromState(state, todayKey());
final recentSessions = state.sessions.reversed.take(5).toList();
final recentReflections = state.reflections.reversed.take(3).toList();
return Column(
children: [
FocusSeedHeader(
title: l10n.journeyTitle,
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
JourneyTimeline(
progress: progress,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 4),
GrowthWeekStrip(state: state),
if (recentSessions.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentFocus,
detail: l10n.sessionsTotal(state.sessions.length),
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentSessions.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.timer_outlined),
title: Text(
l10n.focusMinutes(recentSessions[index].minutes),
),
subtitle: Text(
localizedReadableDate(
context,
recentSessions[index].completedAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
localizedTime(
context,
recentSessions[index].completedAt,
),
),
),
],
],
),
),
],
if (recentReflections.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentReflections,
detail: l10n.recentReflectionsDetail,
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentReflections.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(recentReflections[index].mood.icon),
title: Text(
recentReflections[index].note,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(recentReflections[index].dateKey),
),
],
],
),
),
],
],
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, required this.detail});
final String title;
final String detail;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/app_state.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/surface_panel.dart';
class GrowthWeekStrip extends StatelessWidget {
const GrowthWeekStrip({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
final l10n = context.l10n;
return SurfacePanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sevenDayGrowth,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.sevenDayGrowthSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Row(
children: [
for (final day in days)
Expanded(
child: _GrowthDay(
day: day,
stage: growthStageFor(state, day),
),
),
],
),
],
),
);
}
}
class _GrowthDay extends StatelessWidget {
const _GrowthDay({required this.day, required this.stage});
final String day;
final GrowthStage stage;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final icon = switch (stage) {
GrowthStage.seed => Icons.circle_outlined,
GrowthStage.sprout => Icons.grass_rounded,
GrowthStage.leaves => Icons.eco_rounded,
GrowthStage.flower => Icons.local_florist_rounded,
};
return Semantics(
label: l10n.growthDaySemantic(
day.substring(5),
localizedGrowthStage(l10n, stage),
),
child: Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
stage == GrowthStage.seed
? theme.colorScheme.surfaceContainer
: theme.colorScheme.primary.withValues(alpha: 0.14),
),
child: Icon(
icon,
size: 19,
color:
stage == GrowthStage.seed
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.primary,
),
),
const SizedBox(height: 5),
Text(day.substring(8), style: theme.textTheme.labelSmall),
],
),
);
}
}
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/surface_panel.dart';
class JourneyTimeline extends StatelessWidget {
const JourneyTimeline({
required this.progress,
required this.onReflect,
super.key,
});
final TodayProgress progress;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final items = [
_TimelineData(
title: l10n.progressIntention,
detail:
progress.hasIntention ? progress.intention : l10n.chooseOneOutcome,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
),
_TimelineData(
title: l10n.progressFocus,
detail:
progress.hasFocus
? l10n.focusedMinutes(progress.focusMinutes)
: l10n.noFocusSessionYet,
icon: Icons.timer_outlined,
complete: progress.hasFocus,
),
_TimelineData(
title: l10n.progressHabits,
detail: l10n.habitsCompleted(
progress.completedHabits,
progress.totalHabits,
),
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_TimelineData(
title: l10n.progressReflect,
detail:
progress.hasReflection
? l10n.reflectionCaptured
: l10n.reflectionAction,
icon: Icons.auto_awesome_outlined,
complete: progress.hasReflection,
action: progress.hasReflection ? null : onReflect,
),
_TimelineData(
title: l10n.growthTitle,
detail:
progress.hasReflection ? l10n.growthFlowerOpen : l10n.growthLocked,
icon: Icons.local_florist_outlined,
complete: progress.hasReflection,
),
];
return Column(
children: [
for (var index = 0; index < items.length; index++)
_TimelineItem(data: items[index], isLast: index == items.length - 1),
],
);
}
}
class _TimelineItem extends StatelessWidget {
const _TimelineItem({required this.data, required this.isLast});
final _TimelineData data;
final bool isLast;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final accent =
data.icon == Icons.auto_awesome_outlined
? visuals.reflection
: theme.colorScheme.primary;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 38,
child: Column(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
data.complete
? accent
: theme.colorScheme.surfaceContainerLowest,
border: Border.all(color: accent),
),
child: Icon(
data.complete ? Icons.check_rounded : data.icon,
size: 16,
color: data.complete ? theme.colorScheme.onPrimary : accent,
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
color:
data.complete
? accent.withValues(alpha: 0.55)
: theme.colorScheme.outlineVariant,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SurfacePanel(
padding: const EdgeInsets.all(14),
borderColor:
data.action == null ? null : accent.withValues(alpha: 0.5),
onTap: data.action,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.title, style: theme.textTheme.labelLarge),
const SizedBox(height: 3),
Text(
data.detail,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (data.action != null)
Icon(Icons.arrow_forward_rounded, color: accent),
],
),
),
),
),
],
),
);
}
}
class _TimelineData {
const _TimelineData({
required this.title,
required this.detail,
required this.icon,
required this.complete,
this.action,
});
final String title;
final String detail;
final IconData icon;
final bool complete;
final VoidCallback? action;
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/settings_tile.dart';
import '../../widgets/surface_panel.dart';
class MeScreen extends StatelessWidget {
const MeScreen({
required this.controller,
required this.appearance,
required this.onAppearanceChanged,
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
super.key,
});
final FocusSeedController controller;
final AppearancePreference appearance;
final ValueChanged<AppearancePreference> onAppearanceChanged;
final LanguagePreference language;
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
return Column(
children: [
FocusSeedHeader(
title: l10n.meTitle,
subtitle: l10n.meSubtitle,
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
Text(
l10n.appearance,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SurfacePanel(
child: SizedBox(
width: double.infinity,
child: SegmentedButton<AppearancePreference>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: AppearancePreference.system,
icon: const Icon(Icons.brightness_auto_outlined),
label: Text(l10n.system),
),
ButtonSegment(
value: AppearancePreference.light,
icon: const Icon(Icons.light_mode_outlined),
label: Text(l10n.light),
),
ButtonSegment(
value: AppearancePreference.dark,
icon: const Icon(Icons.dark_mode_outlined),
label: Text(l10n.dark),
),
],
selected: {appearance},
onSelectionChanged:
(selection) => onAppearanceChanged(selection.first),
),
),
),
if (showLanguageTools) ...[
const SizedBox(height: 18),
_SectionHeader(
title: l10n.language,
detail: l10n.developmentOnly,
),
const SizedBox(height: 8),
SurfacePanel(
child: SizedBox(
width: double.infinity,
child: SegmentedButton<LanguagePreference>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: LanguagePreference.defaultLocale,
icon: const Icon(Icons.tune_rounded),
label: Text(l10n.languageDefault),
),
ButtonSegment(
value: LanguagePreference.english,
icon: const Icon(Icons.language_rounded),
label: Text(l10n.languageEnglish),
),
ButtonSegment(
value: LanguagePreference.chinese,
icon: const Icon(Icons.translate_rounded),
label: Text(l10n.languageChinese),
),
],
selected: {language},
onSelectionChanged:
(selection) => onLanguageChanged(selection.first),
),
),
),
],
const SizedBox(height: 18),
Text(
l10n.yourData,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Column(
children: [
SettingsTile(
icon: Icons.ios_share_outlined,
title: l10n.exportJson,
subtitle: l10n.exportJsonSubtitle,
onTap: () => showExportDialog(context, state),
),
const Divider(),
SettingsTile(
icon: Icons.file_upload_outlined,
title: l10n.importJson,
subtitle: l10n.importJsonSubtitle,
onTap:
() => showImportDialog(context, (raw) async {
final imported = await controller.importState(raw);
if (context.mounted) {
showSnack(
context,
imported
? l10n.dataImported
: l10n.importFailed,
);
}
}),
),
const Divider(),
SettingsTile(
icon: Icons.privacy_tip_outlined,
title: l10n.privacyTitle,
subtitle: l10n.privacySubtitle,
onTap: () => showPrivacySheet(context),
),
],
),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SettingsTile(
icon: Icons.delete_outline,
title: l10n.resetLocalGarden,
subtitle: l10n.resetLocalGardenSubtitle,
destructive: true,
onTap:
() => confirmClearData(context, () async {
final cleared = await controller.clearData();
if (context.mounted && cleared) {
showSnack(context, l10n.localDataCleared);
}
}),
),
),
const SizedBox(height: 18),
Text(
'Focus Seed 1.0.1',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.detail});
final String title;
final String detail;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(child: Text(title, style: theme.textTheme.titleMedium)),
Text(
detail,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
@@ -1,123 +0,0 @@
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,164 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart';
import '../../models/mood.dart';
Future<void> showReflectionSheet(
BuildContext context,
FocusSeedController controller,
) {
final state = controller.state;
if (state == null) {
return Future.value();
}
final existing =
state.reflections
.where((entry) => entry.dateKey == todayKey())
.firstOrNull;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => _ReflectionComposer(
initialNote: existing?.note ?? '',
initialMood: existing?.mood ?? Mood.clear,
onSave: controller.saveReflection,
),
);
}
class _ReflectionComposer extends StatefulWidget {
const _ReflectionComposer({
required this.initialNote,
required this.initialMood,
required this.onSave,
});
final String initialNote;
final Mood initialMood;
final Future<bool> Function(String note, Mood mood) onSave;
@override
State<_ReflectionComposer> createState() => _ReflectionComposerState();
}
class _ReflectionComposerState extends State<_ReflectionComposer> {
late final TextEditingController _controller;
late Mood _mood;
bool _saving = false;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialNote);
_mood = widget.initialMood;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
final l10n = context.l10n;
if (_controller.text.trim().isEmpty) {
setState(() => _errorText = l10n.reflectionEmptyError);
return;
}
setState(() {
_saving = true;
_errorText = null;
});
final saved = await widget.onSave(_controller.text, _mood);
if (!mounted) {
return;
}
if (saved) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_errorText = l10n.reflectionSaveError;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
4,
20,
MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.reflectionSheetTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
l10n.reflectionSheetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mood in Mood.values)
ChoiceChip(
avatar: Icon(mood.icon, size: 17),
label: Text(localizedMood(l10n, mood)),
selected: _mood == mood,
onSelected: (_) => setState(() => _mood = mood),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _controller,
autofocus: widget.initialNote.isEmpty,
minLines: 4,
maxLines: 7,
maxLength: 280,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: l10n.reflectionHint,
errorText: _errorText,
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon:
_saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome_outlined),
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
),
),
],
),
),
);
}
}
@@ -1,70 +0,0 @@
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),
),
),
],
),
);
}
}
+148 -233
View File
@@ -1,269 +1,184 @@
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 '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.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';
import '../journey/journey_screen.dart';
import '../me/me_screen.dart';
import '../today/today_screen.dart';
class FocusSeedHome extends StatefulWidget {
const FocusSeedHome({super.key});
const FocusSeedHome({
required this.appearance,
required this.onAppearanceChanged,
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
super.key,
});
final AppearancePreference appearance;
final ValueChanged<AppearancePreference> onAppearanceChanged;
final LanguagePreference language;
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
@override
State<FocusSeedHome> createState() => _FocusSeedHomeState();
}
class _FocusSeedHomeState extends State<FocusSeedHome> {
final _store = FocusSeedStore();
final _analytics = const NoopAnalyticsProvider();
AppState? _state;
FocusSeedController? _controller;
FocusTimerController? _timerController;
int _tabIndex = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final state = await _store.load();
if (!mounted) {
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) {
return;
}
setState(() => _state = state);
}
Future<void> _setStateAndSave(AppState state) async {
setState(() => _state = state);
await _store.save(state);
_controller = FocusSeedController(
defaultHabitTitles: localizedStarterHabits(context.l10n),
);
_timerController = FocusTimerController(
onCompleted: (minutes) async {
final saved = await _controller!.completeFocusSession(minutes);
if (saved) {
await HapticFeedback.mediumImpact();
}
},
);
_controller!.load();
}
@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,
final controller = _controller;
final timerController = _timerController;
if (controller == null || timerController == null) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
controller.updateDefaultHabitTitles(
localizedStarterHabits(context.l10n),
);
final state = controller.state;
if (controller.isLoading) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
if (state == null) {
final l10n = context.l10n;
return Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
HomeScreen(
state: state,
onAnchorChanged:
(anchor) => _setStateAndSave(
state.copyWith(anchor: anchor.trim()),
),
onOpenTimer: () => setState(() => _tabIndex = 1),
onToggleHabit: _toggleHabit,
onAddHabit: _addHabit,
const Icon(Icons.error_outline_rounded, size: 42),
const SizedBox(height: 12),
Text(
_messageForError(l10n, controller.error) ??
l10n.couldNotOpenApp,
textAlign: TextAlign.center,
),
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,
const SizedBox(height: 16),
FilledButton.icon(
onPressed: controller.load,
icon: const Icon(Icons.refresh_rounded),
label: Text(l10n.tryAgain),
),
],
),
),
),
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',
),
),
);
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor:
theme.navigationBarTheme.backgroundColor ??
theme.colorScheme.surface,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
),
child: Scaffold(
body: SafeArea(
child: IndexedStack(
index: _tabIndex,
children: [
TodayScreen(
controller: controller,
timerController: timerController,
),
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',
JourneyScreen(controller: controller),
MeScreen(
controller: controller,
appearance: widget.appearance,
onAppearanceChanged: widget.onAppearanceChanged,
language: widget.language,
showLanguageTools: widget.showLanguageTools,
onLanguageChanged: widget.onLanguageChanged,
),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (index) {
HapticFeedback.selectionClick();
setState(() => _tabIndex = index);
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.eco_outlined),
selectedIcon: const Icon(Icons.eco_rounded),
label: context.l10n.navToday,
),
NavigationDestination(
icon: const Icon(Icons.landscape_outlined),
selectedIcon: const Icon(Icons.landscape_rounded),
label: context.l10n.navJourney,
),
NavigationDestination(
icon: const Icon(Icons.person_outline_rounded),
selectedIcon: const Icon(Icons.person_rounded),
label: context.l10n.navMe,
),
],
),
),
);
},
);
}
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');
}
@override
void dispose() {
_timerController?.dispose();
_controller?.dispose();
super.dispose();
}
}
String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
return switch (error) {
FocusSeedError.openFailed => l10n.openFailed,
FocusSeedError.saveFailed => l10n.saveFailed,
null => null,
};
}
-212
View File
@@ -1,212 +0,0 @@
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'),
),
],
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart';
import '../../models/today_progress.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/surface_panel.dart';
import '../habits/habit_manager_sheet.dart';
import '../reflection/reflection_sheet.dart';
import 'widgets/daily_progress_rail.dart';
import 'widgets/focus_session_card.dart';
import 'widgets/habit_section.dart';
import 'widgets/intention_card.dart';
import 'widgets/reflection_prompt.dart';
class TodayScreen extends StatelessWidget {
const TodayScreen({
required this.controller,
required this.timerController,
super.key,
});
final FocusSeedController controller;
final FocusTimerController timerController;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
final now = DateTime.now();
final today = todayKey();
final progress = TodayProgress.fromState(state, today);
final reflection =
state.reflections.where((entry) => entry.dateKey == today).firstOrNull;
void editIntention() {
showIntentionDialog(
context,
controller.setTodayAnchor,
initialValue: progress.intention,
);
}
return Column(
children: [
FocusSeedHeader(
title: localizedGreeting(l10n, now),
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
if (controller.error != null) ...[
_InlineError(
message: _messageForError(l10n, controller.error!),
),
const SizedBox(height: 10),
],
IntentionCard(
intention: progress.intention,
onEdit: editIntention,
),
const SizedBox(height: 12),
FocusSessionCard(
controller: timerController,
growthStage: progress.growthStage,
hasIntention: progress.hasIntention,
onSetIntention: editIntention,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: DailyProgressRail(progress: progress),
),
const SizedBox(height: 12),
HabitSection(
habits: state.habits,
dateKey: today,
onToggle: controller.toggleHabit,
onAdd: () => showHabitDialog(context, controller.addHabit),
onManage: () => showHabitManagerSheet(context, controller),
),
const SizedBox(height: 12),
ReflectionPrompt(
reflection: reflection,
onTap: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
],
),
),
],
);
}
}
String _messageForError(AppLocalizations l10n, FocusSeedError error) {
return switch (error) {
FocusSeedError.openFailed => l10n.openFailed,
FocusSeedError.saveFailed => l10n.saveFailed,
};
}
class _InlineError extends StatelessWidget {
const _InlineError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline, color: scheme.onErrorContainer),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(color: scheme.onErrorContainer),
),
),
],
),
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
class CompactHabitRow extends StatelessWidget {
const CompactHabitRow({
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 theme = Theme.of(context);
final l10n = context.l10n;
final complete = habit.completedDates.contains(dateKey);
return Semantics(
button: true,
checked: complete,
label: habit.title,
child: InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
complete ? theme.colorScheme.primary : Colors.transparent,
border: Border.all(
color:
complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Icon(
complete ? Icons.eco_rounded : Icons.circle_outlined,
key: ValueKey(complete),
size: 16,
color:
complete
? theme.colorScheme.onPrimary
: Colors.transparent,
),
),
),
const SizedBox(width: 11),
Expanded(
child: Text(
habit.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color:
complete
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
decoration: complete ? TextDecoration.lineThrough : null,
),
),
),
if (onEdit != null || onDelete != null)
PopupMenuButton<String>(
tooltip: l10n.habitOptions,
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
} else if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => [
PopupMenuItem(value: 'edit', child: Text(l10n.rename)),
PopupMenuItem(
value: 'delete',
child: Text(l10n.delete),
),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
class DailyProgressRail extends StatelessWidget {
const DailyProgressRail({required this.progress, super.key});
final TodayProgress progress;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final items = [
_ProgressItem(
label: l10n.progressIntention,
detail: progress.hasIntention ? l10n.done : l10n.next,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
),
_ProgressItem(
label: l10n.progressFocus,
detail:
progress.hasFocus
? l10n.minutesShort(progress.focusMinutes)
: l10n.next,
icon: Icons.timer_outlined,
complete: progress.hasFocus,
),
_ProgressItem(
label: l10n.progressHabits,
detail: '${progress.completedHabits}/${progress.totalHabits}',
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_ProgressItem(
label: l10n.progressReflect,
detail: progress.hasReflection ? l10n.done : l10n.later,
icon: Icons.auto_awesome_outlined,
complete: progress.hasReflection,
),
];
final textScale = MediaQuery.textScalerOf(context).scale(12) / 12;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 320 || textScale > 1.35) {
return _VerticalProgress(items: items);
}
return _HorizontalProgress(items: items);
},
);
}
}
class _HorizontalProgress extends StatelessWidget {
const _HorizontalProgress({required this.items});
final List<_ProgressItem> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Semantics(
label: context.l10n.dailyStepsComplete(
items.where((item) => item.complete).length,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: Column(
children: [
SizedBox(
height: 34,
child: Stack(
alignment: Alignment.center,
children: [
if (index > 0)
Align(
alignment: Alignment.centerLeft,
child: Container(
width: double.infinity,
height: 2,
color:
items[index - 1].complete
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
if (index < items.length - 1)
Align(
alignment: Alignment.centerRight,
child: Container(
width: double.infinity,
height: 2,
color:
items[index].complete
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
_ProgressDot(item: items[index]),
],
),
),
const SizedBox(height: 5),
Text(
items[index].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
items[index].detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
],
),
),
],
),
);
}
}
class _VerticalProgress extends StatelessWidget {
const _VerticalProgress({required this.items});
final List<_ProgressItem> items;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var index = 0; index < items.length; index++) ...[
Row(
children: [
_ProgressDot(item: items[index]),
const SizedBox(width: 10),
Expanded(
child: Text(
items[index].label,
style: Theme.of(context).textTheme.labelLarge,
),
),
Text(items[index].detail),
],
),
if (index < items.length - 1) const SizedBox(height: 10),
],
],
);
}
}
class _ProgressDot extends StatelessWidget {
const _ProgressDot({required this.item});
final _ProgressItem item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
item.complete
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerLowest,
border: Border.all(
color:
item.complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
),
child: Icon(
item.complete ? Icons.check_rounded : item.icon,
size: 17,
color:
item.complete
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
);
}
}
class _ProgressItem {
const _ProgressItem({
required this.label,
required this.detail,
required this.icon,
required this.complete,
});
final String label;
final String detail;
final IconData icon;
final bool complete;
}
@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../application/focus_timer_controller.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/growth_object.dart';
import '../../../widgets/surface_panel.dart';
class FocusSessionCard extends StatelessWidget {
const FocusSessionCard({
required this.controller,
required this.growthStage,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
super.key,
});
final FocusTimerController controller;
final GrowthStage growthStage;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return SurfacePanel(
elevated: true,
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.focusLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(16) / 16;
final stack = constraints.maxWidth < 260 || textScale > 1.6;
final details = _TimerDetails(controller: controller);
final plant = GrowthObject(
stage: growthStage,
size: stack ? 116 : 122,
);
if (stack) {
return Column(
children: [plant, const SizedBox(height: 4), details],
);
}
return Row(
children: [
Expanded(child: details),
const SizedBox(width: 8),
plant,
],
);
},
),
const SizedBox(height: 14),
if (!controller.isRunning && !controller.isCompleted)
SizedBox(
width: double.infinity,
child: SegmentedButton<int>(
segments: [
for (final minutes in FocusTimerController.presets)
ButtonSegment(value: minutes, label: Text('$minutes')),
],
selected: {controller.selectedMinutes},
onSelectionChanged: (selection) {
HapticFeedback.selectionClick();
controller.selectMinutes(selection.first);
},
),
),
if (controller.isRunning || controller.isPaused) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: controller.progress,
minHeight: 7,
),
),
],
const SizedBox(height: 14),
_TimerActions(
controller: controller,
hasIntention: hasIntention,
onSetIntention: onSetIntention,
onReflect: onReflect,
),
],
),
);
},
);
}
}
class _TimerDetails extends StatelessWidget {
const _TimerDetails({required this.controller});
final FocusTimerController controller;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final status = switch (controller.status) {
FocusTimerStatus.idle => l10n.timerReady,
FocusTimerStatus.running => l10n.timerRunning,
FocusTimerStatus.paused => l10n.timerPaused,
FocusTimerStatus.completed => l10n.timerCompleted,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Text(
formatSeconds(controller.remainingSeconds),
key: ValueKey(controller.remainingSeconds),
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
controller.isCompleted
? theme.colorScheme.tertiary
: theme.colorScheme.primary,
),
),
const SizedBox(width: 7),
Flexible(
child: Text(
status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
);
}
}
class _TimerActions extends StatelessWidget {
const _TimerActions({
required this.controller,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
});
final FocusTimerController controller;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (controller.isCompleted) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: onReflect,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.reflectOneMinute),
),
),
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.startAnotherSession,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
);
}
if (!hasIntention && !controller.isRunning && !controller.isPaused) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onSetIntention,
icon: const Icon(Icons.flag_outlined),
label: Text(l10n.setTodaysIntention),
),
);
}
if (controller.isRunning) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: controller.pause,
icon: const Icon(Icons.pause_rounded),
label: Text(l10n.pause),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: controller.completeNow,
icon: const Icon(Icons.stop_rounded),
label: Text(l10n.end),
),
],
);
}
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {
HapticFeedback.selectionClick();
controller.start();
},
icon: Icon(
controller.isPaused
? Icons.play_arrow_rounded
: Icons.play_circle_outline_rounded,
),
label: Text(
controller.isPaused
? l10n.resume
: l10n.startMinutes(controller.selectedMinutes),
),
),
),
if (controller.isPaused) ...[
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.resetTimer,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
],
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
import '../../../widgets/surface_panel.dart';
import 'compact_habit_row.dart';
class HabitSection extends StatelessWidget {
const HabitSection({
required this.habits,
required this.dateKey,
required this.onToggle,
required this.onAdd,
required this.onManage,
super.key,
});
final List<Habit> habits;
final String dateKey;
final ValueChanged<Habit> onToggle;
final VoidCallback onAdd;
final VoidCallback onManage;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final completed =
habits.where((habit) => habit.completedDates.contains(dateKey)).length;
return SurfacePanel(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todaysHabits,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
l10n.completedCount(completed, habits.length),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
tooltip: l10n.manageHabits,
onPressed: onManage,
icon: const Icon(Icons.tune_rounded),
),
IconButton.filledTonal(
tooltip: l10n.addHabit,
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
),
],
),
const SizedBox(height: 4),
if (habits.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 20),
child: Row(
children: [
Icon(
Icons.eco_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(child: Text(l10n.emptyHabitPrompt)),
TextButton(onPressed: onAdd, child: Text(l10n.add)),
],
),
)
else
for (var index = 0; index < habits.take(4).length; index++) ...[
if (index > 0) const Divider(),
CompactHabitRow(
habit: habits[index],
dateKey: dateKey,
onToggle: () => onToggle(habits[index]),
),
],
if (habits.length > 4) ...[
const Divider(),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onManage,
icon: const Icon(Icons.arrow_forward_rounded, size: 18),
label: Text(l10n.viewAllHabits(habits.length)),
),
),
],
],
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../widgets/surface_panel.dart';
class IntentionCard extends StatelessWidget {
const IntentionCard({
required this.intention,
required this.onEdit,
super.key,
});
final String intention;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final isEmpty = intention.isEmpty;
return SurfacePanel(
elevated: !isEmpty,
onTap: onEdit,
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.11),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.flag_outlined, color: theme.colorScheme.primary),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todayIntentionLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
isEmpty ? l10n.chooseOneOutcome : intention,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontSize: 18,
color:
isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
isEmpty ? Icons.add_rounded : Icons.edit_outlined,
color: theme.colorScheme.onSurfaceVariant,
size: 21,
),
],
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/reflection_entry.dart';
import '../../../widgets/surface_panel.dart';
class ReflectionPrompt extends StatelessWidget {
const ReflectionPrompt({
required this.reflection,
required this.onTap,
super.key,
});
final ReflectionEntry? reflection;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final isComplete = reflection != null;
return SurfacePanel(
borderColor: visuals.reflection.withValues(alpha: 0.35),
color: visuals.reflection.withValues(alpha: 0.06),
onTap: onTap,
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: visuals.reflection.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isComplete ? Icons.local_florist_rounded : Icons.edit_outlined,
color: visuals.reflection,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isComplete
? l10n.reflectionSaved
: l10n.reflectionPromptTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 3),
Text(
isComplete ? reflection!.note : l10n.reflectionPromptBody,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
isComplete ? Icons.edit_outlined : Icons.arrow_forward_rounded,
color: visuals.reflection,
),
],
),
);
}
}