feat(app): map questions on Japan's prefectures
A map question is a multiple-choice card drawn on a map: the composer's Map kind takes the region tapped as its answer, and studying it means tapping a region and checking, the right one green and a wrong pick red, each named. The map of Japan's 47 prefectures comes from Natural Earth (public domain), built by tool/maps/japan_prefectures.py from release v5.1.2 (its download checked by hash): simplified, small islands dropped, rounded; about 107 KB. Maps are drawn to fit, tappable and zoomable for small regions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f922fceb47
commit
5a5b2feead
26 changed files with 1033 additions and 42 deletions
|
|
@ -103,7 +103,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
|
||||||
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
|
||||||
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
|
||||||
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`SerialQueue`, `core/async/`).
|
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`SerialQueue`, `core/async/`), and a cache keeps values, not futures (`GeoMapRepository`). Assets are read for real, which the fake clock doesn't advance: a widget test showing a map gets the maps read beforehand (`loadedMaps(tester)` into `testDependencies(maps: ...)`).
|
||||||
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
|
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
|
||||||
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
|
||||||
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
|
- **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does.
|
||||||
|
|
|
||||||
18
halkyon_learn_flutter/assets/maps/ATTRIBUTION.md
Normal file
18
halkyon_learn_flutter/assets/maps/ATTRIBUTION.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Map data
|
||||||
|
|
||||||
|
`japan_prefectures.geojson` derives from **Natural Earth**
|
||||||
|
(<https://www.naturalearthdata.com/>), *Admin 1 – States, Provinces*, 1:10m
|
||||||
|
cultural vectors, release v5.1.2. Natural Earth's map data is in the public
|
||||||
|
domain:
|
||||||
|
|
||||||
|
> All versions of Natural Earth raster and vector map data found on this
|
||||||
|
> website are in the public domain. You may use the maps in any manner,
|
||||||
|
> including modifying the content and design, electronic dissemination, and
|
||||||
|
> offset printing.
|
||||||
|
|
||||||
|
`tool/maps/japan_prefectures.py` builds the file: it keeps Japan's 47
|
||||||
|
prefectures, simplifies their outlines (Douglas–Peucker, 0.006°), drops islands
|
||||||
|
too small to see (each prefecture keeps its largest), and rounds coordinates to
|
||||||
|
4 decimals. The outlines are an approximation for display, not survey-accurate
|
||||||
|
data. Regions are named by their ISO 3166-2 codes (`JP-01` … `JP-47`), with
|
||||||
|
Natural Earth's English and Japanese names.
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -309,6 +309,12 @@
|
||||||
"studysetResetConfirm": "Reset",
|
"studysetResetConfirm": "Reset",
|
||||||
"studysetResetDone": "Progress reset",
|
"studysetResetDone": "Progress reset",
|
||||||
|
|
||||||
|
"mapUnknown": "This map needs a newer version of the app.",
|
||||||
|
"mapPickRegion": "Tap the region that answers the question; pinch or scroll to zoom.",
|
||||||
|
"mapNonePicked": "No region picked yet",
|
||||||
|
"composerPlaceholderQuestion": "Add question",
|
||||||
|
"composerLabelRegion": "Answer",
|
||||||
|
|
||||||
"commonCancel": "Cancel",
|
"commonCancel": "Cancel",
|
||||||
"commonClose": "Close",
|
"commonClose": "Close",
|
||||||
"commonUndo": "Undo",
|
"commonUndo": "Undo",
|
||||||
|
|
|
||||||
|
|
@ -1690,6 +1690,36 @@ abstract class AppLocalizations {
|
||||||
/// **'Progress reset'**
|
/// **'Progress reset'**
|
||||||
String get studysetResetDone;
|
String get studysetResetDone;
|
||||||
|
|
||||||
|
/// No description provided for @mapUnknown.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This map needs a newer version of the app.'**
|
||||||
|
String get mapUnknown;
|
||||||
|
|
||||||
|
/// No description provided for @mapPickRegion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap the region that answers the question; pinch or scroll to zoom.'**
|
||||||
|
String get mapPickRegion;
|
||||||
|
|
||||||
|
/// No description provided for @mapNonePicked.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No region picked yet'**
|
||||||
|
String get mapNonePicked;
|
||||||
|
|
||||||
|
/// No description provided for @composerPlaceholderQuestion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add question'**
|
||||||
|
String get composerPlaceholderQuestion;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelRegion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Answer'**
|
||||||
|
String get composerLabelRegion;
|
||||||
|
|
||||||
/// No description provided for @commonCancel.
|
/// No description provided for @commonCancel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetResetDone => 'Progress reset';
|
String get studysetResetDone => 'Progress reset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapUnknown => 'This map needs a newer version of the app.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapPickRegion =>
|
||||||
|
'Tap the region that answers the question; pinch or scroll to zoom.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapNonePicked => 'No region picked yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderQuestion => 'Add question';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelRegion => 'Answer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get commonCancel => 'Cancel';
|
String get commonCancel => 'Cancel';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ final class RichContent(
|
||||||
'document': {'type': 'page'},
|
'document': {'type': 'page'},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// [text] as a document, a paragraph per line.
|
||||||
|
factory plain(String text) => RichContent({
|
||||||
|
'document': {
|
||||||
|
'type': 'page',
|
||||||
|
'children': [
|
||||||
|
for (final line in text.split('\n'))
|
||||||
|
{
|
||||||
|
'type': 'paragraph',
|
||||||
|
'data': {
|
||||||
|
'delta': [
|
||||||
|
if (line.isNotEmpty) {'insert': line},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/// Reads the JSON written by [toJson].
|
/// Reads the JSON written by [toJson].
|
||||||
///
|
///
|
||||||
/// Throws a [FormatException] for anything else, including content in a
|
/// Throws a [FormatException] for anything else, including content in a
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gatew
|
||||||
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/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/data/sync_card_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
|
|
@ -45,6 +46,9 @@ class Dependencies({
|
||||||
|
|
||||||
/// Opens a signed-in person's local data (see `PersonScope`).
|
/// Opens a signed-in person's local data (see `PersonScope`).
|
||||||
required final OpenPerson openPerson,
|
required final OpenPerson openPerson,
|
||||||
|
|
||||||
|
/// The region maps the app bundles, for map questions.
|
||||||
|
required final GeoMapRepository maps,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) {
|
}) {
|
||||||
/// Builds the production graph around one Serverpod client.
|
/// Builds the production graph around one Serverpod client.
|
||||||
|
|
@ -72,6 +76,7 @@ class Dependencies({
|
||||||
return Dependencies(
|
return Dependencies(
|
||||||
auth: AuthRepository(authGateway),
|
auth: AuthRepository(authGateway),
|
||||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||||
|
maps: GeoMapRepository(),
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
await previousClosed.timeout(
|
await previousClosed.timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,6 @@ final class const Card({
|
||||||
/// version, oldest first.
|
/// version, oldest first.
|
||||||
final Map<CardField, List<Object?>> conflicts = const {},
|
final Map<CardField, List<Object?>> conflicts = const {},
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// This card showing [content] instead, e.g. to study it another way.
|
|
||||||
Card withContent(CardContent content) => Card(
|
|
||||||
id: id,
|
|
||||||
position: position,
|
|
||||||
content: content,
|
|
||||||
heads: heads,
|
|
||||||
conflicts: conflicts,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Whether devices changed a field differently and the person has to
|
/// Whether devices changed a field differently and the person has to
|
||||||
/// choose.
|
/// choose.
|
||||||
bool get hasConflict => conflicts.isNotEmpty;
|
bool get hasConflict => conflicts.isNotEmpty;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||||
|
|
@ -27,25 +31,42 @@ enum CardKind() {
|
||||||
vocabulary,
|
vocabulary,
|
||||||
|
|
||||||
/// Items to sort.
|
/// Items to sort.
|
||||||
matching;
|
matching,
|
||||||
|
|
||||||
|
/// A region to point at on a map: a multiple-choice card drawn on it.
|
||||||
|
map;
|
||||||
|
|
||||||
/// The kind of [content].
|
/// The kind of [content].
|
||||||
static CardKind of(CardContent content) => switch (content.type) {
|
static CardKind of(CardContent content) => switch (content.type) {
|
||||||
|
CardType.multipleChoice when content.map != null => map,
|
||||||
CardType.frontBack || CardType.multipleChoice => flashcard,
|
CardType.frontBack || CardType.multipleChoice => flashcard,
|
||||||
CardType.vocabulary => vocabulary,
|
CardType.vocabulary => vocabulary,
|
||||||
CardType.matching => matching,
|
CardType.matching => matching,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// [content] as this kind, every field kept for switching back.
|
/// [content] as this kind, every field kept for switching back but the
|
||||||
CardContent apply(CardContent content) => switch (this) {
|
/// map, which makes a map question.
|
||||||
flashcard => content.copyWith(
|
CardContent apply(CardContent content) {
|
||||||
type: content.answers.length > 1
|
final plain = content.withField(CardField.map, null);
|
||||||
? CardType.multipleChoice
|
return switch (this) {
|
||||||
: CardType.frontBack,
|
flashcard => plain.copyWith(
|
||||||
),
|
type: content.answers.length > 1
|
||||||
vocabulary => content.copyWith(type: CardType.vocabulary),
|
? CardType.multipleChoice
|
||||||
matching => content.copyWith(type: CardType.matching),
|
: CardType.frontBack,
|
||||||
};
|
),
|
||||||
|
vocabulary => plain.copyWith(type: CardType.vocabulary),
|
||||||
|
matching => plain.copyWith(type: CardType.matching),
|
||||||
|
// Only an answer naming a region is one on a map.
|
||||||
|
map => content.copyWith(
|
||||||
|
type: CardType.multipleChoice,
|
||||||
|
map: content.map ?? defaultMapId,
|
||||||
|
answers: [
|
||||||
|
for (final answer in content.answers)
|
||||||
|
if (answer.region != null) answer,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The answers of a flashcard: a front/back card's back as its one right
|
/// The answers of a flashcard: a front/back card's back as its one right
|
||||||
|
|
@ -135,6 +156,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
CardKind.flashcard => l10n.composerLabelFront,
|
CardKind.flashcard => l10n.composerLabelFront,
|
||||||
CardKind.vocabulary => l10n.composerLabelExpression,
|
CardKind.vocabulary => l10n.composerLabelExpression,
|
||||||
CardKind.matching => l10n.composerLabelPrompt,
|
CardKind.matching => l10n.composerLabelPrompt,
|
||||||
|
CardKind.map => l10n.composerLabelQuestion,
|
||||||
};
|
};
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -192,6 +214,10 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
value: CardKind.matching,
|
value: CardKind.matching,
|
||||||
label: Text(l10n.composerKindMatching),
|
label: Text(l10n.composerKindMatching),
|
||||||
),
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: CardKind.map,
|
||||||
|
label: Text(l10n.composerKindMap),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
selected: {kind},
|
selected: {kind},
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
|
|
@ -224,6 +250,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
CardKind.flashcard => l10n.composerPlaceholderFront,
|
CardKind.flashcard => l10n.composerPlaceholderFront,
|
||||||
CardKind.vocabulary => l10n.composerPlaceholderExpression,
|
CardKind.vocabulary => l10n.composerPlaceholderExpression,
|
||||||
CardKind.matching => l10n.composerPlaceholderPrompt,
|
CardKind.matching => l10n.composerPlaceholderPrompt,
|
||||||
|
CardKind.map => l10n.composerPlaceholderQuestion,
|
||||||
},
|
},
|
||||||
startExpanded: widget.focusFront,
|
startExpanded: widget.focusFront,
|
||||||
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
||||||
|
|
@ -264,6 +291,20 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
onChanged: (matching) =>
|
onChanged: (matching) =>
|
||||||
_change(_sent.copyWith(matching: matching)),
|
_change(_sent.copyWith(matching: matching)),
|
||||||
),
|
),
|
||||||
|
CardKind.map => _MapAnswer(
|
||||||
|
content: content,
|
||||||
|
onPicked: (region) => _change(
|
||||||
|
_sent.copyWith(
|
||||||
|
answers: [
|
||||||
|
CardAnswer(
|
||||||
|
content: RichContent.plain(region.name),
|
||||||
|
correct: true,
|
||||||
|
region: region.code,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -295,6 +336,57 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A map question's answer: the region to point at, picked on the map.
|
||||||
|
class const _MapAnswer({
|
||||||
|
required final CardContent content,
|
||||||
|
required final ValueChanged<MapRegion> onPicked,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final picked = content.answers.firstOrNull?.region;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l10n.composerLabelRegion, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.mapPickRegion,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
MapOf(
|
||||||
|
mapId: content.map ?? defaultMapId,
|
||||||
|
builder: (context, map) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
RegionMap(
|
||||||
|
map: map,
|
||||||
|
selected: picked,
|
||||||
|
onRegionTapped: (code) => onPicked(map.region(code)!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
switch (map.region(picked)) {
|
||||||
|
final region? =>
|
||||||
|
region.localName == null
|
||||||
|
? region.name
|
||||||
|
: '${region.name} · ${region.localName}',
|
||||||
|
null => l10n.mapNonePicked,
|
||||||
|
},
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The card's actions: duplicate, clear its fields, delete.
|
/// The card's actions: duplicate, clear its fields, delete.
|
||||||
class const _Actions({
|
class const _Actions({
|
||||||
required final VoidCallback onDuplicate,
|
required final VoidCallback onDuplicate,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
|
/// Loads the bundled map [_id] for what shows it.
|
||||||
|
class GeoMapBloc(final GeoMapRepository _maps, final String _id)
|
||||||
|
extends Bloc<GeoMapEvent, GeoMapState> {
|
||||||
|
this : super(const GeoMapStateLoading()) {
|
||||||
|
on<GeoMapEventStarted>((event, emit) async {
|
||||||
|
emit(switch (await _maps.load(_id)) {
|
||||||
|
final map? => GeoMapStateLoaded(map),
|
||||||
|
null => const GeoMapStateUnknown(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to a map shown.
|
||||||
|
sealed class const GeoMapEvent() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map shows: load it.
|
||||||
|
final class const GeoMapEventStarted() extends GeoMapEvent;
|
||||||
|
|
||||||
|
/// A map, as it shows.
|
||||||
|
sealed class const GeoMapState() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading the map.
|
||||||
|
final class const GeoMapStateLoading() extends GeoMapState;
|
||||||
|
|
||||||
|
/// A map this app doesn't bundle, e.g. one a newer version added.
|
||||||
|
final class const GeoMapStateUnknown() extends GeoMapState;
|
||||||
|
|
||||||
|
/// The map.
|
||||||
|
final class const GeoMapStateLoaded(
|
||||||
|
/// The map.
|
||||||
|
final GeoMap map,
|
||||||
|
) extends GeoMapState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [map.id];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
|
/// Reads the map [id] from GeoJSON as the app's map script writes it: a
|
||||||
|
/// feature per region, with `code`, `name` and `name_ja`, and a polygon or
|
||||||
|
/// several.
|
||||||
|
///
|
||||||
|
/// Throws a [FormatException] for anything else.
|
||||||
|
GeoMap parseGeoMap(String id, String geojson) {
|
||||||
|
final json = jsonDecode(geojson);
|
||||||
|
if (json case {'features': final List<Object?> features}) {
|
||||||
|
return GeoMap(
|
||||||
|
id: id,
|
||||||
|
regions: [for (final feature in features) _region(feature)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const FormatException('Not a map.');
|
||||||
|
}
|
||||||
|
|
||||||
|
MapRegion _region(Object? feature) {
|
||||||
|
if (feature case {
|
||||||
|
'properties': {'code': final String code, 'name': final String name},
|
||||||
|
'geometry': {
|
||||||
|
'type': final String type,
|
||||||
|
'coordinates': final List<Object?> coordinates,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
final localName = switch (feature) {
|
||||||
|
{'properties': {'name_ja': final String localName}} => localName,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return MapRegion(
|
||||||
|
code: code,
|
||||||
|
name: name,
|
||||||
|
localName: localName,
|
||||||
|
polygons: switch (type) {
|
||||||
|
'Polygon' => [_polygon(coordinates)],
|
||||||
|
'MultiPolygon' => [
|
||||||
|
for (final polygon in coordinates) _polygon(polygon),
|
||||||
|
],
|
||||||
|
_ => throw FormatException('A region of the unknown kind $type.'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const FormatException('Not a region.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Polygon _polygon(Object? json) => switch (json) {
|
||||||
|
final List<Object?> rings => [for (final ring in rings) _ring(ring)],
|
||||||
|
_ => throw const FormatException('Not a polygon.'),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ring _ring(Object? json) => switch (json) {
|
||||||
|
final List<Object?> points => [
|
||||||
|
for (final point in points)
|
||||||
|
switch (point) {
|
||||||
|
[final num lon, final num lat] => (lon.toDouble(), lat.toDouble()),
|
||||||
|
_ => throw const FormatException('Not a point.'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_ => throw const FormatException('Not a ring.'),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_parser.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
|
||||||
|
/// The maps the app bundles, by the id cards name them with: their asset.
|
||||||
|
const bundledMaps = {'japan': 'assets/maps/japan_prefectures.geojson'};
|
||||||
|
|
||||||
|
/// The map a new map question is drawn on.
|
||||||
|
const defaultMapId = 'japan';
|
||||||
|
|
||||||
|
/// The bundled maps, each read from the app's assets once; from [_bundle],
|
||||||
|
/// the app's own unless a test gives one.
|
||||||
|
class GeoMapRepository([final AssetBundle? _bundle]) {
|
||||||
|
// The maps themselves, not the futures that read them: a completed future
|
||||||
|
// kept for later would complete what waits for it in the zone it was made
|
||||||
|
// in, which a widget test's fake clock never reaches (AGENTS.md, Testing).
|
||||||
|
final _maps = <String, GeoMap>{};
|
||||||
|
|
||||||
|
/// The map [id]; null for one this app doesn't bundle, e.g. one a newer
|
||||||
|
/// version added.
|
||||||
|
Future<GeoMap?> load(String id) async {
|
||||||
|
final asset = bundledMaps[id];
|
||||||
|
if (asset == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _maps[id] ??= parseGeoMap(
|
||||||
|
id,
|
||||||
|
await (_bundle ?? rootBundle).loadString(asset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// A point on a map: degrees of longitude and latitude.
|
||||||
|
typedef GeoPoint = (double lon, double lat);
|
||||||
|
|
||||||
|
/// A closed ring of points, the first repeated last.
|
||||||
|
typedef Ring = List<GeoPoint>;
|
||||||
|
|
||||||
|
/// One area of a polygon: its outline first, then its holes.
|
||||||
|
typedef Polygon = List<Ring>;
|
||||||
|
|
||||||
|
/// A region of a map, e.g. a prefecture: its code, its names and its
|
||||||
|
/// outline in one or more polygons (islands).
|
||||||
|
final class const MapRegion({
|
||||||
|
/// Its ISO 3166-2 code, e.g. `JP-13`.
|
||||||
|
required final String code,
|
||||||
|
|
||||||
|
/// Its English name.
|
||||||
|
required final String name,
|
||||||
|
|
||||||
|
/// Its name in the map's own language, if the map has one.
|
||||||
|
required final String? localName,
|
||||||
|
|
||||||
|
/// Its areas.
|
||||||
|
required final List<Polygon> polygons,
|
||||||
|
}) {
|
||||||
|
/// Whether [point] lies inside the region: in an outline and in none of
|
||||||
|
/// its holes.
|
||||||
|
bool contains(GeoPoint point) => polygons.any(
|
||||||
|
(polygon) =>
|
||||||
|
_inRing(polygon.first, point) &&
|
||||||
|
!polygon.skip(1).any((hole) => _inRing(hole, point)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A map of regions, e.g. Japan's prefectures, as the app bundles it.
|
||||||
|
final class const GeoMap({
|
||||||
|
/// Its id, as cards name it, e.g. `japan`.
|
||||||
|
required final String id,
|
||||||
|
|
||||||
|
/// Its regions, by code.
|
||||||
|
required final List<MapRegion> regions,
|
||||||
|
}) {
|
||||||
|
/// The region [code], or null.
|
||||||
|
MapRegion? region(String? code) =>
|
||||||
|
regions.where((region) => region.code == code).firstOrNull;
|
||||||
|
|
||||||
|
/// The region at [point], or null for the sea between them.
|
||||||
|
MapRegion? regionAt(GeoPoint point) =>
|
||||||
|
regions.where((region) => region.contains(point)).firstOrNull;
|
||||||
|
|
||||||
|
/// The smallest box holding every region: west, south, east, north.
|
||||||
|
(double, double, double, double) get bounds {
|
||||||
|
var (west, south, east, north) = (180.0, 90.0, -180.0, -90.0);
|
||||||
|
for (final region in regions) {
|
||||||
|
for (final polygon in region.polygons) {
|
||||||
|
for (final (lon, lat) in polygon.first) {
|
||||||
|
west = math.min(west, lon);
|
||||||
|
east = math.max(east, lon);
|
||||||
|
south = math.min(south, lat);
|
||||||
|
north = math.max(north, lat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (west, south, east, north);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [point] lies inside [ring], by the even-odd rule.
|
||||||
|
bool _inRing(Ring ring, GeoPoint point) {
|
||||||
|
final (x, y) = point;
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
final (xi, yi) = ring[i];
|
||||||
|
final (xj, yj) = ring[j];
|
||||||
|
if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/bloc/geo_map_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Shows what [builder] makes of the bundled map [mapId] once it is read: a
|
||||||
|
/// spinner until then, and a note for a map this app version doesn't have.
|
||||||
|
class const MapOf({
|
||||||
|
/// The map's id.
|
||||||
|
required final String mapId,
|
||||||
|
|
||||||
|
/// What shows the map.
|
||||||
|
required final Widget Function(BuildContext context, GeoMap map) builder,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => BlocProvider(
|
||||||
|
key: ValueKey(mapId),
|
||||||
|
create: (context) =>
|
||||||
|
GeoMapBloc(DependenciesScope.of(context).maps, mapId)
|
||||||
|
..add(const GeoMapEventStarted()),
|
||||||
|
child: BlocBuilder<GeoMapBloc, GeoMapState>(
|
||||||
|
builder: (context, state) => switch (state) {
|
||||||
|
GeoMapStateLoading() => const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: AppLoadingIndicator()),
|
||||||
|
),
|
||||||
|
GeoMapStateUnknown() => Text(AppLocalizations.of(context).mapUnknown),
|
||||||
|
GeoMapStateLoaded(:final map) => builder(context, map),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// [map] drawn to fit its box, longitude and latitude to scale at the map's
|
||||||
|
/// middle; tapping a region reports its code. Pinching or scrolling zooms,
|
||||||
|
/// for regions too small to tap otherwise.
|
||||||
|
///
|
||||||
|
/// The [selected] region is filled in the primary colour; once checked,
|
||||||
|
/// the [right] region green and a [wrong] one red.
|
||||||
|
class const RegionMap({
|
||||||
|
/// The map.
|
||||||
|
required final GeoMap map,
|
||||||
|
|
||||||
|
/// Called with the code of the region tapped; null makes it read-only.
|
||||||
|
final ValueChanged<String>? onRegionTapped,
|
||||||
|
|
||||||
|
/// The region picked.
|
||||||
|
final String? selected,
|
||||||
|
|
||||||
|
/// The region that was right to pick, once checked.
|
||||||
|
final String? right,
|
||||||
|
|
||||||
|
/// The region picked wrongly, once checked.
|
||||||
|
final String? wrong,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: _Projection(map, const Size(1, 1)).aspectRatio,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
maxScale: 8,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final projection = _Projection(map, constraints.biggest);
|
||||||
|
return GestureDetector(
|
||||||
|
onTapUp: onRegionTapped == null
|
||||||
|
? null
|
||||||
|
: (details) {
|
||||||
|
final region = map.regionAt(
|
||||||
|
projection.toGeo(details.localPosition),
|
||||||
|
);
|
||||||
|
if (region != null) {
|
||||||
|
onRegionTapped!(region.code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: CustomPaint(
|
||||||
|
size: constraints.biggest,
|
||||||
|
painter: _MapPainter(
|
||||||
|
map: map,
|
||||||
|
projection: projection,
|
||||||
|
land: colors.surface,
|
||||||
|
border: colors.outline,
|
||||||
|
fills: {
|
||||||
|
?selected: colors.primary,
|
||||||
|
?wrong: colors.error,
|
||||||
|
?right: theme.extras.success,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longitude and latitude to a box and back: equirectangular, longitude
|
||||||
|
/// scaled by the cosine of the map's middle latitude so shapes keep their
|
||||||
|
/// proportions there, with a margin.
|
||||||
|
class _Projection(final GeoMap map, final Size size) {
|
||||||
|
this {
|
||||||
|
final (west, south, east, north) = map.bounds;
|
||||||
|
_west = west;
|
||||||
|
_north = north;
|
||||||
|
_xScale = math.cos((south + north) / 2 * math.pi / 180);
|
||||||
|
final width = (east - west) * _xScale;
|
||||||
|
final height = north - south;
|
||||||
|
aspectRatio = width / height;
|
||||||
|
_scale = math.min(
|
||||||
|
(size.width - 2 * _margin) / width,
|
||||||
|
(size.height - 2 * _margin) / height,
|
||||||
|
);
|
||||||
|
_offset = Offset(
|
||||||
|
(size.width - width * _scale) / 2,
|
||||||
|
(size.height - height * _scale) / 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _margin = 8.0;
|
||||||
|
|
||||||
|
late final double aspectRatio;
|
||||||
|
late final double _west;
|
||||||
|
late final double _north;
|
||||||
|
late final double _xScale;
|
||||||
|
late final double _scale;
|
||||||
|
late final Offset _offset;
|
||||||
|
|
||||||
|
Offset toBox(GeoPoint point) {
|
||||||
|
final (lon, lat) = point;
|
||||||
|
return _offset +
|
||||||
|
Offset((lon - _west) * _xScale * _scale, (_north - lat) * _scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeoPoint toGeo(Offset offset) {
|
||||||
|
final local = offset - _offset;
|
||||||
|
return (
|
||||||
|
local.dx / _scale / _xScale + _west,
|
||||||
|
_north - local.dy / _scale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _MapPainter({
|
||||||
|
required final GeoMap map,
|
||||||
|
required final _Projection projection,
|
||||||
|
required final Color land,
|
||||||
|
required final Color border,
|
||||||
|
required final Map<String, Color> fills,
|
||||||
|
}) extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final stroke = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 0.6
|
||||||
|
..color = border;
|
||||||
|
for (final region in map.regions) {
|
||||||
|
final path = Path()..fillType = PathFillType.evenOdd;
|
||||||
|
for (final polygon in region.polygons) {
|
||||||
|
for (final ring in polygon) {
|
||||||
|
path.addPolygon([
|
||||||
|
for (final point in ring) projection.toBox(point),
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas
|
||||||
|
..drawPath(path, Paint()..color = fills[region.code] ?? land)
|
||||||
|
..drawPath(path, stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_MapPainter oldDelegate) =>
|
||||||
|
oldDelegate.map != map ||
|
||||||
|
oldDelegate.projection.size != projection.size ||
|
||||||
|
oldDelegate.land != land ||
|
||||||
|
oldDelegate.border != border ||
|
||||||
|
!_sameFills(oldDelegate.fills, fills);
|
||||||
|
|
||||||
|
static bool _sameFills(Map<String, Color> a, Map<String, Color> b) =>
|
||||||
|
a.length == b.length && a.entries.every((e) => b[e.key] == e.value);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card.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/geo_map/widget/map_of.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
// Material's Card widget would clash with the domain's Card.
|
// Material's Card widget would clash with the domain's Card.
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
@ -401,3 +403,89 @@ class const _Item({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A question on a map: the question, the map to tap a region on, and
|
||||||
|
/// Check; once checked, the right region green and a wrong pick red, with
|
||||||
|
/// their names.
|
||||||
|
class const MapBody({
|
||||||
|
required final Card card,
|
||||||
|
required final bool checked,
|
||||||
|
required final VoidCallback onCheck,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<MapBody> createState() => _MapBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapBodyState() extends State<MapBody> {
|
||||||
|
String? _picked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final content = widget.card.content;
|
||||||
|
final right = content.answers
|
||||||
|
.where((answer) => answer.correct && answer.region != null)
|
||||||
|
.firstOrNull
|
||||||
|
?.region;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
RichContentView(content: content.front),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MapOf(
|
||||||
|
mapId: content.map!,
|
||||||
|
builder: (context, map) {
|
||||||
|
String nameOf(String? code) => switch (map.region(code)) {
|
||||||
|
final region? =>
|
||||||
|
region.localName == null
|
||||||
|
? region.name
|
||||||
|
: '${region.name} · ${region.localName}',
|
||||||
|
null => l10n.mapNonePicked,
|
||||||
|
};
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
RegionMap(
|
||||||
|
map: map,
|
||||||
|
selected: widget.checked ? null : _picked,
|
||||||
|
right: widget.checked ? right : null,
|
||||||
|
wrong: widget.checked && _picked != right ? _picked : null,
|
||||||
|
onRegionTapped: widget.checked
|
||||||
|
? null
|
||||||
|
: (code) => setState(() => _picked = code),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (widget.checked) ...[
|
||||||
|
Text(
|
||||||
|
nameOf(right),
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.extras.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_picked != right)
|
||||||
|
Text(
|
||||||
|
nameOf(_picked),
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
Text(nameOf(_picked), style: theme.textTheme.titleSmall),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!widget.checked) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _picked == null ? null : widget.onCheck,
|
||||||
|
child: Text(l10n.studyCheck),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,13 +135,18 @@ class const _Showing(final StudySessionStateShowing state)
|
||||||
final key = ValueKey((card.id, state.shownAt));
|
final key = ValueKey((card.id, state.shownAt));
|
||||||
void check() => _send(context, const StudySessionEventAnswerShown());
|
void check() => _send(context, const StudySessionEventAnswerShown());
|
||||||
final body = switch (card.content.type) {
|
final body = switch (card.content.type) {
|
||||||
CardType.multipleChoice when card.content.map == null =>
|
CardType.multipleChoice when card.content.map != null => MapBody(
|
||||||
MultipleChoiceBody(
|
key: key,
|
||||||
key: key,
|
card: card,
|
||||||
card: card,
|
checked: state.answerShown,
|
||||||
checked: state.answerShown,
|
onCheck: check,
|
||||||
onCheck: check,
|
),
|
||||||
),
|
CardType.multipleChoice => MultipleChoiceBody(
|
||||||
|
key: key,
|
||||||
|
card: card,
|
||||||
|
checked: state.answerShown,
|
||||||
|
onCheck: check,
|
||||||
|
),
|
||||||
CardType.matching => MatchingBody(
|
CardType.matching => MatchingBody(
|
||||||
key: key,
|
key: key,
|
||||||
card: card,
|
card: card,
|
||||||
|
|
@ -154,23 +159,15 @@ class const _Showing(final StudySessionStateShowing state)
|
||||||
asksBack: state.asksBack,
|
asksBack: state.asksBack,
|
||||||
answerShown: state.answerShown,
|
answerShown: state.answerShown,
|
||||||
),
|
),
|
||||||
// A question on a map shows its answer like a back, until maps come.
|
CardType.frontBack => FrontBackBody(
|
||||||
CardType.frontBack || CardType.multipleChoice => FrontBackBody(
|
|
||||||
key: key,
|
key: key,
|
||||||
card: card.content.type == CardType.multipleChoice
|
card: card,
|
||||||
? card.withContent(
|
|
||||||
card.content.copyWith(
|
|
||||||
back: card.content.answers.firstOrNull?.content,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: card,
|
|
||||||
asksBack: state.asksBack,
|
asksBack: state.asksBack,
|
||||||
answerShown: state.answerShown,
|
answerShown: state.answerShown,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
final revealsByButton = switch (card.content.type) {
|
final revealsByButton = switch (card.content.type) {
|
||||||
CardType.multipleChoice => card.content.map != null,
|
CardType.multipleChoice || CardType.matching => false,
|
||||||
CardType.matching => false,
|
|
||||||
CardType.frontBack || CardType.vocabulary => true,
|
CardType.frontBack || CardType.vocabulary => true,
|
||||||
};
|
};
|
||||||
return Center(
|
return Center(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ flutter:
|
||||||
- assets/config.json
|
- assets/config.json
|
||||||
# The kingfisher: the app icon, also shown atop the navigation rail.
|
# The kingfisher: the app icon, also shown atop the navigation rail.
|
||||||
- assets/icon/halkyon_icon.png
|
- assets/icon/halkyon_icon.png
|
||||||
|
# Region maps for map questions (see assets/maps/ATTRIBUTION.md).
|
||||||
|
- assets/maps/japan_prefectures.geojson
|
||||||
|
|
||||||
# To add custom fonts to your application, add a fonts section here,
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
# in this "flutter" section. Each entry in this list should have a
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
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/geo_map/data/geo_map_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.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/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';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
import '../../../support/loaded_maps.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
import '../../../support/test_dependencies.dart';
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
|
@ -50,7 +53,10 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the studyset `japanese`, with the cards the test gave [cards].
|
/// Opens the studyset `japanese`, with the cards the test gave [cards].
|
||||||
Future<void> showStudyset(WidgetTester tester) async {
|
Future<void> showStudyset(
|
||||||
|
WidgetTester tester, {
|
||||||
|
GeoMapRepository? maps,
|
||||||
|
}) async {
|
||||||
tester.view.physicalSize = const Size(1400, 1600);
|
tester.view.physicalSize = const Size(1400, 1600);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
@ -63,7 +69,11 @@ void main() {
|
||||||
);
|
);
|
||||||
await pumpApp(
|
await pumpApp(
|
||||||
tester,
|
tester,
|
||||||
testDependencies(studysetGateway: studysets, cardGateway: gateway),
|
testDependencies(
|
||||||
|
studysetGateway: studysets,
|
||||||
|
cardGateway: gateway,
|
||||||
|
maps: maps,
|
||||||
|
),
|
||||||
location: RoutePaths.studyset('japanese'),
|
location: RoutePaths.studyset('japanese'),
|
||||||
);
|
);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
@ -221,4 +231,28 @@ void main() {
|
||||||
|
|
||||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a map question takes the region tapped as its answer', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
||||||
|
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||||
|
await open(tester, 'Where is Tokyo?');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Map'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('No region picked yet'), findsOneWidget);
|
||||||
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(BackButton));
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||||
|
expect(saved.type, CardType.multipleChoice);
|
||||||
|
expect(saved.map, 'japan');
|
||||||
|
expect(saved.answers.single.region, 'JP-13');
|
||||||
|
expect(saved.answers.single.content.plainText, 'Tokyo');
|
||||||
|
expect(saved.back, RichContent.empty());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue