feat: add journey stats and history detail
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 => '最近专注';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user