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 '../../application/focus_seed_controller.dart';
|
||||||
import '../../core/date_keys.dart';
|
import '../../core/date_keys.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
|
import '../../models/journey_stats.dart';
|
||||||
import '../../models/today_progress.dart';
|
import '../../models/today_progress.dart';
|
||||||
import '../../widgets/focus_seed_header.dart';
|
import '../../widgets/focus_seed_header.dart';
|
||||||
import '../../widgets/surface_panel.dart';
|
import '../../widgets/surface_panel.dart';
|
||||||
import '../reflection/reflection_sheet.dart';
|
import '../reflection/reflection_sheet.dart';
|
||||||
|
import 'history_screen.dart';
|
||||||
import 'widgets/growth_week_strip.dart';
|
import 'widgets/growth_week_strip.dart';
|
||||||
|
import 'widgets/journey_stats_panel.dart';
|
||||||
import 'widgets/journey_timeline.dart';
|
import 'widgets/journey_timeline.dart';
|
||||||
|
|
||||||
class JourneyScreen extends StatelessWidget {
|
class JourneyScreen extends StatelessWidget {
|
||||||
@@ -21,6 +24,7 @@ class JourneyScreen extends StatelessWidget {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final progress = TodayProgress.fromState(state, todayKey());
|
final progress = TodayProgress.fromState(state, todayKey());
|
||||||
|
final stats = JourneyStats.fromState(state);
|
||||||
final recentSessions = state.sessions.reversed.take(5).toList();
|
final recentSessions = state.sessions.reversed.take(5).toList();
|
||||||
final recentReflections = state.reflections.reversed.take(3).toList();
|
final recentReflections = state.reflections.reversed.take(3).toList();
|
||||||
|
|
||||||
@@ -39,7 +43,30 @@ class JourneyScreen extends StatelessWidget {
|
|||||||
progress: progress,
|
progress: progress,
|
||||||
onReflect: () => showReflectionSheet(context, controller),
|
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),
|
GrowthWeekStrip(state: state),
|
||||||
if (recentSessions.isNotEmpty) ...[
|
if (recentSessions.isNotEmpty) ...[
|
||||||
const SizedBox(height: 18),
|
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",
|
"todayDataCleared": "Today's data cleared",
|
||||||
"localDataCleared": "Local data deleted",
|
"localDataCleared": "Local data deleted",
|
||||||
"journeyTitle": "Today's journey",
|
"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",
|
"recentFocus": "Recent focus",
|
||||||
"sessionsTotal": "{count} sessions total",
|
"sessionsTotal": "{count} sessions total",
|
||||||
"@sessionsTotal": {
|
"@sessionsTotal": {
|
||||||
|
|||||||
@@ -159,6 +159,36 @@
|
|||||||
"todayDataCleared": "今日数据已清理",
|
"todayDataCleared": "今日数据已清理",
|
||||||
"localDataCleared": "本地数据已删除",
|
"localDataCleared": "本地数据已删除",
|
||||||
"journeyTitle": "\u4eca\u65e5\u8fdb\u5c55",
|
"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",
|
"recentFocus": "\u6700\u8fd1\u4e13\u6ce8",
|
||||||
"sessionsTotal": "\u5171 {count} \u6b21",
|
"sessionsTotal": "\u5171 {count} \u6b21",
|
||||||
"@sessionsTotal": {
|
"@sessionsTotal": {
|
||||||
|
|||||||
@@ -764,6 +764,102 @@ abstract class AppLocalizations {
|
|||||||
/// **'Today\'s journey'**
|
/// **'Today\'s journey'**
|
||||||
String get journeyTitle;
|
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.
|
/// No description provided for @recentFocus.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -358,6 +358,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get journeyTitle => 'Today\'s journey';
|
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
|
@override
|
||||||
String get recentFocus => 'Recent focus';
|
String get recentFocus => 'Recent focus';
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,58 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get journeyTitle => '今日进展';
|
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
|
@override
|
||||||
String get recentFocus => '最近专注';
|
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);
|
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', (
|
testWidgets('Today remains usable at narrow width and 200 percent text', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user