From eb8b209c0c61efe7a38eeacda4893420d45d8af6 Mon Sep 17 00:00:00 2001 From: leavy Date: Fri, 12 Jun 2026 01:26:19 +0800 Subject: [PATCH] feat: add journey stats and history detail --- lib/src/features/journey/history_screen.dart | 201 ++++++++++++++++++ lib/src/features/journey/journey_screen.dart | 29 ++- .../journey/widgets/journey_stats_panel.dart | 129 +++++++++++ lib/src/l10n/app_en.arb | 30 +++ lib/src/l10n/app_zh.arb | 30 +++ lib/src/l10n/generated/app_localizations.dart | 96 +++++++++ .../l10n/generated/app_localizations_en.dart | 54 +++++ .../l10n/generated/app_localizations_zh.dart | 52 +++++ lib/src/models/journey_stats.dart | 50 +++++ test/widget_test.dart | 31 +++ 10 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 lib/src/features/journey/history_screen.dart create mode 100644 lib/src/features/journey/widgets/journey_stats_panel.dart create mode 100644 lib/src/models/journey_stats.dart diff --git a/lib/src/features/journey/history_screen.dart b/lib/src/features/journey/history_screen.dart new file mode 100644 index 0000000..00bfaee --- /dev/null +++ b/lib/src/features/journey/history_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; + +import '../../application/focus_seed_controller.dart'; +import '../../l10n/l10n.dart'; +import '../../models/focus_session.dart'; +import '../../models/reflection_entry.dart'; +import '../../widgets/surface_panel.dart'; + +enum _HistoryView { focus, notes } + +class HistoryScreen extends StatefulWidget { + const HistoryScreen({required this.controller, super.key}); + + final FocusSeedController controller; + + @override + State createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends State { + _HistoryView _view = _HistoryView.focus; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar(title: Text(l10n.historyTitle)), + body: SafeArea( + top: false, + child: AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final state = widget.controller.state!; + final focusSessions = state.sessions.reversed.toList(); + final reflections = state.reflections.reversed.toList(); + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), + children: [ + SizedBox( + width: double.infinity, + child: SegmentedButton<_HistoryView>( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: _HistoryView.focus, + icon: const Icon(Icons.timer_outlined), + label: Text(l10n.focusHistory), + ), + ButtonSegment( + value: _HistoryView.notes, + icon: const Icon(Icons.edit_note_rounded), + label: Text(l10n.notesHistory), + ), + ], + selected: {_view}, + onSelectionChanged: + (selection) => setState(() => _view = selection.first), + ), + ), + const SizedBox(height: 14), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: + _view == _HistoryView.focus + ? _FocusHistoryList( + key: const ValueKey('focus-history'), + sessions: focusSessions, + ) + : _ReflectionHistoryList( + key: const ValueKey('reflection-history'), + reflections: reflections, + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _FocusHistoryList extends StatelessWidget { + const _FocusHistoryList({required this.sessions, super.key}); + + final List sessions; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (sessions.isEmpty) { + return _EmptyHistory( + title: l10n.emptyFocusHistoryTitle, + body: l10n.emptyFocusHistoryBody, + ); + } + return SurfacePanel( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + child: Column( + children: [ + for (var index = 0; index < sessions.length; index++) ...[ + if (index > 0) const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.timer_outlined), + title: Text(l10n.focusMinutes(sessions[index].minutes)), + subtitle: Text( + localizedReadableDate(context, sessions[index].completedAt), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + localizedTime(context, sessions[index].completedAt), + ), + ), + ], + ], + ), + ); + } +} + +class _ReflectionHistoryList extends StatelessWidget { + const _ReflectionHistoryList({required this.reflections, super.key}); + + final List reflections; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + if (reflections.isEmpty) { + return _EmptyHistory( + title: l10n.emptyNotesHistoryTitle, + body: l10n.emptyNotesHistoryBody, + ); + } + return SurfacePanel( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + child: Column( + children: [ + for (var index = 0; index < reflections.length; index++) ...[ + if (index > 0) const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(reflections[index].mood.icon), + title: Text( + reflections[index].note, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(reflections[index].dateKey), + ), + ], + ], + ), + ); + } +} + +class _EmptyHistory extends StatelessWidget { + const _EmptyHistory({required this.title, required this.body}); + + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SurfacePanel( + padding: const EdgeInsets.all(22), + child: Column( + children: [ + Icon( + Icons.auto_graph_rounded, + size: 38, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 10), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + body, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/journey/journey_screen.dart b/lib/src/features/journey/journey_screen.dart index 53aad7b..e62cda3 100644 --- a/lib/src/features/journey/journey_screen.dart +++ b/lib/src/features/journey/journey_screen.dart @@ -3,11 +3,14 @@ import 'package:flutter/material.dart'; import '../../application/focus_seed_controller.dart'; import '../../core/date_keys.dart'; import '../../l10n/l10n.dart'; +import '../../models/journey_stats.dart'; import '../../models/today_progress.dart'; import '../../widgets/focus_seed_header.dart'; import '../../widgets/surface_panel.dart'; import '../reflection/reflection_sheet.dart'; +import 'history_screen.dart'; import 'widgets/growth_week_strip.dart'; +import 'widgets/journey_stats_panel.dart'; import 'widgets/journey_timeline.dart'; class JourneyScreen extends StatelessWidget { @@ -21,6 +24,7 @@ class JourneyScreen extends StatelessWidget { final l10n = context.l10n; final now = DateTime.now(); final progress = TodayProgress.fromState(state, todayKey()); + final stats = JourneyStats.fromState(state); final recentSessions = state.sessions.reversed.take(5).toList(); final recentReflections = state.reflections.reversed.take(3).toList(); @@ -39,7 +43,30 @@ class JourneyScreen extends StatelessWidget { progress: progress, onReflect: () => showReflectionSheet(context, controller), ), - const SizedBox(height: 4), + const SizedBox(height: 12), + JourneyStatsPanel(stats: stats), + const SizedBox(height: 12), + SurfacePanel( + padding: const EdgeInsets.symmetric(horizontal: 14), + onTap: + () => Navigator.of(context).push( + MaterialPageRoute( + builder: + (context) => HistoryScreen(controller: controller), + ), + ), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.history_rounded), + title: Text(l10n.viewHistory), + subtitle: Text(l10n.viewHistorySubtitle), + trailing: const Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + ), + ), + ), + const SizedBox(height: 12), GrowthWeekStrip(state: state), if (recentSessions.isNotEmpty) ...[ const SizedBox(height: 18), diff --git a/lib/src/features/journey/widgets/journey_stats_panel.dart b/lib/src/features/journey/widgets/journey_stats_panel.dart new file mode 100644 index 0000000..839a377 --- /dev/null +++ b/lib/src/features/journey/widgets/journey_stats_panel.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../models/journey_stats.dart'; +import '../../../widgets/surface_panel.dart'; + +class JourneyStatsPanel extends StatelessWidget { + const JourneyStatsPanel({required this.stats, super.key}); + + final JourneyStats stats; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return SurfacePanel( + elevated: true, + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.journeyStatsTitle, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 520 ? 4 : 2; + return GridView.count( + crossAxisCount: columns, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: columns == 4 ? 1.35 : 1.85, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + _StatItem( + icon: Icons.timer_outlined, + animatedTarget: stats.weekFocusMinutes, + valueBuilder: (value) => value.toString(), + label: l10n.weekFocusLabel, + ), + _StatItem( + icon: Icons.local_fire_department_outlined, + animatedTarget: stats.currentStreakDays, + valueBuilder: l10n.daysValue, + label: l10n.streakLabel, + ), + _StatItem( + icon: Icons.calendar_month_outlined, + animatedTarget: stats.activeDaysThisWeek, + valueBuilder: l10n.activeDaysValue, + label: l10n.activeDaysLabel, + ), + _StatItem( + icon: Icons.check_circle_outline_rounded, + animatedTarget: (stats.habitCompletionRate * 100).round(), + valueBuilder: (value) => '$value%', + label: l10n.habitRateLabel, + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _StatItem extends StatelessWidget { + const _StatItem({ + required this.icon, + required this.animatedTarget, + required this.valueBuilder, + required this.label, + }); + + final IconData icon; + final int animatedTarget; + final String Function(int value) valueBuilder; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(icon, color: scheme.primary, size: 20), + TweenAnimationBuilder( + tween: Tween(end: animatedTarget.toDouble()), + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + builder: + (context, animatedValue, _) => Text( + valueBuilder(animatedValue.round()), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/l10n/app_en.arb b/lib/src/l10n/app_en.arb index 982ded5..b115af6 100644 --- a/lib/src/l10n/app_en.arb +++ b/lib/src/l10n/app_en.arb @@ -159,6 +159,36 @@ "todayDataCleared": "Today's data cleared", "localDataCleared": "Local data deleted", "journeyTitle": "Today's journey", + "journeyStatsTitle": "This week", + "weekFocusLabel": "Focus", + "streakLabel": "Streak", + "activeDaysLabel": "Active", + "habitRateLabel": "Habits", + "daysValue": "{count}d", + "@daysValue": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "activeDaysValue": "{count}/7", + "@activeDaysValue": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "viewHistory": "View history", + "viewHistorySubtitle": "Review focus sessions and reflections.", + "historyTitle": "History", + "focusHistory": "Focus", + "notesHistory": "Notes", + "emptyFocusHistoryTitle": "No focus sessions yet", + "emptyFocusHistoryBody": "Start one session from Today and it will appear here.", + "emptyNotesHistoryTitle": "No notes yet", + "emptyNotesHistoryBody": "Save a short reflection after focus to make your progress visible.", "recentFocus": "Recent focus", "sessionsTotal": "{count} sessions total", "@sessionsTotal": { diff --git a/lib/src/l10n/app_zh.arb b/lib/src/l10n/app_zh.arb index d34a242..15713d8 100644 --- a/lib/src/l10n/app_zh.arb +++ b/lib/src/l10n/app_zh.arb @@ -159,6 +159,36 @@ "todayDataCleared": "今日数据已清理", "localDataCleared": "本地数据已删除", "journeyTitle": "\u4eca\u65e5\u8fdb\u5c55", + "journeyStatsTitle": "本周", + "weekFocusLabel": "专注", + "streakLabel": "连续", + "activeDaysLabel": "活跃", + "habitRateLabel": "习惯", + "daysValue": "{count}天", + "@daysValue": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "activeDaysValue": "{count}/7", + "@activeDaysValue": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "viewHistory": "查看历史", + "viewHistorySubtitle": "回看专注记录和复盘。", + "historyTitle": "历史", + "focusHistory": "专注", + "notesHistory": "复盘", + "emptyFocusHistoryTitle": "还没有专注记录", + "emptyFocusHistoryBody": "从今日页开始一轮专注,完成后会出现在这里。", + "emptyNotesHistoryTitle": "还没有复盘", + "emptyNotesHistoryBody": "专注后保存一句复盘,让进展更清晰。", "recentFocus": "\u6700\u8fd1\u4e13\u6ce8", "sessionsTotal": "\u5171 {count} \u6b21", "@sessionsTotal": { diff --git a/lib/src/l10n/generated/app_localizations.dart b/lib/src/l10n/generated/app_localizations.dart index 28cc1f3..60b7288 100644 --- a/lib/src/l10n/generated/app_localizations.dart +++ b/lib/src/l10n/generated/app_localizations.dart @@ -764,6 +764,102 @@ abstract class AppLocalizations { /// **'Today\'s journey'** String get journeyTitle; + /// No description provided for @journeyStatsTitle. + /// + /// In en, this message translates to: + /// **'This week'** + String get journeyStatsTitle; + + /// No description provided for @weekFocusLabel. + /// + /// In en, this message translates to: + /// **'Focus'** + String get weekFocusLabel; + + /// No description provided for @streakLabel. + /// + /// In en, this message translates to: + /// **'Streak'** + String get streakLabel; + + /// No description provided for @activeDaysLabel. + /// + /// In en, this message translates to: + /// **'Active'** + String get activeDaysLabel; + + /// No description provided for @habitRateLabel. + /// + /// In en, this message translates to: + /// **'Habits'** + String get habitRateLabel; + + /// No description provided for @daysValue. + /// + /// In en, this message translates to: + /// **'{count}d'** + String daysValue(int count); + + /// No description provided for @activeDaysValue. + /// + /// In en, this message translates to: + /// **'{count}/7'** + String activeDaysValue(int count); + + /// No description provided for @viewHistory. + /// + /// In en, this message translates to: + /// **'View history'** + String get viewHistory; + + /// No description provided for @viewHistorySubtitle. + /// + /// In en, this message translates to: + /// **'Review focus sessions and reflections.'** + String get viewHistorySubtitle; + + /// No description provided for @historyTitle. + /// + /// In en, this message translates to: + /// **'History'** + String get historyTitle; + + /// No description provided for @focusHistory. + /// + /// In en, this message translates to: + /// **'Focus'** + String get focusHistory; + + /// No description provided for @notesHistory. + /// + /// In en, this message translates to: + /// **'Notes'** + String get notesHistory; + + /// No description provided for @emptyFocusHistoryTitle. + /// + /// In en, this message translates to: + /// **'No focus sessions yet'** + String get emptyFocusHistoryTitle; + + /// No description provided for @emptyFocusHistoryBody. + /// + /// In en, this message translates to: + /// **'Start one session from Today and it will appear here.'** + String get emptyFocusHistoryBody; + + /// No description provided for @emptyNotesHistoryTitle. + /// + /// In en, this message translates to: + /// **'No notes yet'** + String get emptyNotesHistoryTitle; + + /// No description provided for @emptyNotesHistoryBody. + /// + /// In en, this message translates to: + /// **'Save a short reflection after focus to make your progress visible.'** + String get emptyNotesHistoryBody; + /// No description provided for @recentFocus. /// /// In en, this message translates to: diff --git a/lib/src/l10n/generated/app_localizations_en.dart b/lib/src/l10n/generated/app_localizations_en.dart index 31a81ee..6cbfc6f 100644 --- a/lib/src/l10n/generated/app_localizations_en.dart +++ b/lib/src/l10n/generated/app_localizations_en.dart @@ -358,6 +358,60 @@ class AppLocalizationsEn extends AppLocalizations { @override String get journeyTitle => 'Today\'s journey'; + @override + String get journeyStatsTitle => 'This week'; + + @override + String get weekFocusLabel => 'Focus'; + + @override + String get streakLabel => 'Streak'; + + @override + String get activeDaysLabel => 'Active'; + + @override + String get habitRateLabel => 'Habits'; + + @override + String daysValue(int count) { + return '${count}d'; + } + + @override + String activeDaysValue(int count) { + return '$count/7'; + } + + @override + String get viewHistory => 'View history'; + + @override + String get viewHistorySubtitle => 'Review focus sessions and reflections.'; + + @override + String get historyTitle => 'History'; + + @override + String get focusHistory => 'Focus'; + + @override + String get notesHistory => 'Notes'; + + @override + String get emptyFocusHistoryTitle => 'No focus sessions yet'; + + @override + String get emptyFocusHistoryBody => + 'Start one session from Today and it will appear here.'; + + @override + String get emptyNotesHistoryTitle => 'No notes yet'; + + @override + String get emptyNotesHistoryBody => + 'Save a short reflection after focus to make your progress visible.'; + @override String get recentFocus => 'Recent focus'; diff --git a/lib/src/l10n/generated/app_localizations_zh.dart b/lib/src/l10n/generated/app_localizations_zh.dart index 4f82f55..4bc73bd 100644 --- a/lib/src/l10n/generated/app_localizations_zh.dart +++ b/lib/src/l10n/generated/app_localizations_zh.dart @@ -354,6 +354,58 @@ class AppLocalizationsZh extends AppLocalizations { @override String get journeyTitle => '今日进展'; + @override + String get journeyStatsTitle => '本周'; + + @override + String get weekFocusLabel => '专注'; + + @override + String get streakLabel => '连续'; + + @override + String get activeDaysLabel => '活跃'; + + @override + String get habitRateLabel => '习惯'; + + @override + String daysValue(int count) { + return '$count天'; + } + + @override + String activeDaysValue(int count) { + return '$count/7'; + } + + @override + String get viewHistory => '查看历史'; + + @override + String get viewHistorySubtitle => '回看专注记录和复盘。'; + + @override + String get historyTitle => '历史'; + + @override + String get focusHistory => '专注'; + + @override + String get notesHistory => '复盘'; + + @override + String get emptyFocusHistoryTitle => '还没有专注记录'; + + @override + String get emptyFocusHistoryBody => '从今日页开始一轮专注,完成后会出现在这里。'; + + @override + String get emptyNotesHistoryTitle => '还没有复盘'; + + @override + String get emptyNotesHistoryBody => '专注后保存一句复盘,让进展更清晰。'; + @override String get recentFocus => '最近专注'; diff --git a/lib/src/models/journey_stats.dart b/lib/src/models/journey_stats.dart new file mode 100644 index 0000000..1acd3bd --- /dev/null +++ b/lib/src/models/journey_stats.dart @@ -0,0 +1,50 @@ +import '../core/date_keys.dart'; +import 'app_state.dart'; + +class JourneyStats { + const JourneyStats({ + required this.currentStreakDays, + required this.weekFocusMinutes, + required this.activeDaysThisWeek, + required this.habitCompletionRate, + }); + + final int currentStreakDays; + final int weekFocusMinutes; + final int activeDaysThisWeek; + final double habitCompletionRate; + + static JourneyStats fromState(AppState state) { + final dateKeys = lastSevenDateKeys(); + final dateKeySet = dateKeys.toSet(); + final weekFocusMinutes = dateKeys.fold( + 0, + (total, key) => total + minutesForDate(state.sessions, key), + ); + final activeDays = dateKeys.where((key) => _hasProgress(state, key)).length; + final habitCompletions = state.habits.fold( + 0, + (total, habit) => + total + habit.completedDates.where(dateKeySet.contains).length, + ); + final habitOpportunities = state.habits.length * dateKeys.length; + + return JourneyStats( + currentStreakDays: currentStreak( + state.habits, + state.sessions, + state.reflections, + ), + weekFocusMinutes: weekFocusMinutes, + activeDaysThisWeek: activeDays, + habitCompletionRate: + habitOpportunities == 0 ? 0 : habitCompletions / habitOpportunities, + ); + } + + static bool _hasProgress(AppState state, String key) { + return state.habits.any((habit) => habit.completedDates.contains(key)) || + state.sessions.any((session) => dateKey(session.completedAt) == key) || + state.reflections.any((reflection) => reflection.dateKey == key); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e42f109..ecc5e2a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -63,6 +63,37 @@ void main() { expect(find.text('Import JSON'), findsNothing); }); + testWidgets('Journey opens history detail with empty states', (tester) async { + await pumpFocusSeedApp(tester, locale: const Locale('en')); + + await tester.tap(find.text('Journey')); + await tester.pumpAndSettle(); + + final journeyScroll = find.byType(Scrollable).first; + await tester.scrollUntilVisible( + find.text('This week'), + 180, + scrollable: journeyScroll, + ); + expect(find.text('This week'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('View history'), + 180, + scrollable: journeyScroll, + ); + expect(find.text('View history'), findsOneWidget); + + await tester.tap(find.text('View history')); + await tester.pumpAndSettle(); + + expect(find.text('History'), findsOneWidget); + expect(find.text('No focus sessions yet'), findsOneWidget); + + await tester.tap(find.text('Notes')); + await tester.pumpAndSettle(); + expect(find.text('No notes yet'), findsOneWidget); + }); + testWidgets('Today remains usable at narrow width and 200 percent text', ( tester, ) async {