feat: add journey stats and history detail

This commit is contained in:
2026-06-12 01:26:19 +08:00
parent b287a56731
commit eb8b209c0c
10 changed files with 701 additions and 1 deletions
@@ -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<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
_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<FocusSession> 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<ReflectionEntry> 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,
),
),
],
),
);
}
}
+28 -1
View File
@@ -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<void>(
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),
@@ -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<double>(
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,
),
),
],
),
),
);
}
}
+30
View File
@@ -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": {
+30
View File
@@ -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": {
@@ -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:
@@ -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';
@@ -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 => '最近专注';
+50
View File
@@ -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<int>(
0,
(total, key) => total + minutesForDate(state.sessions, key),
);
final activeDays = dateKeys.where((key) => _hasProgress(state, key)).length;
final habitCompletions = state.habits.fold<int>(
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);
}
}
+31
View File
@@ -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 {