feat(app): the home dashboard's data: activity, streak, goal, layout

What the dashboard shows, worked out from this device's data: cards
rated (crammed included, fresh starts not) and cards written per local
day; the streak, up to today or yesterday, rest days counting; the cards
falling due per day, but those resting in archived studysets; the study
goal's progress in its day or week. The goal, the rest days and the
dashboard's arrangement live in the synced settings (payload schema 3).
Cards tell when they were made by their UUID v7 ids; reviews report
their changes.

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 21:59:52 +02:00
commit d846ee4582
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1024 additions and 36 deletions

View file

@ -137,12 +137,13 @@ Future<PersonDependencies> _openPerson(
cardChanges: cards.changes,
);
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
final deletions = StudysetDeletions(studysets, cards);
return PersonDependencies(
studysets: studysets,
cards: cards,
reviews: ReviewRepository(SyncReviewGateway(db, sync)),
reviews: reviews,
tags: tags,
settings: settings,
deletions: deletions,
@ -154,6 +155,7 @@ Future<PersonDependencies> _openPerson(
await studysets.dispose();
await tags.dispose();
await settings.dispose();
await reviews.dispose();
await cards.dispose();
await sync.dispose();
await db.close();

View file

@ -0,0 +1,12 @@
/// When the UUID v7 [id] was made, from the milliseconds since 1970 in its
/// first 48 bits (RFC 9562), in UTC; null for an id of another version.
DateTime? uuidV7Time(String id) {
final hex = id.replaceAll('-', '');
if (hex.length != 32 || hex[12] != '7') {
return null;
}
final millis = int.tryParse(hex.substring(0, 12), radix: 16);
return millis == null
? null
: DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
}

View file

@ -3,6 +3,10 @@ import 'dart:typed_data';
/// A card's row as the device's database stores it.
typedef CardRow = ({String id, String position});
/// A card wherever it is: its id, its studyset, and when it was made (from
/// its UUID v7 id; null for an id that doesn't say).
typedef CardStamp = ({String id, String studysetId, DateTime? createdAt});
/// A card revision's row as the device's database stores it.
typedef CardRevisionRow = ({
String id,
@ -17,6 +21,9 @@ abstract interface class CardGateway() {
/// The cards of the studyset [studysetId] on this device, unordered.
Future<List<CardRow>> cardsOf(String studysetId);
/// Every card of the person on this device, unordered.
Future<List<CardStamp>> allCards();
/// The revisions of the cards [cardIds], unordered.
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds);

View file

@ -86,6 +86,10 @@ class CardRepository(final CardGateway _gateway) {
);
}
/// Every card of the person on this device: its id, its studyset, and
/// when it was made.
Future<List<CardStamp>> allCards() => _gateway.allCards();
/// Creates a card with [content] at the end of [studysetId]; returns its
/// id.
Future<String> create(String studysetId, CardContent content) async {

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
@ -24,6 +25,16 @@ class SyncCardGateway(
(id: card.id!.uuid, position: card.position),
];
@override
Future<List<CardStamp>> allCards() async => [
for (final card in await protocol.Card.db.find(_db))
(
id: card.id!.uuid,
studysetId: card.studysetId.uuid,
createdAt: uuidV7Time(card.id!.uuid),
),
];
@override
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async {
if (cardIds.isEmpty) {

View file

@ -0,0 +1,220 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// The home dashboard: the streak, the study goal and how far it got, the
/// week with its rest days and the cards falling due, the activity of the
/// past year, and how the person arranged it. It follows every rating, card,
/// studyset and setting, here or synced.
class HomeBloc(
final StudysetRepository _studysets,
final CardRepository _cards,
final ReviewRepository _reviews,
final SettingsRepository _settings,
) extends Bloc<HomeEvent, HomeState> {
this : super(const HomeStateLoading()) {
on<HomeEventStarted>(_onStarted);
on<HomeEventChanged>((event, emit) => _refresh(emit));
on<HomeEventRestDayToggled>(
(event, emit) => _settings.update(
(settings) => settings.copyWith(
restDays: settings.restDays.contains(event.day)
? ({...settings.restDays}..remove(event.day))
: {...settings.restDays, event.day},
),
),
);
on<HomeEventGoalSet>(
(event, emit) =>
_settings.update((settings) => settings.withGoal(event.goal)),
);
on<HomeEventLayoutChanged>(
(event, emit) => _settings.update(
(settings) => settings.copyWith(home: event.layout),
),
);
}
final _subscriptions = <StreamSubscription<Object?>>[];
var _settingsNow = const PersonSettings();
var _studysetsNow = const <Studyset>[];
/// How often the dashboard was worked out, so that a slow, older working
/// out never overwrites a newer one.
var _refreshes = 0;
void _onStarted(HomeEventStarted event, Emitter<HomeState> emit) {
void changed(Object? _) => add(const HomeEventChanged());
_subscriptions.addAll([
_reviews.changes.listen(changed),
_cards.changes.listen(changed),
_settings.watch().listen((settings) {
_settingsNow = settings;
changed(null);
}, onError: (_) {}),
_studysets.watchAll().listen((studysets) {
_studysetsNow = studysets;
changed(null);
}, onError: (_) {}),
]);
}
Future<void> _refresh(Emitter<HomeState> emit) async {
final refresh = ++_refreshes;
try {
final now = clock.now();
final reviews = await _reviews.all();
final cards = await _cards.allCards();
final ratings = [
for (final review in reviews)
if (review.mode != StudyMode.reset) review.reviewedAt,
];
final activity = StudyActivity.of(
ratings: ratings,
written: [for (final card in cards) ?card.createdAt],
);
// Cards resting in an archived studyset fall due nowhere.
final resting = {
for (final studyset in _studysetsNow)
if (studyset.isArchived) ...subtreeOf(studyset.id, _studysetsNow),
};
final active = [
for (final card in cards)
if (!resting.contains(card.studysetId)) card.id,
];
final schedules = await _reviews.schedulesOf(active);
if (refresh != _refreshes) {
return;
}
final settings = _settingsNow;
emit(
HomeStateLoaded(
now: now,
activity: activity,
restDays: settings.restDays,
streak: studyStreak(activity, settings.restDays, now),
due: dueByDay([
for (final schedule in schedules.values) ?schedule.due,
], localDay(now)),
goal: settings.goal,
goalProgress: switch (settings.goal) {
final goal? => goalProgress(goal, ratings, now),
null => 0,
},
layout: settings.home,
),
);
} on Object catch (error) {
log('Working out the dashboard failed', name: 'Home', error: error);
}
}
@override
Future<void> close() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await super.close();
}
}
/// What happens on the home dashboard.
sealed class const HomeEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The dashboard opens: follow what it shows.
final class const HomeEventStarted() extends HomeEvent;
/// Something it shows changed: work it out anew.
final class const HomeEventChanged() extends HomeEvent;
/// Rest on [day], or not any more.
final class const HomeEventRestDayToggled(
/// The day, a local date without a time.
final DateTime day,
) extends HomeEvent {
@override
List<Object?> get props => [day];
}
/// Study for [goal]; none when null.
final class const HomeEventGoalSet(
/// The goal.
final StudyGoal? goal,
) extends HomeEvent {
@override
List<Object?> get props => [goal];
}
/// Arrange the dashboard as [layout].
final class const HomeEventLayoutChanged(
/// The layout.
final HomeLayout layout,
) extends HomeEvent {
@override
List<Object?> get props => [layout];
}
/// The home dashboard, as it shows.
sealed class const HomeState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Working it out.
final class const HomeStateLoading() extends HomeState;
/// The dashboard.
final class const HomeStateLoaded({
/// When it was worked out.
required final DateTime now,
/// What the person studied, by day.
required final StudyActivity activity,
/// The days the person rests.
required final Set<DateTime> restDays,
/// The streak, in days.
required final int streak,
/// How many cards fall due on each day.
required final Map<DateTime, int> due,
/// The study goal; null without one.
required final StudyGoal? goal,
/// How many cards were rated in the goal's period.
required final int goalProgress,
/// How the dashboard is arranged.
required final HomeLayout layout,
}) extends HomeState {
@override
List<Object?> get props => [
now,
activity,
restDays,
streak,
due,
goal,
goalProgress,
layout,
];
}

View file

@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
/// A widget of the home dashboard.
enum HomeWidget() {
/// The study goal.
goal,
/// The week overview.
week,
/// The activity heatmap.
activity,
}
/// How the home dashboard is arranged: its widgets' [order], top to bottom,
/// and those [hidden]. Every widget is in the order, also a hidden one,
/// which keeps its place.
final class const HomeLayout({
/// The widgets, top to bottom.
final List<HomeWidget> order = HomeWidget.values,
/// The widgets hidden.
final Set<HomeWidget> hidden = const {},
}) with Equatable {
/// The layout with [order] (widgets a newer version knows dropped,
/// widgets it misses added at the end) and [hidden].
factory reconciled(List<HomeWidget> order, Set<HomeWidget> hidden) =>
HomeLayout(
order: [
...{...order},
for (final widget in HomeWidget.values)
if (!order.contains(widget)) widget,
],
hidden: hidden,
);
/// The widgets shown, top to bottom.
List<HomeWidget> get shown => [
for (final widget in order)
if (!hidden.contains(widget)) widget,
];
@override
List<Object?> get props => [order, hidden];
}

View file

@ -0,0 +1,147 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
/// The local day [instant] falls on, without a time.
DateTime localDay(DateTime instant) {
final local = instant.toLocal();
return DateTime(local.year, local.month, local.day);
}
/// [day] moved by [days] calendar days, staying a date without a time.
DateTime addDays(DateTime day, int days) =>
DateTime(day.year, day.month, day.day + days);
/// The Monday of the week [day] lies in.
DateTime mondayOf(DateTime day) => addDays(day, -(day.weekday - 1));
/// How much the person studied on each local day: cards rated, crammed
/// included, and cards written by hand (decided 2026-10-01).
final class const StudyActivity(
/// What happened on each day, by the day.
final Map<DateTime, int> byDay,
) with Equatable {
/// The activity of [ratings] and [written] cards, each an instant, by the
/// local day it fell on.
factory of({
required Iterable<DateTime> ratings,
required Iterable<DateTime> written,
}) {
final byDay = <DateTime, int>{};
for (final instant in [...ratings, ...written]) {
final day = localDay(instant);
byDay[day] = (byDay[day] ?? 0) + 1;
}
return StudyActivity(byDay);
}
/// No activity at all.
static const none = StudyActivity({});
/// How much happened on [day].
int on(DateTime day) => byDay[day] ?? 0;
/// Whether the person studied on [day].
bool studiedOn(DateTime day) => on(day) > 0;
@override
List<Object?> get props => [byDay];
}
/// The streak: how many days in a row the person studied or rested, up to
/// today when it counts, else up to yesterday (today is still open); 0 once
/// a day without either broke it.
int studyStreak(StudyActivity activity, Set<DateTime> restDays, DateTime now) {
bool counts(DateTime day) =>
activity.studiedOn(day) || restDays.contains(day);
final today = localDay(now);
var day = counts(today) ? today : addDays(today, -1);
var streak = 0;
while (counts(day)) {
streak++;
day = addDays(day, -1);
}
return streak;
}
/// How a day shows in the week overview and the streak's week.
enum DayState() {
/// Studied.
studied,
/// Rested, keeping the streak.
rested,
/// Past, without studying or resting.
missed,
/// Today, not studied yet.
today,
/// Still to come.
upcoming,
}
/// A day of a week: its date, how it stands, and how much was studied.
typedef WeekDay = ({DateTime day, DayState state, int studied});
/// Monday to Sunday of the week [day] lies in, as of [now].
List<WeekDay> weekOf(
DateTime day,
StudyActivity activity,
Set<DateTime> restDays,
DateTime now,
) {
final today = localDay(now);
final monday = mondayOf(day);
return [
for (var i = 0; i < 7; i++)
() {
final date = addDays(monday, i);
return (
day: date,
studied: activity.on(date),
state: activity.studiedOn(date)
? DayState.studied
: restDays.contains(date)
? DayState.rested
: date == today
? DayState.today
: date.isAfter(today)
? DayState.upcoming
: DayState.missed,
);
}(),
];
}
/// How dark a day shows in the heatmap, 0 (nothing) to 4: coarse, so one
/// card already shows.
int heatLevel(int count) => switch (count) {
<= 0 => 0,
<= 4 => 1,
<= 9 => 2,
<= 19 => 3,
_ => 4,
};
/// How many cards fall due on each local day: those overdue on [today].
Map<DateTime, int> dueByDay(Iterable<DateTime> dues, DateTime today) {
final byDay = <DateTime, int>{};
for (final due in dues) {
final day = localDay(due);
final counted = day.isBefore(today) ? today : day;
byDay[counted] = (byDay[counted] ?? 0) + 1;
}
return byDay;
}
/// How many cards were rated in [goal]'s period that [now] lies in: since
/// the local midnight, or since Monday's.
int goalProgress(StudyGoal goal, Iterable<DateTime> ratings, DateTime now) {
final today = localDay(now);
final start = switch (goal.period) {
GoalPeriod.day => today,
GoalPeriod.week => mondayOf(today),
};
return ratings.where((rating) => !localDay(rating).isBefore(start)).length;
}

View file

@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
/// How often a study goal starts over.
enum GoalPeriod() {
/// Every local day.
day,
/// Every week, from Monday.
week,
}
/// A study goal: rating at least [target] cards a [period].
final class const StudyGoal({
/// How often it starts over.
required final GoalPeriod period,
/// How many cards to rate in a period.
required final int target,
}) with Equatable {
/// The lowest target.
static const minTarget = 1;
/// The highest target.
static const maxTarget = 1000;
/// A new goal's target.
static const defaultTarget = 20;
@override
List<Object?> get props => [period, target];
}

View file

@ -1,6 +1,8 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -10,10 +12,13 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
//
// Schema 1 held the tags each studyset is studied by. Schema 2 added how the
// person studies (mode, order, cards per round, new cards per day, back
// first), each left out while as it comes; schema 1 reads with those.
// first), each left out while as it comes. Schema 3 added the study goal
// (period and target), the rest days (dates as YYYY-MM-DD) and the home
// layout (the widgets' order and those hidden, by name). Older schemas read
// with what they lack as it comes.
/// The schema this app writes.
const settingsSchemaVersion = 2;
const settingsSchemaVersion = 3;
/// Encodes [settings] into their payload.
ByteData encodeSettingsPayload(PersonSettings settings) {
@ -35,9 +40,26 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
if (study.newPerDay != defaults.newPerDay) 'newPerDay': study.newPerDay,
if (study.backFirst != defaults.backFirst) 'backFirst': study.backFirst,
},
if (settings.goal case final goal?)
'goal': {'period': goal.period.name, 'target': goal.target},
if (settings.restDays.isNotEmpty)
'restDays': [
for (final day in (settings.restDays.toList()..sort())) _date(day),
],
if (settings.home != const HomeLayout())
'home': {
'order': [for (final widget in settings.home.order) widget.name],
'hidden': [for (final widget in settings.home.hidden) widget.name],
},
});
}
/// [day] as YYYY-MM-DD.
String _date(DateTime day) =>
'${day.year.toString().padLeft(4, '0')}-'
'${day.month.toString().padLeft(2, '0')}-'
'${day.day.toString().padLeft(2, '0')}';
/// Decodes a person's settings.
///
/// Throws a [FormatException] for anything else, including settings written
@ -51,6 +73,13 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
studysetId: _tagRefs(tags),
},
study: _study(json['study']),
goal: _goal(json['goal']),
restDays: {
if (json['restDays'] case final List<Object?> days)
for (final day in days)
if (day is String) ?_parseDate(day),
},
home: _home(json['home']),
);
}
throw const FormatException('Not a settings payload.');
@ -96,3 +125,47 @@ StudyOptions _study(Object? json) {
},
);
}
/// The study goal in [json]; none for a period this app doesn't know.
StudyGoal? _goal(Object? json) {
if (json case {'period': final String period, 'target': final int target}) {
final known = GoalPeriod.values
.where((each) => each.name == period)
.firstOrNull;
if (known != null) {
return StudyGoal(
period: known,
target: target.clamp(StudyGoal.minTarget, StudyGoal.maxTarget),
);
}
}
return null;
}
/// The day [text] names as YYYY-MM-DD, or null.
DateTime? _parseDate(String text) {
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(text);
if (match == null) {
return null;
}
return DateTime(
int.parse(match[1]!),
int.parse(match[2]!),
int.parse(match[3]!),
);
}
/// The home layout in [json]; widgets this app doesn't know are left out.
HomeLayout _home(Object? json) {
List<HomeWidget> named(Object? names) => [
if (names case final List<Object?> list)
for (final name in list)
?HomeWidget.values.where((widget) => widget.name == name).firstOrNull,
];
if (json is! Map<String, Object?>) {
return const HomeLayout();
}
return HomeLayout.reconciled(named(json['order']), {
...named(json['hidden']),
});
}

View file

@ -1,4 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -10,6 +12,16 @@ final class const PersonSettings({
/// How the person studies.
final StudyOptions study = const StudyOptions(),
/// The person's study goal; null without one.
final StudyGoal? goal,
/// The days the person rests, which keep their streak: local dates,
/// without a time.
final Set<DateTime> restDays = const {},
/// How the home dashboard is arranged.
final HomeLayout home = const HomeLayout(),
}) with Equatable {
/// The tags the studyset [studysetId] is studied by; none for all its
/// cards.
@ -19,27 +31,53 @@ final class const PersonSettings({
/// These settings with the studyset [studysetId] studied by [tags], or by
/// all its cards when there are none.
PersonSettings withStudyTags(String studysetId, List<TagRef> tags) =>
PersonSettings(
copyWith(
studyTags: {
for (final MapEntry(:key, :value) in studyTags.entries)
if (key != studysetId) key: value,
if (tags.isNotEmpty) studysetId: tags,
},
study: study,
);
/// These settings with [study] as how the person studies.
PersonSettings withStudy(StudyOptions study) =>
PersonSettings(studyTags: studyTags, study: study);
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
/// These settings with [goal] as the study goal, or none when null.
PersonSettings withGoal(StudyGoal? goal) => PersonSettings(
studyTags: studyTags,
study: study,
goal: goal,
restDays: restDays,
home: home,
);
/// These settings with some fields replaced; [withGoal] clears the goal.
PersonSettings copyWith({
Map<String, List<TagRef>>? studyTags,
StudyOptions? study,
StudyGoal? goal,
Set<DateTime>? restDays,
HomeLayout? home,
}) => PersonSettings(
studyTags: studyTags ?? this.studyTags,
study: study ?? this.study,
goal: goal ?? this.goal,
restDays: restDays ?? this.restDays,
home: home ?? this.home,
);
/// These settings with what only [other] has added: where both have a
/// setting, these win. Two devices' settings fold so.
/// setting, these win; rest days of both count. Two devices' settings
/// fold so.
PersonSettings foldedWith(PersonSettings other) => PersonSettings(
studyTags: {...other.studyTags, ...studyTags},
// Options left as they come count as none.
study: study == const StudyOptions() ? other.study : study,
goal: goal ?? other.goal,
restDays: {...other.restDays, ...restDays},
home: home == const HomeLayout() ? other.home : home,
);
@override
List<Object?> get props => [studyTags, study];
List<Object?> get props => [studyTags, study, goal, restDays, home];
}

View file

@ -14,6 +14,9 @@ abstract interface class ReviewGateway() {
/// The reviews of the cards [cardIds], unordered.
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds);
/// Every review of the person on this device, unordered.
Future<List<ReviewRow>> allReviews();
/// Appends reviews; reviews are never changed.
Future<void> insert(Iterable<ReviewRow> reviews);

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_gateway.dart';
@ -8,6 +9,27 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
/// The person's reviews: an append-only log from which every device derives
/// the same schedules (decided 2026-09-30).
class ReviewRepository(final ReviewGateway _gateway) {
this {
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
}
final _changes = StreamController<void>.broadcast();
late final StreamSubscription<void> _remoteChanges;
/// Fires after every review recorded, here or on another device.
Stream<void> get changes => _changes.stream;
/// Every review of the person on this device, of every mode.
Future<List<Review>> all() async => [
for (final row in await _gateway.allReviews()) ?_decode(row),
];
/// Stops following remote changes.
Future<void> dispose() async {
await _remoteChanges.cancel();
await _changes.close();
}
/// The reviews of the cards [cardIds] on this device.
Future<List<Review>> reviewsOf(Set<String> cardIds) async => [
for (final row in await _gateway.reviewsOf(cardIds)) ?_decode(row),
@ -38,35 +60,43 @@ class ReviewRepository(final ReviewGateway _gateway) {
required DateTime reviewedAt,
required Duration duration,
StudyMode mode = StudyMode.review,
}) => _gateway.insert([
(
id: '',
cardId: cardId,
rating: rating.value,
reviewedAt: reviewedAt.toUtc(),
durationMs: duration.inMilliseconds,
mode: mode.value,
),
]);
/// Starts the progress of [cardIds] afresh at [at], in both directions;
/// their reviews stay, for the history.
Future<void> reset(Iterable<String> cardIds, DateTime at) => _gateway.insert([
for (final cardId in cardIds)
}) async {
await _gateway.insert([
(
id: '',
cardId: cardId,
// The rating column needs a value; a fresh start's isn't read.
rating: Rating.again.value,
reviewedAt: at.toUtc(),
durationMs: 0,
mode: StudyMode.reset.value,
rating: rating.value,
reviewedAt: reviewedAt.toUtc(),
durationMs: duration.inMilliseconds,
mode: mode.value,
),
]);
]);
_changes.add(null);
}
/// Starts the progress of [cardIds] afresh at [at], in both directions;
/// their reviews stay, for the history.
Future<void> reset(Iterable<String> cardIds, DateTime at) async {
await _gateway.insert([
for (final cardId in cardIds)
(
id: '',
cardId: cardId,
// The rating column needs a value; a fresh start's isn't read.
rating: Rating.again.value,
reviewedAt: at.toUtc(),
durationMs: 0,
mode: StudyMode.reset.value,
),
]);
_changes.add(null);
}
/// Undoes [reset] of [cardIds] at [at].
Future<void> undoReset(Iterable<String> cardIds, DateTime at) =>
_gateway.deleteResets({...cardIds}, at.toUtc());
Future<void> undoReset(Iterable<String> cardIds, DateTime at) async {
await _gateway.deleteResets({...cardIds}, at.toUtc());
_changes.add(null);
}
/// Of [reviews], those of [mode] after the last fresh start.
List<Review> _since(List<Review> reviews, StudyMode mode) {

View file

@ -32,6 +32,19 @@ class SyncReviewGateway(
];
}
@override
Future<List<ReviewRow>> allReviews() async => [
for (final review in await protocol.ReviewLog.db.find(_db))
(
id: review.id!.uuid,
cardId: review.cardId.uuid,
rating: review.rating,
reviewedAt: review.reviewedAt,
durationMs: review.durationMs,
mode: review.mode,
),
];
@override
Future<void> insert(Iterable<ReviewRow> reviews) async {
final rows = [

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
void main() {
test('reads the time a UUID v7 was made', () {
// RFC 9562, appendix A.6: 2022-02-22 19:22:22 UTC.
expect(
uuidV7Time('017f22e2-79b0-7cc3-98c4-dc0c0c07398f'),
DateTime.utc(2022, 2, 22, 19, 22, 22),
);
});
test('has no time for another version or no UUID', () {
expect(uuidV7Time('f81d4fae-7dec-41d0-a765-00a0c91e6bf6'), isNull);
expect(uuidV7Time('card-1'), isNull);
});
}

View file

@ -0,0 +1,162 @@
import 'package:clock/clock.dart';
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/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_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.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:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late ReviewRepository reviews;
late SettingsRepository settings;
late HomeBloc bloc;
// A Wednesday afternoon, local time.
final now = DateTime(2026, 10, 7, 15);
final today = DateTime(2026, 10, 7);
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
reviews = ReviewRepository(FakeReviewGateway());
settings = SettingsRepository(FakeSettingsGateway());
});
tearDown(() async {
await bloc.close();
await studysets.dispose();
await cards.dispose();
await reviews.dispose();
await settings.dispose();
});
CardContent card() =>
CardContent(front: RichContent.empty(), back: RichContent.empty());
void start() =>
bloc = HomeBloc(studysets, cards, reviews, settings)
..add(const HomeEventStarted());
Future<HomeStateLoaded> showing(bool Function(HomeStateLoaded) test) async =>
await bloc.stream.firstWhere((s) => s is HomeStateLoaded && test(s))
as HomeStateLoaded;
test('counts ratings and cards written, keeps the streak and the goal, '
'and follows new ratings', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 1)),
duration: Duration.zero,
);
await settings.update(
(s) => s.withGoal(const StudyGoal(period: GoalPeriod.day, target: 5)),
);
start();
// Written today, rated yesterday: two days in a row.
final first = await showing((s) => s.goal != null);
expect(first.streak, 2);
expect(first.activity.on(today), 1);
expect(first.goalProgress, 0);
await reviews.record(
cardId: neko,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
final rated = await showing((s) => s.goalProgress == 1);
expect(rated.activity.on(today), 2);
});
});
test('cards fall due on their day, but those resting in an archived '
'studyset', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final german = await studysets.create(StudysetContent.titled('German'));
for (final studyset in [japanese, german]) {
final id = await cards.create(studyset, card());
await reviews.record(
cardId: id,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
}
await studysets.archive(german);
start();
final state = await showing((s) => s.due.isNotEmpty);
expect(state.due.values.fold(0, (a, b) => a + b), 1);
});
});
test('a rest day keeps the streak, and toggles off again', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 2)),
duration: Duration.zero,
);
start();
// Written today, rated two days ago: yesterday breaks it.
expect((await showing((s) => true)).streak, 1);
final yesterday = DateTime(2026, 10, 6);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isNotEmpty)).streak, 3);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isEmpty)).streak, 1);
});
});
test('saves the goal and the layout in the settings', () async {
start();
await showing((s) => true);
bloc
..add(
const HomeEventGoalSet(StudyGoal(period: GoalPeriod.week, target: 50)),
)
..add(
const HomeEventLayoutChanged(
HomeLayout(
order: [HomeWidget.week, HomeWidget.goal, HomeWidget.activity],
hidden: {HomeWidget.activity},
),
),
);
final state = await showing(
(s) => s.goal != null && s.layout.hidden.isNotEmpty,
);
expect(state.layout.shown, [HomeWidget.week, HomeWidget.goal]);
expect((await settings.read()).goal?.target, 50);
});
}

View file

@ -0,0 +1,103 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
void main() {
// A Wednesday, in the afternoon, local time.
final now = DateTime(2026, 10, 7, 15);
DateTime day(int d) => DateTime(2026, 10, d);
DateTime at(int d, [int hour = 10]) => DateTime(2026, 10, d, hour);
StudyActivity studied(List<int> days) =>
StudyActivity.of(ratings: [for (final d in days) at(d)], written: []);
test('counts ratings and cards written by the local day they fell on', () {
final activity = StudyActivity.of(
ratings: [at(5, 9), at(5, 23)],
written: [at(6)],
);
expect(activity.on(day(5)), 2);
expect(activity.on(day(6)), 1);
expect(activity.studiedOn(day(4)), isFalse);
});
group('The streak', () {
test('runs up to today once studied today', () {
expect(studyStreak(studied([5, 6, 7]), {}, now), 3);
});
test('runs up to yesterday while today is still open', () {
expect(studyStreak(studied([5, 6]), {}, now), 2);
});
test('is broken by a day without studying or resting', () {
expect(studyStreak(studied([4, 6, 7]), {}, now), 2);
expect(studyStreak(studied([4, 5]), {}, now), 0);
});
test('keeps going over a rest day, which counts', () {
expect(studyStreak(studied([4, 6, 7]), {day(5)}, now), 4);
});
});
test('a week runs Monday to Sunday, each day as it stands', () {
final week = weekOf(day(7), studied([5, 7]), {day(6)}, now);
expect(week.map((each) => each.day.day), [5, 6, 7, 8, 9, 10, 11]);
expect(week.map((each) => each.state), [
DayState.studied,
DayState.rested,
DayState.studied,
DayState.upcoming,
DayState.upcoming,
DayState.upcoming,
DayState.upcoming,
]);
expect(
weekOf(day(7), studied([]), {}, now).take(3).map((each) => each.state),
[DayState.missed, DayState.missed, DayState.today],
);
});
test('heat starts at one card and darkens in coarse steps', () {
expect([0, 1, 4, 5, 9, 10, 19, 20].map(heatLevel), [
0,
1,
1,
2,
2,
3,
3,
4,
]);
});
test('cards fall due on their day; overdue ones today', () {
expect(dueByDay([at(1), at(7), at(9), at(9, 20)], day(7)), {
day(7): 2,
day(9): 2,
});
});
test("a goal's progress counts the ratings of its day or week", () {
final ratings = [at(4), at(5), at(6), at(7), at(7, 14)];
expect(
goalProgress(
const StudyGoal(period: GoalPeriod.day, target: 20),
ratings,
now,
),
2,
);
expect(
goalProgress(
const StudyGoal(period: GoalPeriod.week, target: 20),
ratings,
now,
),
4,
);
});
}

View file

@ -1,5 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
@ -95,4 +97,50 @@ void main() {
StudyOrder.newestFirst,
);
});
test('the goal, rest days and home layout survive a round trip', () {
final settings = PersonSettings(
goal: const StudyGoal(period: GoalPeriod.week, target: 150),
restDays: {DateTime(2026, 10, 5), DateTime(2026, 12, 24)},
home: const HomeLayout(
order: [HomeWidget.activity, HomeWidget.goal, HomeWidget.week],
hidden: {HomeWidget.week},
),
);
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
});
test('a home layout from another version keeps the widgets this one '
'knows, and adds those it misses', () {
final settings = decodeSettingsPayload(
encodePayload(3, {
'studyTags': {},
'home': {
'order': ['activity', 'pomodoro'],
'hidden': ['pomodoro'],
},
}),
);
expect(settings.home.order, [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.week,
]);
expect(settings.home.hidden, isEmpty);
});
test('folding keeps both rest days, and the goal one of them has', () {
final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)});
final theirs = PersonSettings(
goal: const StudyGoal(period: GoalPeriod.day, target: 20),
restDays: {DateTime(2026, 10, 6)},
);
final folded = mine.foldedWith(theirs);
expect(folded.restDays, {DateTime(2026, 10, 5), DateTime(2026, 10, 6)});
expect(folded.goal, theirs.goal);
});
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -8,7 +9,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
class FakeCardGateway() implements CardGateway {
final _cards = <String, ({String studysetId, String position})>{};
final _cards =
<String, ({String studysetId, String position, DateTime createdAt})>{};
final _revisions = <String, CardRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
@ -50,6 +52,12 @@ class FakeCardGateway() implements CardGateway {
if (card.studysetId == studysetId) (id: id, position: card.position),
];
@override
Future<List<CardStamp>> allCards() async => [
for (final MapEntry(key: id, value: card) in _cards.entries)
(id: id, studysetId: card.studysetId, createdAt: card.createdAt),
];
@override
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
for (final revision in _revisions.values)
@ -66,7 +74,11 @@ class FakeCardGateway() implements CardGateway {
throw error;
}
final id = _newId('card');
_cards[id] = (studysetId: studysetId, position: position);
_cards[id] = (
studysetId: studysetId,
position: position,
createdAt: clock.now().toUtc(),
);
await insertRevision(cardId: id, parents: const {}, payload: payload);
return id;
}
@ -93,7 +105,11 @@ class FakeCardGateway() implements CardGateway {
@override
Future<void> updatePosition(String cardId, String position) async {
if (_cards[cardId] case final card?) {
_cards[cardId] = (studysetId: card.studysetId, position: position);
_cards[cardId] = (
studysetId: card.studysetId,
position: position,
createdAt: card.createdAt,
);
}
}

View file

@ -13,6 +13,9 @@ class FakeReviewGateway() implements ReviewGateway {
if (cardIds.contains(row.cardId)) row,
];
@override
Future<List<ReviewRow>> allReviews() async => [...rows];
@override
Future<void> insert(Iterable<ReviewRow> reviews) async {
for (final review in reviews) {

Some files were not shown because too many files have changed in this diff Show more