checkpoint: focus seed 1.0.1 localized dev switch
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user