Stage 5: History, the shared daily Streak and Reminders
- Streak computed from all complete Sessions: Grace Hour (00:00-01:00 counts for yesterday unless it is done), a Freeze per 7 Streak Days (max 2) spent automatically on a missed day (ADR 0006) - Top bar flame (grey until today is done) opening a panel with current and longest Streak, Freezes and a month calendar; 'Day N of your streak' on a fresh summary - History per Mode grouped by day, reopening each summary - Two adjustable Reminders (19:00, 22:30) scheduled 14 days ahead with flutter_local_notifications, none on a done day; permission asked after the first complete Session or from Settings - Settings: Reminders, the Player chime, exercise credits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
971ff64117
commit
1ffe6269ea
31 files changed
+1972
-50
No files matched your search
@@ -1,21 +1,132 @@
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../design/page_header.dart';
|
||||
import '../../design/zen_nav_bar.dart';
|
||||
import '../../design/zen_style.dart';
|
||||
import '../../localization/app_localizations.dart';
|
||||
import '../../player/views/summary_view.dart';
|
||||
import '../../settings/settings_controller.dart';
|
||||
import '../../streak/day.dart';
|
||||
import '../session_repository.dart';
|
||||
|
||||
/// The current Mode's finished Sessions.
|
||||
/// The current Mode's finished Sessions by day, the latest first; each opens
|
||||
/// its summary again.
|
||||
class HistoryView extends StatelessWidget {
|
||||
const HistoryView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListView(
|
||||
children: [
|
||||
PageHeader(title: l10n.historyTitle),
|
||||
const SizedBox(height: 24),
|
||||
EmptyState(icon: Icons.history_rounded, title: l10n.historyEmptyTitle, body: l10n.historyEmptyBody),
|
||||
],
|
||||
final mode = context.watch<SettingsController>().mode;
|
||||
return StreamBuilder(
|
||||
stream: context.read<SessionRepository>().watchSessions(mode),
|
||||
builder: (context, snapshot) {
|
||||
final sessions = snapshot.data ?? const [];
|
||||
if (sessions.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
PageHeader(title: l10n.historyTitle),
|
||||
const SizedBox(height: 24),
|
||||
EmptyState(icon: Icons.history_rounded, title: l10n.historyEmptyTitle, body: l10n.historyEmptyBody),
|
||||
],
|
||||
);
|
||||
}
|
||||
final today = Day.of(clock.now());
|
||||
final rows = <Widget>[PageHeader(title: l10n.historyTitle)];
|
||||
Day? shown;
|
||||
for (final session in sessions) {
|
||||
final day = Day.of(session.endedAt);
|
||||
if (day != shown) {
|
||||
shown = day;
|
||||
rows.add(_DayHeader(label: _dayLabel(l10n, day, today)));
|
||||
}
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||||
child: _SessionTile(session: session),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom),
|
||||
children: rows,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _dayLabel(AppLocalizations l10n, Day day, Day today) {
|
||||
if (day == today) return l10n.historyToday;
|
||||
if (day == today.add(-1)) return l10n.historyYesterday;
|
||||
final date = day.at(0, 0);
|
||||
return day.year == today.year ? DateFormat.MMMEd().format(date) : DateFormat.yMMMEd().format(date);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayHeader extends StatelessWidget {
|
||||
const _DayHeader({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Text(label, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: ZenStyle.of(context).inkMuted)),
|
||||
);
|
||||
}
|
||||
|
||||
class _SessionTile extends StatelessWidget {
|
||||
const _SessionTile({required this.session});
|
||||
|
||||
final Session session;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final s = ZenStyle.of(context);
|
||||
final text = Theme.of(context).textTheme;
|
||||
final line = l10n.historySessionLine(
|
||||
DateFormat.Hm().format(session.endedAt),
|
||||
l10n.durationMinutes((session.duration.inSeconds / 60).round()),
|
||||
);
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).pushNamed(SummaryView.routeName, arguments: session.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
session.complete ? Icons.check_circle_rounded : Icons.flag_rounded,
|
||||
color: session.complete ? s.accent : s.inkMuted,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(session.planName, style: text.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
Text(line, style: text.bodySmall?.copyWith(color: s.inkMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!session.complete)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: s.surfaceSoft,
|
||||
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 12)),
|
||||
),
|
||||
child: Text(l10n.historyEndedEarly, style: text.labelSmall?.copyWith(color: s.inkMuted)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user