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:
Tizian 「ティツィアーン」 2026-10-01 19:56:29 +02:00
commit 5a5b2feead
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
26 changed files with 1033 additions and 42 deletions

View file

@ -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.

View 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

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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

View file

@ -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),

View file

@ -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;

View file

@ -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,

View file

@ -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];
}

View file

@ -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.'),
};

View file

@ -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),
);
}
}

View file

@ -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;
}

View file

@ -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),
},
),
);
}

View file

@ -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);
}

View file

@ -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),
),
],
],
);
}
}

View file

@ -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(

View file

@ -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

View file

@ -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