feat(app): the home dashboard: streak, goal, week, activity

Home opens on a study dashboard. The streak's flame sits in the app bar
and opens a sheet of how it stands this week. Below: the study goal (a
ring of the day's or week's progress, set and removed in its sheet), the
week (each day studied, rested, missed or to come with the cards falling
due; days to go back and on; today or a day to come made a rest day),
and a year of activity as a heatmap, its weekdays staying in view while
the weeks scroll. A long press arranges the dashboard: drag a widget
elsewhere, hide or show it; the arrangement is synced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 22:13:34 +02:00
commit 111532435f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 1747 additions and 63 deletions

View file

@ -10,8 +10,6 @@
"shellTabProfile": "Profile",
"homeTitle": "Home",
"homeLibraryIntro": "Your studysets and cards are in your library.",
"homeOpenLibrary": "Open library",
"libraryTitle": "Library",
"libraryNewStudyset": "New studyset",
@ -315,6 +313,60 @@
"composerPlaceholderQuestion": "Add question",
"composerLabelRegion": "Answer",
"homeStreakTooltip": "Study streak",
"homeArrange": "Arrange widgets",
"homeDone": "Done",
"homeDragToReorder": "Drag to reorder",
"homeShowWidget": "Show {label}",
"@homeShowWidget": {"placeholders": {"label": {"type": "String"}}},
"homeHideWidget": "Hide {label}",
"@homeHideWidget": {"placeholders": {"label": {"type": "String"}}},
"homeAllHidden": "All widgets are hidden",
"homeAllHiddenHint": "Long-press here to bring some back.",
"homeWidgetGoal": "Study goal",
"homeWidgetWeek": "Week overview",
"homeWidgetActivity": "Activity heatmap",
"homeActivityTitle": "Your activity",
"streakDays": "{count, plural, =1{1 day streak} other{{count} day streak}}",
"streakStart": "Study today to start your streak.",
"streakGrow": "Keep it going: study at least once a day to grow your streak.",
"streakFirstDay": "Review some cards today to light the first day.",
"streakComeBack": "Come back tomorrow to keep your streak.",
"streakKeepAlive": "Study today to keep your streak alive.",
"goalEditTitle": "Edit study goal",
"goalSetTitle": "Set a study goal",
"goalDescriptionDaily": "Review a number of flashcards each day. Every card you rate counts.",
"goalDescriptionWeekly": "Review a number of flashcards each week, from Monday. Every card you rate counts.",
"goalPeriod": "Period",
"goalPerDay": "Per day",
"goalPerWeek": "Per week",
"goalCardsPerDay": "Cards per day",
"goalCardsPerWeek": "Cards per week",
"goalSave": "Save goal",
"goalRemove": "Remove goal",
"goalEmptySubtitle": "Review a set number of cards each day or week.",
"goalDailyHeadline": "Daily goal",
"goalWeeklyHeadline": "Weekly goal",
"goalProgressToday": "{reviewed} of {target} cards today",
"@goalProgressToday": {"placeholders": {"reviewed": {"type": "int"}, "target": {"type": "int"}}},
"goalProgressThisWeek": "{reviewed} of {target} cards this week",
"@goalProgressThisWeek": {"placeholders": {"reviewed": {"type": "int"}, "target": {"type": "int"}}},
"goalMet": "Goal met: nice work!",
"goalRemaining": "{count} to go",
"@goalRemaining": {"placeholders": {"count": {"type": "int"}}},
"weekToday": "Today",
"weekPreviousDay": "Previous day",
"weekNextDay": "Next day",
"weekRestDay": "Rest day",
"weekDueCount": "{count, plural, =1{1 card due} other{{count} cards due}}",
"@weekDueCount": {"placeholders": {"count": {"type": "int"}}},
"heatmapLess": "Less",
"heatmapMore": "More",
"heatmapTotal": "{count, plural, =1{1 activity in the last year} other{{count} activities in the last year}}",
"@heatmapTotal": {"placeholders": {"count": {"type": "int"}}},
"heatmapDay": "{count, plural, =0{Nothing} =1{1 activity} other{{count} activities}} · {date}",
"@heatmapDay": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
"commonCancel": "Cancel",
"commonClose": "Close",
"commonUndo": "Undo",

View file

@ -130,18 +130,6 @@ abstract class AppLocalizations {
/// **'Home'**
String get homeTitle;
/// No description provided for @homeLibraryIntro.
///
/// In en, this message translates to:
/// **'Your studysets and cards are in your library.'**
String get homeLibraryIntro;
/// No description provided for @homeOpenLibrary.
///
/// In en, this message translates to:
/// **'Open library'**
String get homeOpenLibrary;
/// No description provided for @libraryTitle.
///
/// In en, this message translates to:
@ -1720,6 +1708,276 @@ abstract class AppLocalizations {
/// **'Answer'**
String get composerLabelRegion;
/// No description provided for @homeStreakTooltip.
///
/// In en, this message translates to:
/// **'Study streak'**
String get homeStreakTooltip;
/// No description provided for @homeArrange.
///
/// In en, this message translates to:
/// **'Arrange widgets'**
String get homeArrange;
/// No description provided for @homeDone.
///
/// In en, this message translates to:
/// **'Done'**
String get homeDone;
/// No description provided for @homeDragToReorder.
///
/// In en, this message translates to:
/// **'Drag to reorder'**
String get homeDragToReorder;
/// No description provided for @homeShowWidget.
///
/// In en, this message translates to:
/// **'Show {label}'**
String homeShowWidget(String label);
/// No description provided for @homeHideWidget.
///
/// In en, this message translates to:
/// **'Hide {label}'**
String homeHideWidget(String label);
/// No description provided for @homeAllHidden.
///
/// In en, this message translates to:
/// **'All widgets are hidden'**
String get homeAllHidden;
/// No description provided for @homeAllHiddenHint.
///
/// In en, this message translates to:
/// **'Long-press here to bring some back.'**
String get homeAllHiddenHint;
/// No description provided for @homeWidgetGoal.
///
/// In en, this message translates to:
/// **'Study goal'**
String get homeWidgetGoal;
/// No description provided for @homeWidgetWeek.
///
/// In en, this message translates to:
/// **'Week overview'**
String get homeWidgetWeek;
/// No description provided for @homeWidgetActivity.
///
/// In en, this message translates to:
/// **'Activity heatmap'**
String get homeWidgetActivity;
/// No description provided for @homeActivityTitle.
///
/// In en, this message translates to:
/// **'Your activity'**
String get homeActivityTitle;
/// No description provided for @streakDays.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 day streak} other{{count} day streak}}'**
String streakDays(num count);
/// No description provided for @streakStart.
///
/// In en, this message translates to:
/// **'Study today to start your streak.'**
String get streakStart;
/// No description provided for @streakGrow.
///
/// In en, this message translates to:
/// **'Keep it going: study at least once a day to grow your streak.'**
String get streakGrow;
/// No description provided for @streakFirstDay.
///
/// In en, this message translates to:
/// **'Review some cards today to light the first day.'**
String get streakFirstDay;
/// No description provided for @streakComeBack.
///
/// In en, this message translates to:
/// **'Come back tomorrow to keep your streak.'**
String get streakComeBack;
/// No description provided for @streakKeepAlive.
///
/// In en, this message translates to:
/// **'Study today to keep your streak alive.'**
String get streakKeepAlive;
/// No description provided for @goalEditTitle.
///
/// In en, this message translates to:
/// **'Edit study goal'**
String get goalEditTitle;
/// No description provided for @goalSetTitle.
///
/// In en, this message translates to:
/// **'Set a study goal'**
String get goalSetTitle;
/// No description provided for @goalDescriptionDaily.
///
/// In en, this message translates to:
/// **'Review a number of flashcards each day. Every card you rate counts.'**
String get goalDescriptionDaily;
/// No description provided for @goalDescriptionWeekly.
///
/// In en, this message translates to:
/// **'Review a number of flashcards each week, from Monday. Every card you rate counts.'**
String get goalDescriptionWeekly;
/// No description provided for @goalPeriod.
///
/// In en, this message translates to:
/// **'Period'**
String get goalPeriod;
/// No description provided for @goalPerDay.
///
/// In en, this message translates to:
/// **'Per day'**
String get goalPerDay;
/// No description provided for @goalPerWeek.
///
/// In en, this message translates to:
/// **'Per week'**
String get goalPerWeek;
/// No description provided for @goalCardsPerDay.
///
/// In en, this message translates to:
/// **'Cards per day'**
String get goalCardsPerDay;
/// No description provided for @goalCardsPerWeek.
///
/// In en, this message translates to:
/// **'Cards per week'**
String get goalCardsPerWeek;
/// No description provided for @goalSave.
///
/// In en, this message translates to:
/// **'Save goal'**
String get goalSave;
/// No description provided for @goalRemove.
///
/// In en, this message translates to:
/// **'Remove goal'**
String get goalRemove;
/// No description provided for @goalEmptySubtitle.
///
/// In en, this message translates to:
/// **'Review a set number of cards each day or week.'**
String get goalEmptySubtitle;
/// No description provided for @goalDailyHeadline.
///
/// In en, this message translates to:
/// **'Daily goal'**
String get goalDailyHeadline;
/// No description provided for @goalWeeklyHeadline.
///
/// In en, this message translates to:
/// **'Weekly goal'**
String get goalWeeklyHeadline;
/// No description provided for @goalProgressToday.
///
/// In en, this message translates to:
/// **'{reviewed} of {target} cards today'**
String goalProgressToday(int reviewed, int target);
/// No description provided for @goalProgressThisWeek.
///
/// In en, this message translates to:
/// **'{reviewed} of {target} cards this week'**
String goalProgressThisWeek(int reviewed, int target);
/// No description provided for @goalMet.
///
/// In en, this message translates to:
/// **'Goal met: nice work!'**
String get goalMet;
/// No description provided for @goalRemaining.
///
/// In en, this message translates to:
/// **'{count} to go'**
String goalRemaining(int count);
/// No description provided for @weekToday.
///
/// In en, this message translates to:
/// **'Today'**
String get weekToday;
/// No description provided for @weekPreviousDay.
///
/// In en, this message translates to:
/// **'Previous day'**
String get weekPreviousDay;
/// No description provided for @weekNextDay.
///
/// In en, this message translates to:
/// **'Next day'**
String get weekNextDay;
/// No description provided for @weekRestDay.
///
/// In en, this message translates to:
/// **'Rest day'**
String get weekRestDay;
/// No description provided for @weekDueCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 card due} other{{count} cards due}}'**
String weekDueCount(int count);
/// No description provided for @heatmapLess.
///
/// In en, this message translates to:
/// **'Less'**
String get heatmapLess;
/// No description provided for @heatmapMore.
///
/// In en, this message translates to:
/// **'More'**
String get heatmapMore;
/// No description provided for @heatmapTotal.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 activity in the last year} other{{count} activities in the last year}}'**
String heatmapTotal(int count);
/// No description provided for @heatmapDay.
///
/// In en, this message translates to:
/// **'{count, plural, =0{Nothing} =1{1 activity} other{{count} activities}} · {date}'**
String heatmapDay(int count, String date);
/// No description provided for @commonCancel.
///
/// In en, this message translates to:

View file

@ -27,13 +27,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get homeTitle => 'Home';
@override
String get homeLibraryIntro =>
'Your studysets and cards are in your library.';
@override
String get homeOpenLibrary => 'Open library';
@override
String get libraryTitle => 'Library';
@ -974,6 +967,189 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerLabelRegion => 'Answer';
@override
String get homeStreakTooltip => 'Study streak';
@override
String get homeArrange => 'Arrange widgets';
@override
String get homeDone => 'Done';
@override
String get homeDragToReorder => 'Drag to reorder';
@override
String homeShowWidget(String label) {
return 'Show $label';
}
@override
String homeHideWidget(String label) {
return 'Hide $label';
}
@override
String get homeAllHidden => 'All widgets are hidden';
@override
String get homeAllHiddenHint => 'Long-press here to bring some back.';
@override
String get homeWidgetGoal => 'Study goal';
@override
String get homeWidgetWeek => 'Week overview';
@override
String get homeWidgetActivity => 'Activity heatmap';
@override
String get homeActivityTitle => 'Your activity';
@override
String streakDays(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count day streak',
one: '1 day streak',
);
return '$_temp0';
}
@override
String get streakStart => 'Study today to start your streak.';
@override
String get streakGrow =>
'Keep it going: study at least once a day to grow your streak.';
@override
String get streakFirstDay =>
'Review some cards today to light the first day.';
@override
String get streakComeBack => 'Come back tomorrow to keep your streak.';
@override
String get streakKeepAlive => 'Study today to keep your streak alive.';
@override
String get goalEditTitle => 'Edit study goal';
@override
String get goalSetTitle => 'Set a study goal';
@override
String get goalDescriptionDaily =>
'Review a number of flashcards each day. Every card you rate counts.';
@override
String get goalDescriptionWeekly =>
'Review a number of flashcards each week, from Monday. Every card you rate counts.';
@override
String get goalPeriod => 'Period';
@override
String get goalPerDay => 'Per day';
@override
String get goalPerWeek => 'Per week';
@override
String get goalCardsPerDay => 'Cards per day';
@override
String get goalCardsPerWeek => 'Cards per week';
@override
String get goalSave => 'Save goal';
@override
String get goalRemove => 'Remove goal';
@override
String get goalEmptySubtitle =>
'Review a set number of cards each day or week.';
@override
String get goalDailyHeadline => 'Daily goal';
@override
String get goalWeeklyHeadline => 'Weekly goal';
@override
String goalProgressToday(int reviewed, int target) {
return '$reviewed of $target cards today';
}
@override
String goalProgressThisWeek(int reviewed, int target) {
return '$reviewed of $target cards this week';
}
@override
String get goalMet => 'Goal met: nice work!';
@override
String goalRemaining(int count) {
return '$count to go';
}
@override
String get weekToday => 'Today';
@override
String get weekPreviousDay => 'Previous day';
@override
String get weekNextDay => 'Next day';
@override
String get weekRestDay => 'Rest day';
@override
String weekDueCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards due',
one: '1 card due',
);
return '$_temp0';
}
@override
String get heatmapLess => 'Less';
@override
String get heatmapMore => 'More';
@override
String heatmapTotal(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count activities in the last year',
one: '1 activity in the last year',
);
return '$_temp0';
}
@override
String heatmapDay(int count, String date) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count activities',
one: '1 activity',
zero: 'Nothing',
);
return '$_temp0 · $date';
}
@override
String get commonCancel => 'Cancel';

View file

@ -0,0 +1,196 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// A year of studying as a grid, like the contribution grids of code
/// hosts: a column per week, Monday to Sunday, each day as dark as what
/// was studied on it, with the months on top and every other weekday on
/// the left. Centred when it fits, scrolling sideways otherwise, the latest
/// weeks in view first.
class const ActivityHeatmap({
/// What was studied, by day.
required final StudyActivity activity,
/// Today.
required final DateTime today,
/// How many weeks it shows.
final int weeks = 52,
super.key,
}) extends StatelessWidget {
static const _cell = 13.0;
static const _gap = 3.0;
static const _step = _cell + _gap;
static const _monthsHeight = 16.0;
static const _weekdaysWidth = 30.0;
/// The colour of a day at heat [level].
static Color colorOf(ThemeData theme, int level) {
final base = theme.colorScheme.primary;
return switch (level) {
1 => base.withValues(alpha: 0.3),
2 => base.withValues(alpha: 0.5),
3 => base.withValues(alpha: 0.75),
4 => base,
_ => theme.colorScheme.surfaceContainerHighest,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final thisMonday = mondayOf(today);
final mondays = [
for (var week = weeks - 1; week >= 0; week--)
addDays(thisMonday, -7 * week),
];
var total = 0;
for (final monday in mondays) {
for (var day = 0; day < 7; day++) {
final date = addDays(monday, day);
if (!date.isAfter(today)) {
total += activity.on(date);
}
}
}
final muted = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final weekdays = Padding(
padding: const EdgeInsets.only(top: _monthsHeight),
child: Column(
children: [
for (var row = 0; row < 7; row++)
SizedBox(
width: _weekdaysWidth,
height: _step,
child: row.isOdd || row == 6
? null
: Align(
alignment: Alignment.centerRight,
child: Text(
DateFormat.E(locale).format(addDays(thisMonday, row)),
style: muted,
),
),
),
],
),
);
final weeksGrid = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: _monthsHeight,
width: weeks * _step,
child: Stack(
children: [
for (final (i, monday) in mondays.indexed)
// A month's name where its first week begins.
if (i == 0 || monday.month != mondays[i - 1].month)
Positioned(
left: i * _step,
child: Text(
DateFormat.MMM(locale).format(monday),
style: muted,
),
),
],
),
),
Row(
children: [
for (final monday in mondays)
Padding(
padding: const EdgeInsets.only(right: _gap),
child: Column(
children: [
for (var row = 0; row < 7; row++)
_cellFor(context, addDays(monday, row)),
],
),
),
],
),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The weekdays stay in view while the weeks scroll.
LayoutBuilder(
builder: (context, constraints) =>
_weekdaysWidth + 6 + weeks * _step <= constraints.maxWidth
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [weekdays, const SizedBox(width: 6), weeksGrid],
),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
weekdays,
const SizedBox(width: 6),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: weeksGrid,
),
),
],
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: Text(l10n.heatmapTotal(total), style: muted)),
Text(l10n.heatmapLess, style: muted),
const SizedBox(width: 4),
for (var level = 0; level <= 4; level++)
Container(
width: 10,
height: 10,
margin: const EdgeInsets.symmetric(horizontal: 1.5),
decoration: BoxDecoration(
color: colorOf(theme, level),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 4),
Text(l10n.heatmapMore, style: muted),
],
),
],
);
}
Widget _cellFor(BuildContext context, DateTime day) {
final theme = Theme.of(context);
final future = day.isAfter(today);
final count = activity.on(day);
final cell = Container(
width: _cell,
height: _cell,
margin: const EdgeInsets.only(bottom: _gap),
decoration: BoxDecoration(
color: future ? Colors.transparent : colorOf(theme, heatLevel(count)),
borderRadius: BorderRadius.circular(3),
),
);
if (future) {
return cell;
}
final locale = Localizations.localeOf(context).toLanguageTag();
return Tooltip(
message: AppLocalizations.of(
context,
).heatmapDay(count, DateFormat.yMMMd(locale).format(day)),
child: cell,
);
}
}

View file

@ -0,0 +1,251 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The study goal: a ring of how far the period got, with what is still to
/// go; without a goal, an invitation to set one. Tapping opens the goal's
/// sheet.
class const GoalCard({
/// The goal; null without one.
required final StudyGoal? goal,
/// How many cards were rated in its period.
required final int progress,
/// Called with the goal set, or null once removed.
required final ValueChanged<StudyGoal?> onChanged,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final goal = this.goal;
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return SectionCard(
padding: EdgeInsets.zero,
child: InkWell(
onTap: () async {
final result = await showAdaptiveSheet<_GoalResult>(
context,
builder: (context) => _GoalSheet(initial: goal),
);
if (result != null) {
onChanged(result.goal);
}
},
child: Padding(
padding: const EdgeInsets.all(16),
child: goal == null
? Row(
children: [
Icon(LucideIcons.target, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.goalSetTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(l10n.goalEmptySubtitle, style: muted),
],
),
),
const Icon(LucideIcons.chevronRight),
],
)
: Row(
children: [
_Ring(progress: progress, target: goal.target),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
goal.period == GoalPeriod.day
? l10n.goalDailyHeadline
: l10n.goalWeeklyHeadline,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(
goal.period == GoalPeriod.day
? l10n.goalProgressToday(progress, goal.target)
: l10n.goalProgressThisWeek(
progress,
goal.target,
),
style: muted,
),
const SizedBox(height: 2),
if (progress >= goal.target)
Text(
l10n.goalMet,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
)
else
Text(
l10n.goalRemaining(goal.target - progress),
style: theme.textTheme.bodySmall,
),
],
),
),
const Icon(LucideIcons.chevronRight),
],
),
),
),
);
}
}
/// How far the period got, as a ring with the count inside.
class const _Ring({required final int progress, required final int target})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: 56,
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.expand(
child: CircularProgressIndicator(
value: math.min(1, progress / target),
strokeWidth: 6,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
),
Text('$progress', style: theme.textTheme.titleMedium),
],
),
);
}
}
/// What the goal sheet ends with: the goal set, or null once removed.
typedef _GoalResult = ({StudyGoal? goal});
/// Setting the study goal: a day or a week, and how many cards; removing an
/// existing one.
class const _GoalSheet({required final StudyGoal? initial})
extends StatefulWidget {
@override
State<_GoalSheet> createState() => _GoalSheetState();
}
class _GoalSheetState() extends State<_GoalSheet> {
late var _period = widget.initial?.period ?? GoalPeriod.day;
late var _target = widget.initial?.target ?? StudyGoal.defaultTarget;
void _setTarget(int target) => setState(
() => _target = target.clamp(StudyGoal.minTarget, StudyGoal.maxTarget),
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final daily = _period == GoalPeriod.day;
return SheetScaffold(
title: widget.initial == null ? l10n.goalSetTitle : l10n.goalEditTitle,
footer: Row(
children: [
if (widget.initial != null)
TextButton(
onPressed: () => Navigator.of(context).pop((goal: null)),
child: Text(l10n.goalRemove),
),
const Spacer(),
FilledButton(
onPressed: () => Navigator.of(
context,
).pop((goal: StudyGoal(period: _period, target: _target))),
child: Text(l10n.goalSave),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Text(
daily ? l10n.goalDescriptionDaily : l10n.goalDescriptionWeekly,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Text(l10n.goalPeriod, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<GoalPeriod>(
segments: [
ButtonSegment(
value: GoalPeriod.day,
label: Text(l10n.goalPerDay),
),
ButtonSegment(
value: GoalPeriod.week,
label: Text(l10n.goalPerWeek),
),
],
selected: {_period},
showSelectedIcon: false,
onSelectionChanged: (selection) =>
setState(() => _period = selection.single),
),
const SizedBox(height: 20),
Text(
daily ? l10n.goalCardsPerDay : l10n.goalCardsPerWeek,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
IconButton.outlined(
tooltip: l10n.studyFewer,
onPressed: _target > StudyGoal.minTarget
? () => _setTarget(_target - 1)
: null,
icon: const Icon(LucideIcons.minus),
),
SizedBox(
width: 96,
child: Text(
'$_target',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
),
IconButton.outlined(
tooltip: l10n.studyMore,
onPressed: _target < StudyGoal.maxTarget
? () => _setTarget(_target + 1)
: null,
icon: const Icon(LucideIcons.plus),
),
],
),
const SizedBox(height: 16),
],
),
);
}
}

View file

@ -1,46 +1,271 @@
import 'package:go_router/go_router.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/activity_heatmap.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The home tab, where the app opens. For now it leads to the library; the
/// study dashboard comes here.
/// The home tab, where the app opens: a study dashboard. The streak sits in
/// the app bar; below it the study goal, the week and a year of activity,
/// in the order the person arranged them.
///
/// Long-pressing a widget arranges the dashboard: a handle to drag each
/// widget elsewhere, an eye to hide or show it, Done to finish. The
/// arrangement follows the person to every device.
class const HomeScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final person = PersonScope.of(context);
return BlocProvider(
create: (context) => HomeBloc(
person.studysets,
person.cards,
person.reviews,
person.settings,
)..add(const HomeEventStarted()),
child: const _Home(),
);
}
}
class const _Home() extends StatefulWidget {
@override
State<_Home> createState() => _HomeState();
}
class _HomeState() extends State<_Home> {
DateTime? _selected;
var _arranging = false;
void _send(HomeEvent event) => context.read<HomeBloc>().add(event);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar(title: Text(l10n.homeTitle)),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.homeLibraryIntro),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () => context.go(RoutePaths.library),
icon: const Icon(LucideIcons.book),
label: Text(l10n.homeOpenLibrary),
final theme = Theme.of(context);
return BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
final loaded = switch (state) {
final HomeStateLoaded loaded => loaded,
HomeStateLoading() => null,
};
final today = loaded == null ? null : localDay(loaded.now);
return PopScope(
canPop: !_arranging,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
setState(() => _arranging = false);
}
},
child: Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
appBar: _arranging
? AppBar(
leading: IconButton(
tooltip: l10n.homeDone,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _arranging = false),
),
title: Text(l10n.homeArrange),
actions: [
TextButton(
onPressed: () => setState(() => _arranging = false),
child: Text(l10n.homeDone),
),
const SizedBox(width: 8),
],
)
: AppBar(
title: Text(l10n.homeTitle),
actions: [
if (loaded != null && today != null)
StreakChip(
streak: loaded.streak,
week: weekOf(
today,
loaded.activity,
loaded.restDays,
loaded.now,
),
today: today,
),
const SizedBox(width: 8),
],
),
body: loaded == null || today == null
? const Center(child: AppLoadingIndicator())
: _dashboard(loaded, today),
),
);
},
);
}
Widget _dashboard(HomeStateLoaded state, DateTime today) {
final l10n = AppLocalizations.of(context);
final selected = _selected ?? today;
Widget section(HomeWidget widget) => switch (widget) {
HomeWidget.goal => GoalCard(
goal: state.goal,
progress: state.goalProgress,
onChanged: (goal) => _send(HomeEventGoalSet(goal)),
),
HomeWidget.week => SectionCard(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: WeekOverview(
selected: selected,
today: today,
week: weekOf(selected, state.activity, state.restDays, state.now),
due: state.due,
restDays: state.restDays,
onSelect: (day) => setState(() => _selected = day),
onToggleRestDay: () => _send(HomeEventRestDayToggled(selected)),
),
),
HomeWidget.activity => SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.homeActivityTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
ActivityHeatmap(activity: state.activity, today: today),
],
),
),
};
final layout = state.layout;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: _arranging
? _Arranging(
layout: layout,
section: section,
onChanged: (layout) => _send(HomeEventLayoutChanged(layout)),
)
: layout.shown.isEmpty
? GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => setState(() => _arranging = true),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.homeAllHidden,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.homeAllHiddenHint),
],
),
),
)
: ListView(
padding: const EdgeInsets.all(12),
children: [
for (final widget in layout.shown)
GestureDetector(
key: ValueKey(widget),
// Taps still reach the widget's own controls.
behavior: HitTestBehavior.translucent,
onLongPress: () => setState(() => _arranging = true),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: section(widget),
),
),
],
),
),
),
),
),
);
}
}
/// The dashboard being arranged: every widget, hidden ones greyed, each
/// with a handle to drag it elsewhere and an eye to hide or show it; the
/// widgets themselves don't react meanwhile.
class const _Arranging({
required final HomeLayout layout,
required final Widget Function(HomeWidget widget) section,
required final ValueChanged<HomeLayout> onChanged,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
String label(HomeWidget widget) => switch (widget) {
HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.week => l10n.homeWidgetWeek,
HomeWidget.activity => l10n.homeWidgetActivity,
};
return ReorderableListView(
padding: const EdgeInsets.all(12),
buildDefaultDragHandles: false,
onReorderItem: (from, to) {
final order = [...layout.order];
final moved = order.removeAt(from);
order.insert(to, moved);
onChanged(HomeLayout(order: order, hidden: layout.hidden));
},
children: [
for (final (i, widget) in layout.order.indexed)
Padding(
key: ValueKey(widget),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReorderableDragStartListener(
index: i,
child: Tooltip(
message: l10n.homeDragToReorder,
child: const Padding(
padding: EdgeInsets.all(12),
child: Icon(LucideIcons.gripVertical),
),
),
),
Expanded(
child: Opacity(
opacity: layout.hidden.contains(widget) ? 0.4 : 1,
child: IgnorePointer(child: section(widget)),
),
),
IconButton(
tooltip: layout.hidden.contains(widget)
? l10n.homeShowWidget(label(widget))
: l10n.homeHideWidget(label(widget)),
icon: Icon(
layout.hidden.contains(widget)
? LucideIcons.eyeOff
: LucideIcons.eye,
),
onPressed: () => onChanged(
HomeLayout(
order: layout.order,
hidden: layout.hidden.contains(widget)
? ({...layout.hidden}..remove(widget))
: {...layout.hidden, widget},
),
),
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,231 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The streak's flame: copper, lit while the streak lasts.
const streakFlameColor = Color(0xFFC44800);
/// The streak in the app bar: a flame and the days, opening the streak
/// sheet.
class const StreakChip({
/// The streak, in days.
required final int streak,
/// The current week, to show in the sheet.
required final List<WeekDay> week,
/// Today.
required final DateTime today,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final lit = streak > 0;
return Tooltip(
message: AppLocalizations.of(context).homeStreakTooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => showAdaptiveSheet<void>(
context,
builder: (context) =>
_StreakSheet(streak: streak, week: week, today: today),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.flame,
size: 20,
color: lit
? streakFlameColor
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'$streak',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}
/// The streak at large: a flame, how many days, what keeps it going, and
/// this week's days.
class const _StreakSheet({
required final int streak,
required final List<WeekDay> week,
required final DateTime today,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final studiedToday = week.any(
(day) => day.state == DayState.studied && day.day == today,
);
return SheetScaffold(
title: l10n.homeStreakTooltip,
body: Column(
children: [
const SizedBox(height: 8),
Icon(
LucideIcons.flame,
size: 64,
color: streak > 0
? streakFlameColor
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(l10n.streakDays(streak), style: theme.textTheme.headlineSmall),
const SizedBox(height: 4),
Text(
streak == 0 ? l10n.streakStart : l10n.streakGrow,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Row(
children: [
for (final day in week) Expanded(child: _StreakDay(day: day)),
],
),
const SizedBox(height: 16),
Text(
streak == 0
? l10n.streakFirstDay
: studiedToday
? l10n.streakComeBack
: l10n.streakKeepAlive,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
],
),
);
}
}
class const _StreakDay({required final WeekDay day}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final locale = Localizations.localeOf(context).toLanguageTag();
return Column(
children: [
Text(
DateFormat.E(locale).format(day.day),
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 6),
DayMarker(day: day, due: 0),
],
);
}
}
/// How a day stands, as a small round mark: a check for studied, a
/// snowflake for rested, a cross for missed, a dashed ring for today; a day
/// to come shows how many cards fall due, or a faint dashed ring.
class const DayMarker({
required final WeekDay day,
required final int due,
super.key,
}) extends StatelessWidget {
static const _size = 24.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
Widget solid(Color color, Widget child) => Container(
width: _size,
height: _size,
alignment: Alignment.center,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
child: child,
);
Widget dashed(Color color, {double width = 1.5}) => SizedBox(
width: _size,
height: _size,
child: CustomPaint(
painter: _DashedCircle(color: color, strokeWidth: width),
),
);
return switch (day.state) {
DayState.studied => solid(
colors.primary.withValues(alpha: 0.15),
Icon(LucideIcons.check, size: 14, color: colors.primary),
),
DayState.rested => solid(
colors.tertiaryContainer,
Icon(
LucideIcons.snowflake,
size: 14,
color: colors.onTertiaryContainer,
),
),
DayState.missed => solid(
colors.surfaceContainerHighest,
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
),
DayState.today => dashed(colors.primary, width: 2),
DayState.upcoming when due > 0 => solid(
colors.secondaryContainer,
Text(
due > 99 ? '99+' : '$due',
style: theme.textTheme.labelSmall?.copyWith(
color: colors.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
DayState.upcoming => dashed(colors.outlineVariant),
};
}
}
/// A dashed circle: twelve dashes, a little more line than gap.
class const _DashedCircle({
required final Color color,
required final double strokeWidth,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final rect = Rect.fromCircle(
center: size.center(Offset.zero),
radius: (size.shortestSide - strokeWidth) / 2,
);
const dashes = 12;
const sweep = 2 * 3.141592653589793 / dashes;
for (var i = 0; i < dashes; i++) {
canvas.drawArc(rect, i * sweep, sweep * 0.55, false, paint);
}
}
@override
bool shouldRepaint(_DashedCircle oldDelegate) =>
oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth;
}

View file

@ -0,0 +1,187 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The week of [selected] day: the day named on top with going a day back
/// or on and back to today, Monday to Sunday as boxes with how each stands,
/// and making the day picked a rest day when it is today or to come and
/// not studied.
class const WeekOverview({
/// The day picked.
required final DateTime selected,
/// Today.
required final DateTime today,
/// The week's days.
required final List<WeekDay> week,
/// How many cards fall due on each day.
required final Map<DateTime, int> due,
/// The days the person rests.
required final Set<DateTime> restDays,
/// Picks a day.
required final ValueChanged<DateTime> onSelect,
/// Rests on the day picked, or not any more.
required final VoidCallback onToggleRestDay,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final beside = context.windowSize.isAtLeastMedium;
final picked = week.where((day) => day.day == selected).firstOrNull;
final canRest =
!selected.isBefore(today) && picked?.state != DayState.studied;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
DateFormat.MMMMEEEEd(locale).format(selected),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
if (selected != today)
TextButton(
onPressed: () => onSelect(today),
child: Text(l10n.weekToday),
),
IconButton(
tooltip: l10n.weekPreviousDay,
icon: const Icon(LucideIcons.chevronLeft),
onPressed: () => onSelect(addDays(selected, -1)),
),
IconButton(
tooltip: l10n.weekNextDay,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => onSelect(addDays(selected, 1)),
),
],
),
const SizedBox(height: 8),
Row(
children: [
for (final day in week)
Expanded(
child: _Day(
day: day,
today: day.day == today,
selected: day.day == selected,
due: due[day.day] ?? 0,
beside: beside,
onTap: () => onSelect(day.day),
),
),
],
),
if (canRest)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Center(
child: FilterChip(
avatar: const Icon(LucideIcons.snowflake, size: 16),
label: Text(l10n.weekRestDay),
selected: restDays.contains(selected),
onSelected: (_) => onToggleRestDay(),
),
),
),
],
);
}
}
/// A day as an outlined box: its weekday and date, and its mark beside
/// them from medium windows on, below them otherwise. The day picked has a
/// primary outline, today a tint.
class const _Day({
required final WeekDay day,
required final bool today,
required final bool selected,
required final int due,
required final bool beside,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final locale = Localizations.localeOf(context).toLanguageTag();
final info = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: beside
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
Text(
DateFormat.E(locale).format(day.day),
style: theme.textTheme.labelSmall?.copyWith(
color: today ? colors.primary : colors.onSurfaceVariant,
fontWeight: today ? FontWeight.w700 : null,
),
),
Text(
'${day.day.day}',
style: theme.textTheme.titleSmall?.copyWith(
color: today ? colors.primary : colors.onSurface,
fontWeight: selected || today ? FontWeight.w700 : null,
),
),
],
);
final mark = Tooltip(
message: due > 0 ? AppLocalizations.of(context).weekDueCount(due) : '',
child: DayMarker(day: day, due: due),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.symmetric(
vertical: 8,
horizontal: beside ? 8 : 2,
),
decoration: BoxDecoration(
color: today ? colors.primary.withValues(alpha: 0.08) : null,
border: Border.all(
color: selected ? colors.primary : colors.outlineVariant,
width: selected ? 1.8 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: beside
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: info),
mark,
],
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [info, const SizedBox(height: 6), mark],
),
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -185,9 +186,6 @@ void main() {
expect(auth.gitHubSwitches, 1);
expect(find.text('Switch to your GitHub account?'), findsNothing);
expect(
find.text('Your studysets and cards are in your library.'),
findsOneWidget,
);
expect(find.byType(HomeScreen), findsOneWidget);
});
}

View file

@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import '../../../support/fake_auth_gateway.dart';
import '../../../support/pump_app.dart';
@ -10,8 +11,8 @@ void main() {
setUp(() => gateway = FakeAuthGateway());
/// What the home tab says.
final home = find.text('Your studysets and cards are in your library.');
/// The home tab.
final home = find.byType(HomeScreen);
Future<void> showApp(WidgetTester tester) =>
pumpApp(tester, testDependencies(authGateway: gateway));

View file

@ -0,0 +1,112 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway;
late FakeSettingsGateway settingsGateway;
setUp(() {
cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway();
settingsGateway = FakeSettingsGateway();
});
Future<void> showHome(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
cardGateway: cardGateway,
reviewGateway: reviewGateway,
settingsGateway: settingsGateway,
),
);
}
int streak(WidgetTester tester) =>
tester.widget<StreakChip>(find.byType(StreakChip)).streak;
testWidgets('a card rated today lights the streak and the heatmap', (
tester,
) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final id = await cards.create(
'japanese',
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
await ReviewRepository(reviewGateway).record(
cardId: id,
rating: Rating.good,
reviewedAt: DateTime.now(),
duration: Duration.zero,
);
await showHome(tester);
expect(streak(tester), 1);
// The card written and the rating.
expect(find.text('2 activities in the last year'), findsOneWidget);
});
testWidgets('a goal is set in its sheet and shows its progress', (
tester,
) async {
await showHome(tester);
await tester.tap(find.text('Set a study goal'));
await tester.pumpAndSettle();
await tester.tap(find.text('Per week'));
await tester.pump();
await tester.tap(find.byTooltip('More'));
await tester.pump();
await tester.tap(find.text('Save goal'));
await tester.pumpAndSettle();
expect(find.text('Weekly goal'), findsOneWidget);
expect(find.text('0 of 21 cards this week'), findsOneWidget);
expect(find.byType(GoalCard), findsOneWidget);
});
testWidgets('resting today keeps the streak', (tester) async {
await showHome(tester);
expect(streak(tester), 0);
await tester.tap(find.text('Rest day'));
await tester.pumpAndSettle();
expect(streak(tester), 1);
});
testWidgets('a long press arranges the dashboard: a widget hidden stays '
'hidden', (tester) async {
await showHome(tester);
await tester.longPress(find.text('Set a study goal'));
await tester.pumpAndSettle();
expect(find.text('Arrange widgets'), findsOneWidget);
await tester.tap(find.byTooltip('Hide Study goal'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Set a study goal'), findsNothing);
expect(find.text('Your activity'), findsOneWidget);
});
}

View file

@ -1,20 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'support/pump_app.dart';
import 'support/test_dependencies.dart';
void main() {
testWidgets('signs in and opens at home, which leads to the library', (
tester,
) async {
testWidgets('signs in and opens at home, the dashboard, beside the '
'library', (tester) async {
await pumpApp(tester, testDependencies());
expect(
find.text('Your studysets and cards are in your library.'),
findsOneWidget,
);
expect(find.byType(HomeScreen), findsOneWidget);
expect(find.text('Set a study goal'), findsOneWidget);
await tester.tap(find.text('Open library'));
await tester.pumpAndSettle();
await openTab(tester, 'Library');
expect(find.text('No studysets yet'), findsOneWidget);
});