feat(app): studyset payload schema 2, stars and the archive
A studyset is now its content (StudysetContent, everything the synced payload holds) and the columns that change on their own: where it sits (parent, position), and when it was archived or starred. Like a card's content, the payload's part is edited as one value; the columns are written one by one, so starring or archiving on one device and editing on another all survive a sync (ADR 0004). The payload's schema 2 adds the secondary title, the colour (a palette name), the licence (SPDX identifier or name, holder, year) and the languages of the cards' two sides (BCP 47), each left out when not given. Schema 1 reads as schema 2 without them, and a field of the wrong kind makes the payload unreadable rather than half read. Older app versions skip schema 2 studysets, which costs nothing before the first release; fields foreseeable for the first release's features are in, so none of them needs a schema 3 later. The repository stars, unstars, archives and unarchives (at clock time, in UTC), writing only that column. A new sync test (run against a server) checks that a star and an archive on one device and a rename on another all arrive on both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2c1c7d3e42
commit
b171b8e1d4
20 changed files with 528 additions and 136 deletions
|
|
@ -64,7 +64,9 @@ class StudysetEditBloc(
|
||||||
on<StudysetEditEventTitleChanged>(
|
on<StudysetEditEventTitleChanged>(
|
||||||
(event, emit) => emit(
|
(event, emit) => emit(
|
||||||
StudysetEditState(
|
StudysetEditState(
|
||||||
studyset: state.studyset.copyWith(title: event.title),
|
studyset: state.studyset.withContent(
|
||||||
|
state.studyset.content.copyWith(title: event.title),
|
||||||
|
),
|
||||||
saved: state.saved,
|
saved: state.saved,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -72,7 +74,9 @@ class StudysetEditBloc(
|
||||||
on<StudysetEditEventDescriptionChanged>(
|
on<StudysetEditEventDescriptionChanged>(
|
||||||
(event, emit) => emit(
|
(event, emit) => emit(
|
||||||
StudysetEditState(
|
StudysetEditState(
|
||||||
studyset: state.studyset.copyWith(description: event.description),
|
studyset: state.studyset.withContent(
|
||||||
|
state.studyset.content.copyWith(description: event.description),
|
||||||
|
),
|
||||||
saved: state.saved,
|
saved: state.saved,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -86,7 +90,7 @@ class StudysetEditBloc(
|
||||||
Emitter<StudysetEditState> emit,
|
Emitter<StudysetEditState> emit,
|
||||||
) async {
|
) async {
|
||||||
final studyset = state.studyset;
|
final studyset = state.studyset;
|
||||||
await _studysets.update(studyset);
|
await _studysets.updateContent(studyset.id, studyset.content);
|
||||||
emit(StudysetEditState(studyset: studyset, saved: studyset));
|
emit(StudysetEditState(studyset: studyset, saved: studyset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
/// What happens to the list of studysets.
|
/// What happens to the list of studysets.
|
||||||
sealed class const StudysetListEvent() with Equatable {
|
sealed class const StudysetListEvent() with Equatable {
|
||||||
|
|
@ -67,6 +68,6 @@ class StudysetListBloc(final StudysetRepository _studysets)
|
||||||
StudysetListEventCreateRequested event,
|
StudysetListEventCreateRequested event,
|
||||||
Emitter<StudysetListState> emit,
|
Emitter<StudysetListState> emit,
|
||||||
) async {
|
) async {
|
||||||
await _studysets.create(title: event.title);
|
await _studysets.create(StudysetContent.titled(event.title));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,38 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// A studyset's row as the device's database stores it.
|
/// A studyset's row as the device's database stores it: the payload, and the
|
||||||
typedef StudysetRow = ({String id, ByteData payload});
|
/// columns that change on their own (ADR 0004).
|
||||||
|
typedef StudysetRow = ({
|
||||||
|
String id,
|
||||||
|
ByteData payload,
|
||||||
|
String? parentId,
|
||||||
|
String position,
|
||||||
|
DateTime? archivedAt,
|
||||||
|
DateTime? starredAt,
|
||||||
|
});
|
||||||
|
|
||||||
/// The boundary to the studysets in this person's local, synced database.
|
/// The boundary to the studysets in this person's local, synced database.
|
||||||
/// Tests replace it with a fake.
|
/// Tests replace it with a fake.
|
||||||
|
///
|
||||||
|
/// Every update writes only its own column, so a change of another column on
|
||||||
|
/// another device survives the merge (ADR 0001, rule 1).
|
||||||
abstract interface class StudysetGateway() {
|
abstract interface class StudysetGateway() {
|
||||||
/// All studysets on this device, oldest first.
|
/// All studysets on this device, oldest first.
|
||||||
Future<List<StudysetRow>> all();
|
Future<List<StudysetRow>> all();
|
||||||
|
|
||||||
/// Stores a new studyset and returns its id, a UUID v7 made on the device.
|
/// Stores a new studyset at the top level and returns its id, a UUID v7
|
||||||
|
/// made on the device.
|
||||||
Future<String> insert(ByteData payload);
|
Future<String> insert(ByteData payload);
|
||||||
|
|
||||||
/// Replaces a studyset's payload; the only column that changes.
|
/// Replaces a studyset's payload.
|
||||||
Future<void> updatePayload(String id, ByteData payload);
|
Future<void> updatePayload(String id, ByteData payload);
|
||||||
|
|
||||||
|
/// Sets or clears when a studyset was starred.
|
||||||
|
Future<void> updateStarredAt(String id, DateTime? starredAt);
|
||||||
|
|
||||||
|
/// Sets or clears when a studyset was archived.
|
||||||
|
Future<void> updateArchivedAt(String id, DateTime? archivedAt);
|
||||||
|
|
||||||
/// Deletes a studyset's row.
|
/// Deletes a studyset's row.
|
||||||
Future<void> delete(String id);
|
Future<void> delete(String id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,30 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
// A studyset's content as its synced row stores it (see core/content/payload).
|
// A studyset's content as its synced row stores it (see core/content/payload).
|
||||||
|
//
|
||||||
|
// Schema 1 held the title and the description. Schema 2 added the secondary
|
||||||
|
// title, the colour, the licence (name, holder, year) and the languages of
|
||||||
|
// the cards' sides, each left out when not given; schema 1 reads as schema 2
|
||||||
|
// without them.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const studysetSchemaVersion = 1;
|
const studysetSchemaVersion = 2;
|
||||||
|
|
||||||
/// A studyset's content.
|
|
||||||
typedef StudysetContent = ({String title, RichContent description});
|
|
||||||
|
|
||||||
/// Encodes a studyset's content into its payload.
|
/// Encodes a studyset's content into its payload.
|
||||||
ByteData encodeStudysetPayload(StudysetContent content) =>
|
ByteData encodeStudysetPayload(StudysetContent content) =>
|
||||||
encodePayload(studysetSchemaVersion, {
|
encodePayload(studysetSchemaVersion, {
|
||||||
'title': content.title,
|
'title': content.title,
|
||||||
'description': content.description.toJson(),
|
'description': content.description.toJson(),
|
||||||
|
'secondaryTitle': ?content.secondaryTitle,
|
||||||
|
'color': ?content.colorName,
|
||||||
|
'license': ?content.license,
|
||||||
|
'licenseHolder': ?content.licenseHolder,
|
||||||
|
'licenseYear': ?content.licenseYear,
|
||||||
|
'frontLanguage': ?content.frontLanguage,
|
||||||
|
'backLanguage': ?content.backLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Decodes a studyset's payload.
|
/// Decodes a studyset's payload.
|
||||||
|
|
@ -28,7 +38,26 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
|
||||||
'title': final String title,
|
'title': final String title,
|
||||||
'description': final Map<String, Object?> description,
|
'description': final Map<String, Object?> description,
|
||||||
}) {
|
}) {
|
||||||
return (title: title, description: RichContent.fromJson(description));
|
return StudysetContent(
|
||||||
|
title: title,
|
||||||
|
description: RichContent.fromJson(description),
|
||||||
|
secondaryTitle: _optional(json, 'secondaryTitle'),
|
||||||
|
colorName: _optional(json, 'color'),
|
||||||
|
license: _optional(json, 'license'),
|
||||||
|
licenseHolder: _optional(json, 'licenseHolder'),
|
||||||
|
licenseYear: _optional(json, 'licenseYear'),
|
||||||
|
frontLanguage: _optional(json, 'frontLanguage'),
|
||||||
|
backLanguage: _optional(json, 'backLanguage'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a studyset payload.');
|
throw const FormatException('Not a studyset payload.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The optional field [key] of [json]: null when absent, and a
|
||||||
|
/// [FormatException] when it holds something other than a [T].
|
||||||
|
T? _optional<T extends Object>(Map<String, Object?> json, String key) =>
|
||||||
|
switch (json[key]) {
|
||||||
|
null => null,
|
||||||
|
final T value => value,
|
||||||
|
_ => throw FormatException('A studyset payload has a malformed $key.'),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
/// The person's studysets: read from this device, changed offline, and synced
|
/// The person's studysets: read from this device, changed offline, and synced
|
||||||
/// with their other devices in the background.
|
/// with their other devices in the background.
|
||||||
|
|
@ -37,24 +38,40 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
for (final row in await _gateway.all()) ?_decode(row),
|
for (final row in await _gateway.all()) ?_decode(row),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Creates a studyset with an empty description; returns its id.
|
/// Creates a studyset with [content] at the top level; returns its id.
|
||||||
Future<String> create({required String title}) async {
|
Future<String> create(StudysetContent content) async {
|
||||||
final id = await _gateway.insert(
|
final id = await _gateway.insert(encodeStudysetPayload(content));
|
||||||
encodeStudysetPayload((title: title, description: RichContent.empty())),
|
|
||||||
);
|
|
||||||
await _refresh();
|
await _refresh();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the studyset's title and description.
|
/// Stores the studyset [id]'s new [content].
|
||||||
Future<void> update(Studyset studyset) async {
|
Future<void> updateContent(String id, StudysetContent content) async {
|
||||||
await _gateway.updatePayload(
|
await _gateway.updatePayload(id, encodeStudysetPayload(content));
|
||||||
studyset.id,
|
await _refresh();
|
||||||
encodeStudysetPayload((
|
}
|
||||||
title: studyset.title,
|
|
||||||
description: studyset.description,
|
/// Stars the studyset [id], keeping it at the top of the library.
|
||||||
)),
|
Future<void> star(String id) async {
|
||||||
);
|
await _gateway.updateStarredAt(id, clock.now().toUtc());
|
||||||
|
await _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the studyset [id]'s star away.
|
||||||
|
Future<void> unstar(String id) async {
|
||||||
|
await _gateway.updateStarredAt(id, null);
|
||||||
|
await _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archives the studyset [id], and so everything inside it.
|
||||||
|
Future<void> archive(String id) async {
|
||||||
|
await _gateway.updateArchivedAt(id, clock.now().toUtc());
|
||||||
|
await _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the studyset [id] out of the archive.
|
||||||
|
Future<void> unarchive(String id) async {
|
||||||
|
await _gateway.updateArchivedAt(id, null);
|
||||||
await _refresh();
|
await _refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,11 +113,13 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
/// written by a newer app version, or one being deleted.
|
/// written by a newer app version, or one being deleted.
|
||||||
Studyset? _decode(StudysetRow row) {
|
Studyset? _decode(StudysetRow row) {
|
||||||
try {
|
try {
|
||||||
final content = decodeStudysetPayload(row.payload);
|
|
||||||
return Studyset(
|
return Studyset(
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: content.title,
|
content: decodeStudysetPayload(row.payload),
|
||||||
description: content.description,
|
parentId: row.parentId,
|
||||||
|
position: row.position,
|
||||||
|
archivedAt: row.archivedAt,
|
||||||
|
starredAt: row.starredAt,
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,14 @@ class SyncStudysetGateway(
|
||||||
_db,
|
_db,
|
||||||
orderBy: (t) => t.id,
|
orderBy: (t) => t.id,
|
||||||
))
|
))
|
||||||
(id: studyset.id!.uuid, payload: studyset.payload),
|
(
|
||||||
|
id: studyset.id!.uuid,
|
||||||
|
payload: studyset.payload,
|
||||||
|
parentId: studyset.parentId?.uuid,
|
||||||
|
position: studyset.position,
|
||||||
|
archivedAt: studyset.archivedAt,
|
||||||
|
starredAt: studyset.starredAt,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -34,21 +41,38 @@ class SyncStudysetGateway(
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updatePayload(String id, ByteData payload) async {
|
Future<void> updatePayload(String id, ByteData payload) async {
|
||||||
final studyset = await protocol.Studyset.db.findById(
|
if (await _find(id) case final studyset?) {
|
||||||
_db,
|
await protocol.Studyset.db.updateRow(
|
||||||
protocol.UuidValue.withValidation(id),
|
_db,
|
||||||
);
|
studyset.copyWith(payload: payload),
|
||||||
if (studyset == null) {
|
columns: (t) => [t.payload],
|
||||||
return;
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
|
||||||
|
if (await _find(id) case final studyset?) {
|
||||||
|
await protocol.Studyset.db.updateRow(
|
||||||
|
_db,
|
||||||
|
studyset.copyWith(starredAt: starredAt),
|
||||||
|
columns: (t) => [t.starredAt],
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
|
||||||
|
if (await _find(id) case final studyset?) {
|
||||||
|
await protocol.Studyset.db.updateRow(
|
||||||
|
_db,
|
||||||
|
studyset.copyWith(archivedAt: archivedAt),
|
||||||
|
columns: (t) => [t.archivedAt],
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
}
|
}
|
||||||
// Only the named column changes, so a concurrent edit of another column
|
|
||||||
// on another device survives the merge (ADR 0001, rule 1).
|
|
||||||
await protocol.Studyset.db.updateRow(
|
|
||||||
_db,
|
|
||||||
studyset.copyWith(payload: payload),
|
|
||||||
columns: (t) => [t.payload],
|
|
||||||
);
|
|
||||||
_sync.requestSync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -62,4 +86,12 @@ class SyncStudysetGateway(
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<void> get remoteChanges => _sync.merges;
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
|
|
||||||
|
/// The studyset [id], or null once it is gone. Updates name the columns
|
||||||
|
/// they write, so a concurrent change of another column on another device
|
||||||
|
/// survives the merge (ADR 0001, rule 1).
|
||||||
|
Future<protocol.Studyset?> _find(String id) => protocol.Studyset.db.findById(
|
||||||
|
_db,
|
||||||
|
protocol.UuidValue.withValidation(id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,50 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
/// A studyset: the cards, notes and paths that belong together.
|
/// A studyset: the cards, notes and paths that belong together.
|
||||||
final class const Studyset({
|
final class const Studyset({
|
||||||
/// A UUID v7, made on the device that created it.
|
/// A UUID v7, made on the device that created it.
|
||||||
required final String id,
|
required final String id,
|
||||||
|
|
||||||
/// The name people see in lists.
|
/// What the person wrote about it.
|
||||||
required final String title,
|
required final StudysetContent content,
|
||||||
|
|
||||||
/// What the studyset is about, as rich text.
|
/// The studyset it sits in; null at the top level.
|
||||||
required final RichContent description,
|
final String? parentId,
|
||||||
|
|
||||||
|
/// Where it stands among its siblings: a fractional index.
|
||||||
|
final String position = 'a0',
|
||||||
|
|
||||||
|
/// When the person archived it, in UTC; null while it is active.
|
||||||
|
final DateTime? archivedAt,
|
||||||
|
|
||||||
|
/// When the person starred it, in UTC; null when it isn't starred.
|
||||||
|
final DateTime? starredAt,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
@override
|
/// Whether it was archived itself; a studyset inside an archived one rests
|
||||||
List<Object?> get props => [id, title, description];
|
/// in the archive too.
|
||||||
|
bool get isArchived => archivedAt != null;
|
||||||
|
|
||||||
/// This studyset with some fields replaced.
|
/// Whether it is starred, to stay at the top of the library.
|
||||||
Studyset copyWith({String? title, RichContent? description}) => Studyset(
|
bool get isStarred => starredAt != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
parentId,
|
||||||
|
position,
|
||||||
|
archivedAt,
|
||||||
|
starredAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// This studyset with [content] instead of its own.
|
||||||
|
Studyset withContent(StudysetContent content) => Studyset(
|
||||||
id: id,
|
id: id,
|
||||||
title: title ?? this.title,
|
content: content,
|
||||||
description: description ?? this.description,
|
parentId: parentId,
|
||||||
|
position: position,
|
||||||
|
archivedAt: archivedAt,
|
||||||
|
starredAt: starredAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
|
||||||
|
/// What a person writes about a studyset: everything its synced payload
|
||||||
|
/// holds. Where it sits and whether it is starred or archived are columns of
|
||||||
|
/// their own (see `Studyset`), so that changing one of those on one device
|
||||||
|
/// and this on another don't overwrite each other.
|
||||||
|
final class const StudysetContent({
|
||||||
|
/// The name people see.
|
||||||
|
required final String title,
|
||||||
|
|
||||||
|
/// What the studyset is about, as rich text.
|
||||||
|
required final RichContent description,
|
||||||
|
|
||||||
|
/// A short qualifier shown above the title, such as "JLPT N5"; null when
|
||||||
|
/// there is none.
|
||||||
|
final String? secondaryTitle,
|
||||||
|
|
||||||
|
/// The name of its colour in the app's palette; null for the default. A
|
||||||
|
/// name this app version doesn't know counts as the default too.
|
||||||
|
final String? colorName,
|
||||||
|
|
||||||
|
/// The licence its content is under: an SPDX identifier where there is one
|
||||||
|
/// (`CC-BY-SA-4.0`), otherwise the licence's name; null when none is given.
|
||||||
|
final String? license,
|
||||||
|
|
||||||
|
/// Who holds the rights, as the licence's attribution names them.
|
||||||
|
final String? licenseHolder,
|
||||||
|
|
||||||
|
/// The year of the copyright notice.
|
||||||
|
final int? licenseYear,
|
||||||
|
|
||||||
|
/// The language of the cards' fronts, as a BCP 47 tag (`ja`); null when not
|
||||||
|
/// given.
|
||||||
|
final String? frontLanguage,
|
||||||
|
|
||||||
|
/// The language of the cards' backs, like [frontLanguage].
|
||||||
|
final String? backLanguage,
|
||||||
|
}) with Equatable {
|
||||||
|
/// Content with only a [title] and an empty description, as a new
|
||||||
|
/// studyset starts.
|
||||||
|
factory titled(String title) =>
|
||||||
|
StudysetContent(title: title, description: RichContent.empty());
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
secondaryTitle,
|
||||||
|
colorName,
|
||||||
|
license,
|
||||||
|
licenseHolder,
|
||||||
|
licenseYear,
|
||||||
|
frontLanguage,
|
||||||
|
backLanguage,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// This content with a new [title] or [description]. The other fields can
|
||||||
|
/// be cleared, so they change by building new content.
|
||||||
|
StudysetContent copyWith({String? title, RichContent? description}) =>
|
||||||
|
StudysetContent(
|
||||||
|
title: title ?? this.title,
|
||||||
|
description: description ?? this.description,
|
||||||
|
secondaryTitle: secondaryTitle,
|
||||||
|
colorName: colorName,
|
||||||
|
license: license,
|
||||||
|
licenseHolder: licenseHolder,
|
||||||
|
licenseYear: licenseYear,
|
||||||
|
frontLanguage: frontLanguage,
|
||||||
|
backLanguage: backLanguage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ class const _StudysetListView() extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
for (final studyset in studysets)
|
for (final studyset in studysets)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(studyset.title),
|
title: Text(studyset.content.title),
|
||||||
onTap: () => context.go(RoutePaths.studyset(studyset.id)),
|
onTap: () => context.go(RoutePaths.studyset(studyset.id)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class const StudysetScreen({
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
initialValue: studyset.title,
|
initialValue: studyset.content.title,
|
||||||
decoration: const InputDecoration(labelText: 'Title'),
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
onChanged: (title) => context.read<StudysetEditBloc>().add(
|
onChanged: (title) => context.read<StudysetEditBloc>().add(
|
||||||
StudysetEditEventTitleChanged(title),
|
StudysetEditEventTitleChanged(title),
|
||||||
|
|
@ -67,7 +67,7 @@ class const StudysetScreen({
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 320,
|
height: 320,
|
||||||
child: RichContentEditor(
|
child: RichContentEditor(
|
||||||
initialContent: studyset.description,
|
initialContent: studyset.content.description,
|
||||||
onChanged: (description) => context
|
onChanged: (description) => context
|
||||||
.read<StudysetEditBloc>()
|
.read<StudysetEditBloc>()
|
||||||
.add(StudysetEditEventDescriptionChanged(description)),
|
.add(StudysetEditEventDescriptionChanged(description)),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter_test/flutter_test.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/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -15,10 +16,12 @@ void main() {
|
||||||
studysets = FakeStudysetGateway()
|
studysets = FakeStudysetGateway()
|
||||||
..receiveRemoteChange(
|
..receiveRemoteChange(
|
||||||
'japanese',
|
'japanese',
|
||||||
encodeStudysetPayload((
|
encodeStudysetPayload(
|
||||||
title: 'Japanese',
|
StudysetContent(
|
||||||
description: RichContent.empty(),
|
title: 'Japanese',
|
||||||
)),
|
description: RichContent.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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_auth_gateway.dart';
|
import '../../../support/fake_auth_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
@ -161,7 +162,7 @@ void main() {
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
gateway.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
|
||||||
await studysets.create(title: 'Japanese');
|
await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
bloc = (await ready())..add(const GitHubSignInEventRequested());
|
bloc = (await ready())..add(const GitHubSignInEventRequested());
|
||||||
await expectLater(
|
await expectLater(
|
||||||
bloc.stream,
|
bloc.stream,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,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/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/auth/model/github_link_outcome.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
||||||
|
|
||||||
import '../../../support/fake_auth_gateway.dart';
|
import '../../../support/fake_auth_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
@ -20,10 +21,12 @@ void main() {
|
||||||
for (var i = 1; i <= studysets; i++) {
|
for (var i = 1; i <= studysets; i++) {
|
||||||
gateway.receiveRemoteChange(
|
gateway.receiveRemoteChange(
|
||||||
'studyset-$i',
|
'studyset-$i',
|
||||||
encodeStudysetPayload((
|
encodeStudysetPayload(
|
||||||
title: 'Studyset $i',
|
StudysetContent(
|
||||||
description: RichContent.empty(),
|
title: 'Studyset $i',
|
||||||
)),
|
description: RichContent.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await pumpApp(
|
await pumpApp(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
||||||
import '../../../support/fake_card_gateway.dart';
|
import '../../../support/fake_card_gateway.dart';
|
||||||
|
|
@ -51,10 +52,12 @@ void main() {
|
||||||
final studysets = FakeStudysetGateway()
|
final studysets = FakeStudysetGateway()
|
||||||
..receiveRemoteChange(
|
..receiveRemoteChange(
|
||||||
'japanese',
|
'japanese',
|
||||||
encodeStudysetPayload((
|
encodeStudysetPayload(
|
||||||
title: 'Japanese',
|
StudysetContent(
|
||||||
description: RichContent.empty(),
|
title: 'Japanese',
|
||||||
)),
|
description: RichContent.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await pumpApp(
|
await pumpApp(
|
||||||
tester,
|
tester,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import 'package:flutter_test/flutter_test.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/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
import '../../../support/fake_card_gateway.dart';
|
import '../../../support/fake_card_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
@ -35,7 +35,8 @@ void main() {
|
||||||
bloc.stream,
|
bloc.stream,
|
||||||
emits(
|
emits(
|
||||||
isA<StudysetListStateLoaded>().having(
|
isA<StudysetListStateLoaded>().having(
|
||||||
(state) => state.studysets.map((studyset) => studyset.title),
|
(state) =>
|
||||||
|
state.studysets.map((studyset) => studyset.content.title),
|
||||||
'titles',
|
'titles',
|
||||||
['Japanese'],
|
['Japanese'],
|
||||||
),
|
),
|
||||||
|
|
@ -68,12 +69,8 @@ void main() {
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
cardGateway = FakeCardGateway();
|
cardGateway = FakeCardGateway();
|
||||||
cards = CardRepository(cardGateway);
|
cards = CardRepository(cardGateway);
|
||||||
final id = await repository.create(title: 'Japanese');
|
final id = await repository.create(StudysetContent.titled('Japanese'));
|
||||||
studyset = Studyset(
|
studyset = Studyset(id: id, content: StudysetContent.titled('Japanese'));
|
||||||
id: id,
|
|
||||||
title: 'Japanese',
|
|
||||||
description: RichContent.empty(),
|
|
||||||
);
|
|
||||||
bloc = StudysetEditBloc(repository, cards, studyset);
|
bloc = StudysetEditBloc(repository, cards, studyset);
|
||||||
});
|
});
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|
@ -108,7 +105,10 @@ void main() {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect((await repository.watchAll().first).single.title, 'Japanese');
|
expect(
|
||||||
|
(await repository.watchAll().first).single.content.title,
|
||||||
|
'Japanese',
|
||||||
|
);
|
||||||
|
|
||||||
bloc.add(const StudysetEditEventSaveRequested());
|
bloc.add(const StudysetEditEventSaveRequested());
|
||||||
await expectLater(
|
await expectLater(
|
||||||
|
|
@ -121,7 +121,7 @@ void main() {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect((await repository.watchAll().first).single.title, '日本語');
|
expect((await repository.watchAll().first).single.content.title, '日本語');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deletes the studyset and says so', () async {
|
test('deletes the studyset and says so', () async {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,16 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
ByteData bytes(Object json) =>
|
ByteData bytes(Object json) =>
|
||||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||||
|
|
||||||
|
Map<String, Object?> json(ByteData payload) =>
|
||||||
|
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||||
|
as Map<String, Object?>;
|
||||||
|
|
||||||
final description = RichContent({
|
final description = RichContent({
|
||||||
'document': {
|
'document': {
|
||||||
'type': 'page',
|
'type': 'page',
|
||||||
|
|
@ -27,22 +32,62 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('The studyset payload', () {
|
group('The studyset payload', () {
|
||||||
test('survives a round trip, Japanese included', () {
|
test('survives a round trip with everything set, Japanese included', () {
|
||||||
final content = (title: '日本語', description: description);
|
final content = StudysetContent(
|
||||||
|
title: '日本語',
|
||||||
|
description: description,
|
||||||
|
secondaryTitle: 'JLPT N5',
|
||||||
|
colorName: 'cobalt',
|
||||||
|
license: 'CC-BY-SA-4.0',
|
||||||
|
licenseHolder: 'Tae Kim',
|
||||||
|
licenseYear: 2006,
|
||||||
|
frontLanguage: 'ja',
|
||||||
|
backLanguage: 'en',
|
||||||
|
);
|
||||||
|
|
||||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('names its schema version', () {
|
test('leaves out what is not given', () {
|
||||||
final payload = encodeStudysetPayload((
|
final payload = encodeStudysetPayload(
|
||||||
title: 'Japanese',
|
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||||
description: RichContent.empty(),
|
);
|
||||||
));
|
|
||||||
final json = jsonDecode(
|
|
||||||
utf8.decode(Uint8List.sublistView(payload)),
|
|
||||||
) as Map<String, Object?>;
|
|
||||||
|
|
||||||
expect(json['schemaVersion'], studysetSchemaVersion);
|
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names its schema version, the second', () {
|
||||||
|
final payload = encodeStudysetPayload(
|
||||||
|
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(studysetSchemaVersion, 2);
|
||||||
|
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the first schema, which had only a title and a '
|
||||||
|
'description', () {
|
||||||
|
final payload = bytes({
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'title': 'Japanese',
|
||||||
|
'description': description.toJson(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
decodeStudysetPayload(payload),
|
||||||
|
StudysetContent(title: 'Japanese', description: description),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a field of the wrong kind', () {
|
||||||
|
final payload = bytes({
|
||||||
|
'schemaVersion': 2,
|
||||||
|
'title': 'Japanese',
|
||||||
|
'description': description.toJson(),
|
||||||
|
'licenseYear': 'long ago',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses a schema newer than this app knows', () {
|
test('refuses a schema newer than this app knows', () {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
||||||
|
|
@ -20,11 +22,14 @@ void main() {
|
||||||
});
|
});
|
||||||
tearDown(() => repository.dispose());
|
tearDown(() => repository.dispose());
|
||||||
|
|
||||||
|
StudysetContent titled(String title) =>
|
||||||
|
StudysetContent(title: title, description: RichContent.empty());
|
||||||
|
|
||||||
Iterable<String> titles(List<Studyset> studysets) =>
|
Iterable<String> titles(List<Studyset> studysets) =>
|
||||||
studysets.map((studyset) => studyset.title);
|
studysets.map((studyset) => studyset.content.title);
|
||||||
|
|
||||||
test('lists what is stored, then every change', () async {
|
test('lists what is stored, then every change', () async {
|
||||||
await repository.create(title: 'Japanese');
|
await repository.create(titled('Japanese'));
|
||||||
final lists = repository.watchAll();
|
final lists = repository.watchAll();
|
||||||
|
|
||||||
final expectation = expectLater(
|
final expectation = expectLater(
|
||||||
|
|
@ -35,44 +40,78 @@ void main() {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
await repository.create(title: 'Kanji');
|
await repository.create(titled('Kanji'));
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reads the studysets on this device once', () async {
|
test('reads the studysets on this device once', () async {
|
||||||
await repository.create(title: 'Japanese');
|
await repository.create(titled('Japanese'));
|
||||||
await repository.create(title: 'Kanji');
|
await repository.create(titled('Kanji'));
|
||||||
|
|
||||||
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
|
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates studysets with an empty description', () async {
|
test('creates studysets at the top level, neither starred nor '
|
||||||
final id = await repository.create(title: 'Japanese');
|
'archived', () async {
|
||||||
|
final content = StudysetContent(
|
||||||
|
title: 'Japanese',
|
||||||
|
description: RichContent.empty(),
|
||||||
|
secondaryTitle: 'JLPT N5',
|
||||||
|
colorName: 'cobalt',
|
||||||
|
);
|
||||||
|
|
||||||
final studysets = await repository.watchAll().first;
|
final id = await repository.create(content);
|
||||||
expect(studysets, [
|
|
||||||
Studyset(id: id, title: 'Japanese', description: RichContent.empty()),
|
expect(await repository.watchAll().first, [
|
||||||
|
Studyset(id: id, content: content),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stores changes to title and description', () async {
|
test('stores changed content', () async {
|
||||||
final id = await repository.create(title: 'Japanese');
|
final id = await repository.create(titled('Japanese'));
|
||||||
final changed = Studyset(
|
final changed = StudysetContent(
|
||||||
id: id,
|
|
||||||
title: '日本語',
|
title: '日本語',
|
||||||
description: RichContent({
|
description: RichContent({
|
||||||
'document': {'type': 'page'},
|
'document': {'type': 'page'},
|
||||||
'note': 'changed',
|
'note': 'changed',
|
||||||
}),
|
}),
|
||||||
|
licenseYear: 2026,
|
||||||
);
|
);
|
||||||
|
|
||||||
await repository.update(changed);
|
await repository.updateContent(id, changed);
|
||||||
|
|
||||||
expect(await repository.watchAll().first, [changed]);
|
expect(await repository.watchAll().first, [
|
||||||
|
Studyset(id: id, content: changed),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stars and unstars, writing nothing but the star', () async {
|
||||||
|
final starredAt = DateTime.utc(2026, 10, 1, 9);
|
||||||
|
final id = await repository.create(titled('Japanese'));
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(starredAt), () => repository.star(id));
|
||||||
|
expect((await repository.all()).single.starredAt, starredAt);
|
||||||
|
|
||||||
|
await repository.unstar(id);
|
||||||
|
expect((await repository.all()).single.isStarred, isFalse);
|
||||||
|
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archives and unarchives, writing nothing but the archive '
|
||||||
|
'date', () async {
|
||||||
|
final archivedAt = DateTime.utc(2026, 10, 1, 9);
|
||||||
|
final id = await repository.create(titled('Japanese'));
|
||||||
|
|
||||||
|
await withClock(Clock.fixed(archivedAt), () => repository.archive(id));
|
||||||
|
expect((await repository.all()).single.archivedAt, archivedAt);
|
||||||
|
|
||||||
|
await repository.unarchive(id);
|
||||||
|
expect((await repository.all()).single.isArchived, isFalse);
|
||||||
|
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blanks the content before deleting', () async {
|
test('blanks the content before deleting', () async {
|
||||||
final id = await repository.create(title: 'Secret');
|
final id = await repository.create(titled('Secret'));
|
||||||
|
|
||||||
await repository.delete(id);
|
await repository.delete(id);
|
||||||
|
|
||||||
|
|
@ -94,16 +133,13 @@ void main() {
|
||||||
|
|
||||||
gateway.receiveRemoteChange(
|
gateway.receiveRemoteChange(
|
||||||
'laptop-1',
|
'laptop-1',
|
||||||
encodeStudysetPayload((
|
encodeStudysetPayload(titled('From the laptop')),
|
||||||
title: 'From the laptop',
|
|
||||||
description: RichContent.empty(),
|
|
||||||
)),
|
|
||||||
);
|
);
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports a failed read and recovers with the next change', () async {
|
test('reports a failed read and recovers with the next change', () async {
|
||||||
await repository.create(title: 'Japanese');
|
await repository.create(titled('Japanese'));
|
||||||
gateway.failReads = Exception('Disk unavailable');
|
gateway.failReads = Exception('Disk unavailable');
|
||||||
final lists = repository.watchAll();
|
final lists = repository.watchAll();
|
||||||
final expectation = expectLater(
|
final expectation = expectLater(
|
||||||
|
|
@ -116,12 +152,12 @@ void main() {
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
gateway.failReads = null;
|
gateway.failReads = null;
|
||||||
await repository.create(title: 'Kanji');
|
await repository.create(titled('Kanji'));
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips studysets this app version cannot read', () async {
|
test('skips studysets this app version cannot read', () async {
|
||||||
await repository.create(title: 'Readable');
|
await repository.create(titled('Readable'));
|
||||||
gateway.receiveRemoteChange(
|
gateway.receiveRemoteChange(
|
||||||
'future-1',
|
'future-1',
|
||||||
ByteData.sublistView(
|
ByteData.sublistView(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway
|
||||||
/// [receiveRemoteChange] and [receiveRemoteDeletion] play a sync that brought
|
/// [receiveRemoteChange] and [receiveRemoteDeletion] play a sync that brought
|
||||||
/// changes.
|
/// changes.
|
||||||
class FakeStudysetGateway() implements StudysetGateway {
|
class FakeStudysetGateway() implements StudysetGateway {
|
||||||
final _rows = <String, ByteData>{};
|
final _rows = <String, StudysetRow>{};
|
||||||
final _remoteChanges = StreamController<void>.broadcast();
|
final _remoteChanges = StreamController<void>.broadcast();
|
||||||
var _nextId = 1;
|
var _nextId = 1;
|
||||||
|
|
||||||
|
|
@ -21,9 +21,23 @@ class FakeStudysetGateway() implements StudysetGateway {
|
||||||
Exception? failReads;
|
Exception? failReads;
|
||||||
|
|
||||||
/// Stores [payload] as if another device had written it and a sync had
|
/// Stores [payload] as if another device had written it and a sync had
|
||||||
/// brought it here.
|
/// brought it here, with the other columns given.
|
||||||
void receiveRemoteChange(String id, ByteData payload) {
|
void receiveRemoteChange(
|
||||||
_rows[id] = payload;
|
String id,
|
||||||
|
ByteData payload, {
|
||||||
|
String? parentId,
|
||||||
|
String position = 'a0',
|
||||||
|
DateTime? archivedAt,
|
||||||
|
DateTime? starredAt,
|
||||||
|
}) {
|
||||||
|
_rows[id] = (
|
||||||
|
id: id,
|
||||||
|
payload: payload,
|
||||||
|
parentId: parentId,
|
||||||
|
position: position,
|
||||||
|
archivedAt: archivedAt,
|
||||||
|
starredAt: starredAt,
|
||||||
|
);
|
||||||
_remoteChanges.add(null);
|
_remoteChanges.add(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,28 +53,67 @@ class FakeStudysetGateway() implements StudysetGateway {
|
||||||
if (failReads case final error?) {
|
if (failReads case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return [
|
return [..._rows.values];
|
||||||
for (final MapEntry(key: id, value: payload) in _rows.entries)
|
|
||||||
(id: id, payload: payload),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> insert(ByteData payload) async {
|
Future<String> insert(ByteData payload) async {
|
||||||
final id = 'studyset-${_nextId++}';
|
final id = 'studyset-${_nextId++}';
|
||||||
_rows[id] = payload;
|
_rows[id] = (
|
||||||
|
id: id,
|
||||||
|
payload: payload,
|
||||||
|
parentId: null,
|
||||||
|
position: 'a0',
|
||||||
|
archivedAt: null,
|
||||||
|
starredAt: null,
|
||||||
|
);
|
||||||
writes.add((id, payload));
|
writes.add((id, payload));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updatePayload(String id, ByteData payload) async {
|
Future<void> updatePayload(String id, ByteData payload) async {
|
||||||
if (_rows.containsKey(id)) {
|
if (_rows[id] case final row?) {
|
||||||
_rows[id] = payload;
|
_rows[id] = (
|
||||||
|
id: id,
|
||||||
|
payload: payload,
|
||||||
|
parentId: row.parentId,
|
||||||
|
position: row.position,
|
||||||
|
archivedAt: row.archivedAt,
|
||||||
|
starredAt: row.starredAt,
|
||||||
|
);
|
||||||
writes.add((id, payload));
|
writes.add((id, payload));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateStarredAt(String id, DateTime? starredAt) async {
|
||||||
|
if (_rows[id] case final row?) {
|
||||||
|
_rows[id] = (
|
||||||
|
id: id,
|
||||||
|
payload: row.payload,
|
||||||
|
parentId: row.parentId,
|
||||||
|
position: row.position,
|
||||||
|
archivedAt: row.archivedAt,
|
||||||
|
starredAt: starredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async {
|
||||||
|
if (_rows[id] case final row?) {
|
||||||
|
_rows[id] = (
|
||||||
|
id: id,
|
||||||
|
payload: row.payload,
|
||||||
|
parentId: row.parentId,
|
||||||
|
position: row.position,
|
||||||
|
archivedAt: archivedAt,
|
||||||
|
starredAt: row.starredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
_rows.remove(id);
|
_rows.remove(id);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
||||||
|
|
||||||
import 'support/sync_devices.dart';
|
import 'support/sync_devices.dart';
|
||||||
|
|
@ -59,7 +60,9 @@ void main() {
|
||||||
/// A studyset with one card, made on [phone] and synced to [laptop];
|
/// A studyset with one card, made on [phone] and synced to [laptop];
|
||||||
/// returns their ids.
|
/// returns their ids.
|
||||||
Future<(String, String)> shared(SyncDevice phone, SyncDevice laptop) async {
|
Future<(String, String)> shared(SyncDevice phone, SyncDevice laptop) async {
|
||||||
final studyset = await phone.studysets.create(title: 'Japanese');
|
final studyset = await phone.studysets.create(
|
||||||
|
StudysetContent.titled('Japanese'),
|
||||||
|
);
|
||||||
final created = await phone.cards.create(studyset, card('猫', 'cat'));
|
final created = await phone.cards.create(studyset, card('猫', 'cat'));
|
||||||
await phone.sync();
|
await phone.sync();
|
||||||
await laptop.sync();
|
await laptop.sync();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
|
||||||
|
|
||||||
import 'support/sync_devices.dart';
|
import 'support/sync_devices.dart';
|
||||||
|
|
@ -60,23 +61,62 @@ void main() {
|
||||||
final phone = await device(alice, 'phone');
|
final phone = await device(alice, 'phone');
|
||||||
final laptop = await device(alice, 'laptop');
|
final laptop = await device(alice, 'laptop');
|
||||||
|
|
||||||
final id = await phone.studysets.create(title: 'Japanese');
|
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
|
||||||
await phone.sync();
|
await phone.sync();
|
||||||
await laptop.sync();
|
await laptop.sync();
|
||||||
final onLaptop = await laptop.studyset(id);
|
final onLaptop = await laptop.studyset(id);
|
||||||
expect(onLaptop?.title, 'Japanese');
|
expect(onLaptop?.content.title, 'Japanese');
|
||||||
|
|
||||||
await laptop.studysets.update(onLaptop!.copyWith(title: '日本語'));
|
await laptop.studysets.updateContent(
|
||||||
|
id,
|
||||||
|
onLaptop!.content.copyWith(title: '日本語'),
|
||||||
|
);
|
||||||
await laptop.sync();
|
await laptop.sync();
|
||||||
await phone.sync();
|
await phone.sync();
|
||||||
expect((await phone.studyset(id))?.title, '日本語');
|
expect((await phone.studyset(id))?.content.title, '日本語');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starring and archiving on one device and renaming on another all '
|
||||||
|
'survive', () async {
|
||||||
|
final phone = await device(alice, 'phone');
|
||||||
|
final laptop = await device(alice, 'laptop');
|
||||||
|
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
await phone.sync();
|
||||||
|
await laptop.sync();
|
||||||
|
|
||||||
|
await phone.studysets.star(id);
|
||||||
|
await phone.studysets.archive(id);
|
||||||
|
await laptop.studysets.updateContent(
|
||||||
|
id,
|
||||||
|
StudysetContent.titled('日本語'),
|
||||||
|
);
|
||||||
|
await phone.sync();
|
||||||
|
await laptop.sync();
|
||||||
|
await phone.sync();
|
||||||
|
|
||||||
|
for (final device in [phone, laptop]) {
|
||||||
|
final studyset = await device.studyset(id);
|
||||||
|
expect(studyset?.content.title, '日本語');
|
||||||
|
expect(studyset?.isStarred, isTrue);
|
||||||
|
expect(studyset?.isArchived, isTrue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await laptop.studysets.unstar(id);
|
||||||
|
await laptop.studysets.unarchive(id);
|
||||||
|
await laptop.sync();
|
||||||
|
await phone.sync();
|
||||||
|
final onPhone = await phone.studyset(id);
|
||||||
|
expect(onPhone?.isStarred, isFalse);
|
||||||
|
expect(onPhone?.isArchived, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a deleted studyset leaves the other devices and is blank on the '
|
test('a deleted studyset leaves the other devices and is blank on the '
|
||||||
'server', () async {
|
'server', () async {
|
||||||
final phone = await device(alice, 'phone');
|
final phone = await device(alice, 'phone');
|
||||||
final laptop = await device(alice, 'laptop');
|
final laptop = await device(alice, 'laptop');
|
||||||
final id = await phone.studysets.create(title: 'Private notes');
|
final id = await phone.studysets.create(
|
||||||
|
StudysetContent.titled('Private notes'),
|
||||||
|
);
|
||||||
await phone.sync();
|
await phone.sync();
|
||||||
await laptop.sync();
|
await laptop.sync();
|
||||||
|
|
||||||
|
|
@ -103,7 +143,9 @@ void main() {
|
||||||
await bobsPhone.sync();
|
await bobsPhone.sync();
|
||||||
// Alice's device, signed in as her, but its database claims Bob's space.
|
// Alice's device, signed in as her, but its database claims Bob's space.
|
||||||
final forged = await device(alice, 'forged', space: bob.authUserId);
|
final forged = await device(alice, 'forged', space: bob.authUserId);
|
||||||
final intruder = await forged.studysets.create(title: 'Intruder');
|
final intruder = await forged.studysets.create(
|
||||||
|
StudysetContent.titled('Intruder'),
|
||||||
|
);
|
||||||
|
|
||||||
// The server announces which spaces a device syncs (only Alice's), so the
|
// The server announces which spaces a device syncs (only Alice's), so the
|
||||||
// forged row is never sent. A modified client sending it anyway would
|
// forged row is never sent. A modified client sending it anyway would
|
||||||
|
|
@ -126,7 +168,9 @@ void main() {
|
||||||
"claiming another person's space shows none of their studysets",
|
"claiming another person's space shows none of their studysets",
|
||||||
() async {
|
() async {
|
||||||
final bobsPhone = await device(bob, 'bobs-phone');
|
final bobsPhone = await device(bob, 'bobs-phone');
|
||||||
final bobs = await bobsPhone.studysets.create(title: "Bob's");
|
final bobs = await bobsPhone.studysets.create(
|
||||||
|
StudysetContent.titled("Bob's"),
|
||||||
|
);
|
||||||
await bobsPhone.sync();
|
await bobsPhone.sync();
|
||||||
|
|
||||||
// Alice's device claims Bob's space but writes nothing, so nothing is
|
// Alice's device claims Bob's space but writes nothing, so nothing is
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue