feat(map): one map, Japan's prefectures within Japan

A map question is picked on one map: the world's countries in layers
(Europe, East Asia, ...), and within Japan its prefectures, in layers too
(Kantō, Kansai, ...); Back goes up one. The composer's choice between two
maps is gone. A card still names the map its answer is a region of
(`japan`, `world`), as versions up to v0.11.0 did, so they still show it.

In the composer a tap on Japan picks the country, and the "Prefectures"
chip opens its prefectures. Studying, the card decides: a prefecture's
question opens Japan at a tap, a country's picks it.

The map opens on the deepest layer the map questions share (studying, the
round's; writing, the studyset's): Japan for prefectures all over it,
Kantō for Tokyo and Chiba, Europe for European countries.

A tap no longer zooms in when it could mean several regions: it picks the
ring it is on (the nearest of several), else the region under it, else the
nearest ring or outline within reach. Zooming is pinching, or Ctrl (⌘ on a
Mac) and the wheel, as maps on web pages do; the wheel alone scrolls the
page, and a note over the map says how to zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 12:19:03 +02:00
commit 1df60c8830
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
25 changed files with 975 additions and 391 deletions

View file

@ -923,13 +923,13 @@
"studysetResetDone": "Fortschritt zurückgesetzt",
"mapUnknown": "Diese Landkarte braucht eine neuere Version der App.",
"mapPickRegion": "Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; zoome mit zwei Fingern oder durch Scrollen.",
"mapPickRegion": "Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; wo Ringe dicht liegen, zoome hinein.",
"mapNonePicked": "Noch keine Region gewählt",
"composerLabelMap": "Karte",
"mapChoiceJapan": "Japans Präfekturen",
"mapChoiceWorld": "Länder der Welt",
"mapNameJapan": "Japan",
"mapNameWorld": "Welt",
"mapPrefectures": "Präfekturen",
"mapZoomNote": "Strg + Scrollen zum Zoomen",
"mapZoomNoteMac": "⌘ + Scrollen zum Zoomen",
"mapGroupHokkaido": "Hokkaidō",
"mapGroupTohoku": "Tōhoku",
"mapGroupKanto": "Kantō",

View file

@ -1028,13 +1028,13 @@
"studysetResetDone": "Progress reset",
"mapUnknown": "This map needs a newer version of the app.",
"mapPickRegion": "Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.",
"mapPickRegion": "Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; where rings crowd, zoom in.",
"mapNonePicked": "No region picked yet",
"composerLabelMap": "Map",
"mapChoiceJapan": "Japan's prefectures",
"mapChoiceWorld": "Countries of the world",
"mapNameJapan": "Japan",
"mapNameWorld": "World",
"mapPrefectures": "Prefectures",
"mapZoomNote": "Ctrl + scroll to zoom",
"mapZoomNoteMac": "⌘ + scroll to zoom",
"mapGroupHokkaido": "Hokkaidō",
"mapGroupTohoku": "Tōhoku",
"mapGroupKanto": "Kantō",

View file

@ -5449,7 +5449,7 @@ abstract class AppLocalizations {
/// No description provided for @mapPickRegion.
///
/// In en, this message translates to:
/// **'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.'**
/// **'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; where rings crowd, zoom in.'**
String get mapPickRegion;
/// No description provided for @mapNonePicked.
@ -5458,24 +5458,6 @@ abstract class AppLocalizations {
/// **'No region picked yet'**
String get mapNonePicked;
/// No description provided for @composerLabelMap.
///
/// In en, this message translates to:
/// **'Map'**
String get composerLabelMap;
/// No description provided for @mapChoiceJapan.
///
/// In en, this message translates to:
/// **'Japan\'s prefectures'**
String get mapChoiceJapan;
/// No description provided for @mapChoiceWorld.
///
/// In en, this message translates to:
/// **'Countries of the world'**
String get mapChoiceWorld;
/// No description provided for @mapNameJapan.
///
/// In en, this message translates to:
@ -5488,6 +5470,24 @@ abstract class AppLocalizations {
/// **'World'**
String get mapNameWorld;
/// No description provided for @mapPrefectures.
///
/// In en, this message translates to:
/// **'Prefectures'**
String get mapPrefectures;
/// No description provided for @mapZoomNote.
///
/// In en, this message translates to:
/// **'Ctrl + scroll to zoom'**
String get mapZoomNote;
/// No description provided for @mapZoomNoteMac.
///
/// In en, this message translates to:
/// **'⌘ + scroll to zoom'**
String get mapZoomNoteMac;
/// No description provided for @mapGroupHokkaido.
///
/// In en, this message translates to:

View file

@ -3414,26 +3414,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get mapPickRegion =>
'Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; zoome mit zwei Fingern oder durch Scrollen.';
'Wähle einen Teil der Karte, dann tippe auf die Region, die die Frage beantwortet. Winzige Orte haben einen Ring; wo Ringe dicht liegen, zoome hinein.';
@override
String get mapNonePicked => 'Noch keine Region gewählt';
@override
String get composerLabelMap => 'Karte';
@override
String get mapChoiceJapan => 'Japans Präfekturen';
@override
String get mapChoiceWorld => 'Länder der Welt';
@override
String get mapNameJapan => 'Japan';
@override
String get mapNameWorld => 'Welt';
@override
String get mapPrefectures => 'Präfekturen';
@override
String get mapZoomNote => 'Strg + Scrollen zum Zoomen';
@override
String get mapZoomNoteMac => '⌘ + Scrollen zum Zoomen';
@override
String get mapGroupHokkaido => 'Hokkaidō';

View file

@ -3388,26 +3388,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get mapPickRegion =>
'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; pinch or scroll to zoom.';
'Choose a part of the map, then tap the region that answers the question. Tiny places have a ring; where rings crowd, zoom in.';
@override
String get mapNonePicked => 'No region picked yet';
@override
String get composerLabelMap => 'Map';
@override
String get mapChoiceJapan => 'Japan\'s prefectures';
@override
String get mapChoiceWorld => 'Countries of the world';
@override
String get mapNameJapan => 'Japan';
@override
String get mapNameWorld => 'World';
@override
String get mapPrefectures => 'Prefectures';
@override
String get mapZoomNote => 'Ctrl + scroll to zoom';
@override
String get mapZoomNoteMac => '⌘ + scroll to zoom';
@override
String get mapGroupHokkaido => 'Hokkaidō';

View file

@ -620,6 +620,11 @@ final class const CardComposerState({
/// Every card, as the stack shows them.
List<CardDraft> get items => [...drafts, ...existing];
/// The regions the map questions point at, which a map opens around.
Set<String> get mapRegions => {
for (final draft in items) ?draft.content.mapRegion,
};
/// The draft [id], or null.
CardDraft? byId(String? id) =>
items.where((draft) => draft.id == id).firstOrNull;

View file

@ -225,7 +225,8 @@ final class const CardContent({
/// The targets and items of a matching card.
final Matching matching = const Matching(),
/// The id of the map a multiple-choice card is drawn on.
/// The id of the map a multiple-choice card is drawn on: the one its
/// answer is a region of.
final String? map,
/// The page of a PDF the card was made from; kept whatever its type.
@ -469,6 +470,14 @@ final class const CardContent({
].every((each) => each.trim().isEmpty),
};
/// The region a map question points at; null for any other card.
String? get mapRegion => map == null
? null
: answers
.where((answer) => answer.correct && answer.region != null)
.firstOrNull
?.region;
/// Whether the card can be studied: everything its type asks for is there.
bool get isComplete => switch (type) {
CardType.frontBack => !_blank(front) && !_blank(back),

View file

@ -272,6 +272,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
draft: selected,
pins: state.pins,
tagging: state.tagging,
mapRegions: state.mapRegions,
focusFront: _focusFront,
onDuplicate: () => _duplicate(selected),
onDelete: () => _delete(selected),

View file

@ -72,7 +72,7 @@ enum CardKind() {
// Only an answer naming a region is one on a map.
map => content.copyWith(
type: CardType.multipleChoice,
map: content.map ?? defaultMapId,
map: content.map ?? worldMapId,
answers: [
for (final answer in content.answers)
if (answer.region != null) answer,
@ -111,6 +111,10 @@ class const CardDraftEditor({
/// The person's tags.
required final Tagging tagging,
/// The regions the studyset's map questions point at, which a map opens
/// around.
required final Set<String> mapRegions,
/// Whether the front opens as an editor, for a card just added.
required final bool focusFront,
@ -490,8 +494,10 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
),
CardKind.map => _MapAnswer(
content: content,
onPicked: (region) => _change(
around: widget.mapRegions,
onPicked: (map, region) => _change(
_sent.copyWith(
map: map.id,
answers: [
CardAnswer(
content: RichContent.plain(
@ -503,9 +509,6 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
],
),
),
// A region of one map is none of another's.
onMapChosen: (map) =>
_change(_sent.copyWith(map: map, answers: [])),
),
},
if (content.explanation case final explanation?) ...[
@ -570,41 +573,23 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
}
}
/// A map question's answer: the map, Japan's prefectures or the world's
/// countries, and the region to point at, picked on it.
/// A map question's answer: the region to point at, picked on the maps
/// as one, from the layer the studyset's other map questions share.
class const _MapAnswer({
required final CardContent content,
required final ValueChanged<MapRegion> onPicked,
required final ValueChanged<String> onMapChosen,
required final Set<String> around,
/// Called with the region picked and the map it is on.
required final void Function(GeoMap map, MapRegion region) onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final picked = content.answers.firstOrNull?.region;
final mapId = content.map ?? defaultMapId;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.composerLabelMap, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: [
for (final id in bundledMaps.keys)
ButtonSegment(
value: id,
label: Text(switch (id) {
'japan' => l10n.mapChoiceJapan,
'world' => l10n.mapChoiceWorld,
_ => id,
}),
),
],
selected: {mapId},
showSelectedIcon: false,
onSelectionChanged: (chosen) => onMapChosen(chosen.single),
),
const SizedBox(height: 16),
Text(l10n.composerLabelRegion, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
Text(
@ -615,21 +600,22 @@ class const _MapAnswer({
),
const SizedBox(height: 8),
MapOf(
mapId: mapId,
builder: (context, map) => Column(
builder: (context, atlas) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RegionMap(
map: map,
atlas: atlas,
selected: picked,
onRegionTapped: (code) => onPicked(map.region(code)!),
around: around,
onRegionTapped: (code) =>
onPicked(atlas.mapOf(code)!, atlas.region(code)!),
),
const SizedBox(height: 8),
Text(
switch (map.region(picked)) {
switch (atlas.region(picked)) {
final region? => switch (regionOtherName(
context,
map,
atlas.mapOf(picked)!,
region,
)) {
final other? => '${regionName(context, region)} · $other',

View file

@ -1,17 +1,19 @@
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';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/atlas.dart';
/// Loads the bundled map [_id] for what shows it.
/// Loads the bundled maps for what shows them, a card's map [_id] among
/// them: unknown when this app doesn't bundle 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(),
});
if (!bundledMaps.containsKey(_id)) {
emit(const GeoMapStateUnknown());
return;
}
emit(GeoMapStateLoaded(await _maps.atlas()));
});
}
}
@ -31,17 +33,17 @@ sealed class const GeoMapState() with Equatable {
List<Object?> get props => const [];
}
/// Reading the map.
/// Reading the maps.
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.
/// The maps.
final class const GeoMapStateLoaded(
/// The map.
final GeoMap map,
/// The bundled maps, as one.
final Atlas atlas,
) extends GeoMapState {
@override
List<Object?> get props => [map.id];
List<Object?> get props => [atlas];
}

View file

@ -2,29 +2,39 @@ import 'package:flutter/foundation.dart';
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/data/map_groups.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/atlas.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
/// A map the app bundles: its asset, the groups it is picked from first,
/// and its own language, if any.
typedef BundledMap = ({String asset, List<MapGroup> groups, String? language});
/// its own language, if any, and the country it lies within, if any.
typedef BundledMap = ({
String asset,
List<MapGroup> groups,
String? language,
String? country,
});
/// The maps the app bundles, by the id cards name them with, in the order a
/// new map question offers them.
/// The maps the app bundles, by the id cards name them with: the world's
/// countries, and Japan's prefectures within Japan. A card names the map
/// its answer is a region of, as versions up to v0.11.0 did, which showed
/// each map apart, so they still find it.
const bundledMaps = <String, BundledMap>{
worldMapId: (
asset: 'assets/maps/world_countries.geojson',
groups: worldGroups,
language: null,
country: null,
),
'japan': (
asset: 'assets/maps/japan_prefectures.geojson',
groups: japanGroups,
language: 'ja',
),
'world': (
asset: 'assets/maps/world_countries.geojson',
groups: worldGroups,
language: null,
country: 'JP',
),
};
/// The map a new map question is drawn on.
const defaultMapId = 'japan';
/// The world's countries, the map every other lies within.
const worldMapId = 'world';
/// The bundled maps, each read from the app's assets once; from [_bundle],
/// the app's own unless a test gives one.
@ -33,6 +43,20 @@ class GeoMapRepository([final AssetBundle? _bundle]) {
// 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>{};
Atlas? _atlas;
/// Every bundled map, as one.
Future<Atlas> atlas() async {
if (_atlas case final atlas?) {
return atlas;
}
final world = (await load(worldMapId))!;
final inside = <String, GeoMap>{
for (final MapEntry(key: id, value: bundled) in bundledMaps.entries)
?bundled.country: (await load(id))!,
};
return _atlas ??= Atlas(world: world, inside: inside);
}
/// The map [id]; null for one this app doesn't bundle, e.g. one a newer
/// version added.

View file

@ -170,3 +170,10 @@ String mapName(AppLocalizations l10n, String id) => switch (id) {
'world' => l10n.mapNameWorld,
_ => id,
};
/// What the map [id] within a country holds, in the app's language: the
/// chip opening it from the country's group.
String mapInsideName(AppLocalizations l10n, String id) => switch (id) {
'japan' => l10n.mapPrefectures,
_ => mapName(l10n, id),
};

View file

@ -0,0 +1,90 @@
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
/// Where a map looks: the map whole, or one of its groups.
typedef MapLevel = ({GeoMap map, MapGroup? group});
/// The bundled maps as one: the world's countries, and within a country a
/// map of its own ([inside]: Japan's prefectures within Japan), which one
/// opens from the country. A region is found by its code on whichever
/// map holds it.
final class const Atlas({
/// The world's countries.
required final GeoMap world,
/// The maps within countries, by the country's code: `JP` Japan's
/// prefectures.
final Map<String, GeoMap> inside = const {},
}) {
/// The map [code] is a region of, or null.
GeoMap? mapOf(String? code) => [
world,
...inside.values,
].where((map) => map.region(code) != null).firstOrNull;
/// The region [code], on whichever map holds it, or null.
MapRegion? region(String? code) => mapOf(code)?.region(code);
/// The code of the country [map] lies within; null for the world.
String? countryOf(GeoMap map) =>
inside.entries.where((entry) => entry.value == map).firstOrNull?.key;
/// The levels showing [code] where it can be picked, from the whole world
/// down: the world, its groups the region is in, and for a region within
/// a country that country's map whole and its groups the region is in.
/// A group of one region isn't one: it is picked from the level above.
List<MapLevel> levelsOf(String code) {
final map = mapOf(code);
if (map == null) {
return [(map: world, group: null)];
}
final groups = [
for (final group in map.groupsOf(code))
if (group.members.length > 1) (map: map, group: group),
];
return switch (countryOf(map)) {
null => [(map: map, group: null), ...groups],
final country => [
...levelsOf(country),
(map: map, group: null),
...groups,
],
};
}
/// The levels showing every one of [codes], from the whole world down;
/// the whole world for none.
List<MapLevel> levelsShowing(Iterable<String> codes) => codes.isEmpty
? [(map: world, group: null)]
: codes
.skip(1)
.fold(
levelsOf(codes.first),
(levels, code) => levels
.where((level) => levelsOf(code).contains(level))
.toList(),
);
/// The deepest level showing every one of [codes], which a map opens on:
/// Kantō for Tokyo and Chiba, Japan for Tokyo and Osaka, East Asia for
/// Tokyo and Seoul, the whole world for none. Of two as deep, the first
/// in the map's order: Europe for Russia, rather than East Asia.
MapLevel levelFor(Iterable<String> codes) => levelsShowing(
codes,
).reduce((a, b) => _depthOf(b) > _depthOf(a) ? b : a);
/// The level above [level], which Back returns to: a group's map whole,
/// a country's map the world's group the country is in; null for the
/// whole world.
MapLevel? above(MapLevel level) {
if (level.group != null) {
return (map: level.map, group: null);
}
return switch (countryOf(level.map)) {
null => null,
final country => levelsOf(country).last,
};
}
int _depthOf(MapLevel level) =>
(level.map == world ? 0 : 2) + (level.group == null ? 0 : 1);
}

View file

@ -3,17 +3,20 @@ 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:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/atlas.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.
/// Shows what [builder] makes of the bundled maps once they are read: a
/// spinner until then, and a note when the map [mapId] a card names is one
/// this app version doesn't have.
class const MapOf({
/// The map's id.
required final String mapId,
/// What shows the maps.
required final Widget Function(BuildContext context, Atlas atlas) builder,
/// What shows the map.
required final Widget Function(BuildContext context, GeoMap map) builder,
/// The map a card names; the world's countries, which hold every other,
/// for a question being written.
final String mapId = worldMapId,
super.key,
}) extends StatelessWidget {
@override
@ -29,7 +32,7 @@ class const MapOf({
child: Center(child: AppLoadingIndicator()),
),
GeoMapStateUnknown() => Text(AppLocalizations.of(context).mapUnknown),
GeoMapStateLoaded(:final map) => builder(context, map),
GeoMapStateLoaded(:final atlas) => builder(context, atlas),
},
),
);

View file

@ -1,39 +1,70 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/map_groups.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/atlas.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// [map] for picking a region, in two steps where the map has groups: on
/// the whole map one taps a group (a continent, a part of Japan) or its
/// chip, and the map flies there; there one taps the region, and Back
/// returns to the whole map. A region one can hardly hit on a phone is
/// picked where it is large enough, Cyprus in Europe rather than on the
/// world.
/// What tapping a country with a map of its own (Japan: its prefectures)
/// does.
enum CountryTaps() {
/// Picks the country, and a chip opens its map: while writing a
/// question, which may be about either.
pickOrOpen,
/// Picks the country: a question about the country.
pick,
/// Opens its map: a question about a region within it.
open,
}
/// The bundled maps as one, for picking a region in layers: on the whole
/// world one taps a group (a continent or a part of one) or its chip, and
/// the map flies there; there one taps the country. Japan holds a map of
/// its own, its prefectures, in groups too (Kantō, Kansai …), which opens
/// from Japan as [countryTaps] says. Back goes up a layer. So a region one
/// can hardly hit on a phone is picked where it is large enough: Cyprus in
/// Europe rather than on the world, Tokyo in Kantō.
///
/// Even there some are too small to hit (Liechtenstein, Malta, the
/// Caribbean's islands): they get a ring, which picks them, and within
/// 24 dp of it (half the 48 dp Material makes the least a finger is sure
/// to hit) a tap could mean them or their neighbours, so it zooms in rather
/// than guess, until the tap is plain. Pinching zooms and drags pan.
/// Caribbean's islands): they get a ring, which picks them. A tap picks the
/// ring it is on, the nearest of several; else the region it is in; else,
/// in the sea, the nearest ring within 24 dp (half the 48 dp Material makes
/// the least a finger is sure to hit) or outline within 8 dp. Where rings
/// crowd, one zooms in: pinching, or scrolling with Ctrl (⌘ on a Mac) held,
/// as maps on web pages do, so plain scrolling still scrolls the page;
/// scrolling without it says so. Drags pan.
///
/// The [selected] region is filled in the primary colour; once checked,
/// the [right] region green and a [wrong] one red. The map opens on the
/// group they are in, or on the whole map when they are in different ones.
/// deepest layer showing them and the regions [around] it (the map
/// questions studied: on Japan when they are all prefectures), and once
/// checked flies to a layer showing the right and the wrong region, unless
/// it shows them already.
class const RegionMap({
/// The map.
required final GeoMap map,
/// The maps.
required final Atlas atlas,
/// Called with the code of the region picked; null makes it read-only
/// (one can still look at the groups).
/// (one can still look at the layers).
final ValueChanged<String>? onRegionTapped,
/// What a tap on a country with a map of its own does.
final CountryTaps countryTaps = CountryTaps.pickOrOpen,
/// The regions the map opens around, at the deepest layer showing them
/// all.
final Set<String> around = const {},
/// The region picked.
final String? selected,
@ -48,7 +79,7 @@ class const RegionMap({
State<RegionMap> createState() => RegionMapState();
}
/// The state of a [RegionMap]: the group shown and how far it is zoomed.
/// The state of a [RegionMap]: the layer shown and how far it is zoomed.
class RegionMapState()
extends State<RegionMap>
with SingleTickerProviderStateMixin {
@ -58,16 +89,17 @@ class RegionMapState()
/// How near its outline a tap in the sea still means a region.
static const _slop = 8.0;
/// How much a tap that could mean several regions zooms in.
static const _zoomStep = 3.0;
/// How far a group zooms in at most, from showing it whole.
/// How far a layer zooms in at most, from showing it whole.
static const _maxZoom = 24.0;
late var _geometry = _MapGeometry(widget.map);
/// How long the note that Ctrl zooms shows.
static const _zoomNoteTime = Duration(milliseconds: 1500);
/// The group shown; null for the whole map.
late MapGroup? _group = _groupToShow();
/// The maps' outlines as paths, each made once.
final _geometries = <GeoMap, _MapGeometry>{};
/// The layer shown.
late MapLevel _level = _firstLevel();
/// What is shown, once the box is known, and the box.
_View? _view;
@ -85,56 +117,56 @@ class RegionMapState()
_View? _pinchView;
Offset? _pinchFocus;
/// Whether the note that Ctrl zooms shows, and until when.
var _zoomNote = false;
Timer? _zoomNoteTimer;
@override
void didUpdateWidget(RegionMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.map != widget.map) {
if (oldWidget.atlas != widget.atlas) {
_flight.stop();
_geometry = _MapGeometry(widget.map);
_group = _groupToShow();
_geometries.clear();
_level = _firstLevel();
_size = null;
} else if (oldWidget.right != widget.right ||
oldWidget.wrong != widget.wrong) {
final group = _groupToShow();
if (group != _group) {
_open(group);
final shown = {?widget.right, ?widget.wrong};
if (shown.isNotEmpty &&
!widget.atlas.levelsShowing(shown).contains(_level)) {
_open(widget.atlas.levelFor(shown));
}
}
}
@override
void dispose() {
_zoomNoteTimer?.cancel();
_flight.dispose();
super.dispose();
}
/// The group the regions to see are in: the right one and the one picked
/// wrongly once checked, else the one picked; null when they share none,
/// or a group holds only them.
MapGroup? _groupToShow() {
final focus = widget.right ?? widget.selected;
if (focus == null) {
return null;
}
final groups = widget.map
.groupsOf(focus)
.where(
(group) =>
group.members.length > 1 &&
(widget.wrong == null || group.members.contains(widget.wrong)),
);
return groups.firstOrNull;
}
/// The deepest layer showing what is coloured and what the map is
/// around.
MapLevel _firstLevel() => widget.atlas.levelFor({
...widget.around,
?widget.selected,
?widget.right,
?widget.wrong,
});
_MapGeometry _geometryOf(GeoMap map) =>
_geometries[map] ??= _MapGeometry(map);
/// Whether a tap picks a region here, rather than a group.
bool get _picksRegions => _group != null || widget.map.groups.isEmpty;
bool get _picksRegions => _level.group != null || _level.map.groups.isEmpty;
/// The regions marked with a ring when too small to tap: any in a group,
/// on the whole map only those picked there, and those coloured.
/// on a whole map only those picked there, and those coloured.
Set<String>? get _markable => _picksRegions
? null
: {
for (final region in widget.map.regions)
for (final region in _level.map.regions)
if (_pickedAtOnce(region.code)) region.code,
?widget.selected,
?widget.right,
@ -143,14 +175,16 @@ class RegionMapState()
/// Whether the whole map picks [code] at once: in no group, or alone in
/// its one.
bool _pickedAtOnce(String code) => switch (widget.map.groupsOf(code)) {
bool _pickedAtOnce(String code) => switch (_level.map.groupsOf(code)) {
[] => true,
[final group] => group.members.length == 1,
_ => false,
};
GeoBox _frameOf(MapGroup? group) =>
group == null ? widget.map.bounds : widget.map.frameOf(group);
GeoBox _frameOf(MapLevel level) => switch (level.group) {
final group? => level.map.frameOf(group),
null => level.map.bounds,
};
void _fly() {
setState(
@ -173,21 +207,32 @@ class RegionMapState()
_flight.forward(from: 0);
}
/// Shows [group] whole; the whole map for null.
void _open(MapGroup? group) {
setState(() => _group = group);
/// Shows [level] whole.
void _open(MapLevel level) {
setState(() => _level = level);
if (_size case final size?) {
_goTo(_View.fit(_frameOf(group), size));
_goTo(_View.fit(_frameOf(level), size));
}
}
/// A group chosen: its region at once when it holds only that, else the
/// group.
/// A group chosen: its region at once when it holds only that and can be
/// picked, else the group.
void _choose(MapGroup group) {
if (group.members.length == 1 && widget.onRegionTapped != null) {
widget.onRegionTapped!(group.members.single);
_pick(group.members.single);
} else {
_open(group);
_open((map: _level.map, group: group));
}
}
/// [code] tapped: a country with a map of its own opens it where
/// [RegionMap.countryTaps] says so; anything else is picked.
void _pick(String code) {
if (widget.atlas.inside[code] case final inside?
when widget.countryTaps == CountryTaps.open) {
_open((map: inside, group: null));
} else {
widget.onRegionTapped?.call(code);
}
}
@ -197,7 +242,7 @@ class RegionMapState()
if (view == null || size == null || _flight.isAnimating) {
return;
}
final hits = _geometry.hitsAt(
final hits = _geometryOf(_level.map).hitsAt(
point,
view,
size,
@ -208,90 +253,50 @@ class RegionMapState()
if (hits.isEmpty) {
return;
}
if (_picksRegions) {
_pick(point, hits);
} else {
// The region under the finger, else the nearest, and of its groups
// the one whose middle is nearest the tap: Russia's Siberia opens
// East Asia, Moscow Europe.
final hit =
hits.where((hit) => hit.certain && !hit.marked).firstOrNull ??
_nearest(hits);
final code = hit.region.code;
if (_pickedAtOnce(code)) {
widget.onRegionTapped?.call(code);
return;
}
final at = view.toGeo(point, size);
final groups = widget.map.groupsOf(code)
..sort(
(a, b) => _degreesBetween(
_frameOf(a).center,
at,
).compareTo(_degreesBetween(_frameOf(b).center, at)),
);
_open(groups.first);
}
}
/// Picks the region of [hits] the tap at [point] plainly means: the only
/// one; else the one whose ring it is on, with no other ring near; else,
/// with no ring near, the one it is in. Anything else zooms in, and once
/// that is as far as it goes, picks the nearest.
void _pick(Offset point, List<_Hit> hits) {
final onRegion = widget.onRegionTapped;
if (onRegion == null) {
final code = _meant(hits).region.code;
if (_picksRegions || _pickedAtOnce(code)) {
_pick(code);
return;
}
final marked = hits.where((hit) => hit.marked).toList();
final certain = hits.where((hit) => hit.certain).toList();
final plain = switch ((hits, marked, certain)) {
([final only], _, _) => only,
(_, [final ring], _) when ring.certain => ring,
(_, [], [final inside]) => inside,
_ => null,
};
if (plain != null) {
onRegion(plain.region.code);
} else if (!_zoomIn(point)) {
onRegion(_nearest(hits).region.code);
// Of the region's groups, the one whose middle is nearest the tap:
// Russia's Siberia opens East Asia, Moscow Europe.
final at = view.toGeo(point, size);
final groups = _level.map.groupsOf(code)
..sort(
(a, b) =>
_degreesBetween(
_frameOf((map: _level.map, group: a)).center,
at,
).compareTo(
_degreesBetween(_frameOf((map: _level.map, group: b)).center, at),
),
);
_open((map: _level.map, group: groups.first));
}
/// The region a tap on [hits] means: the ring it is on, the nearest of
/// several, as rings lie above the land; else the region it is in; else
/// the nearest.
static _Hit _meant(List<_Hit> hits) {
final onRings = hits.where((hit) => hit.marked && hit.certain).toList();
if (onRings.isNotEmpty) {
return _nearest(onRings);
}
return hits.where((hit) => hit.certain).firstOrNull ?? _nearest(hits);
}
static _Hit _nearest(List<_Hit> hits) =>
hits.reduce((a, b) => a.distance <= b.distance ? a : b);
/// Zooms in around [point], which stays under the finger; false when it
/// is zoomed in as far as it goes.
bool _zoomIn(Offset point) {
final view = _view!;
final size = _size!;
final (least, most) = _scales(size);
if (view.scale >= most * 0.999) {
return false;
}
final at = view.toGeo(point, size);
_goTo(
_clamp(
view
.withScale(math.min(view.scale * _zoomStep, most))
.placing(at, point, size),
least,
most,
),
);
return true;
}
/// How far the group shown zooms: whole, and as far as it goes.
/// How far the layer shown zooms: whole, and as far as it goes.
(double, double) _scales(Size size) {
final least = _View.fit(_frameOf(_group), size).scale;
final least = _View.fit(_frameOf(_level), size).scale;
return (least, least * _maxZoom);
}
/// [view] within [least] and [most] zoom, its middle within the group.
/// [view] within [least] and [most] zoom, its middle within the layer.
_View _clamp(_View view, double least, double most) {
final (west, south, east, north) = _frameOf(_group);
final (west, south, east, north) = _frameOf(_level);
return _View(
lon: view.lon.clamp(west, east),
lat: view.lat.clamp(south, north),
@ -300,6 +305,22 @@ class RegionMapState()
);
}
/// [view] zoomed by [factor] around [point], which stays where it is.
void _zoomAround(_View view, Offset point, double factor) {
final size = _size!;
final (least, most) = _scales(size);
final at = view.toGeo(point, size);
setState(
() => _view = _clamp(
view
.withScale((view.scale * factor).clamp(least, most))
.placing(at, point, size),
least,
most,
),
);
}
void _pinchStarted(ScaleStartDetails details) {
_flight.stop();
_pinchView = _view;
@ -325,30 +346,38 @@ class RegionMapState()
);
}
/// A mouse's wheel zooms around the pointer, as pinching does.
/// A mouse's wheel with Ctrl (or ⌘) held zooms around the pointer, as
/// pinching does, and the page around doesn't scroll; without, the page
/// scrolls and a note says how to zoom.
void _scrolled(PointerSignalEvent event) {
final view = _view;
final size = _size;
if (event is! PointerScrollEvent || view == null || size == null) {
if (event is! PointerScrollEvent || _view == null || _size == null) {
return;
}
_flight.stop();
final (least, most) = _scales(size);
final at = view.toGeo(event.localPosition, size);
setState(
() => _view = _clamp(
view
.withScale(
(view.scale * math.exp(-event.scrollDelta.dy / 200)).clamp(
least,
most,
),
)
.placing(at, event.localPosition, size),
least,
most,
),
);
final keys = HardwareKeyboard.instance;
if (!keys.isControlPressed && !keys.isMetaPressed) {
_noteZoomKeys();
return;
}
GestureBinding.instance.pointerSignalResolver.register(event, (event) {
_flight.stop();
_zoomAround(
_view!,
event.localPosition,
math.exp(-(event as PointerScrollEvent).scrollDelta.dy / 200),
);
});
}
void _noteZoomKeys() {
_zoomNoteTimer?.cancel();
_zoomNoteTimer = Timer(_zoomNoteTime, () {
if (mounted) {
setState(() => _zoomNote = false);
}
});
if (!_zoomNote) {
setState(() => _zoomNote = true);
}
}
/// Where [point] shows on the screen now, for tests to tap it.
@ -370,6 +399,24 @@ class RegionMapState()
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final atlas = widget.atlas;
final fills = {
?widget.selected: colors.primary,
?widget.wrong: colors.error,
?widget.right: theme.extras.success,
};
final world = _geometryOf(atlas.world);
final shown = _geometryOf(_level.map);
final country = atlas.countryOf(_level.map);
// Around a country's map the world shows, and on any map what is
// coloured on another: Tokyo, right, on East Asia when Seoul was picked.
final drawn = {_level.map, atlas.world};
final regions = [
...shown.regions,
for (final code in fills.keys)
if (atlas.mapOf(code) case final map? when !drawn.contains(map))
..._geometryOf(map).regions.where((shape) => shape.code == code),
];
final canvas = ClipRRect(
key: _canvasKey,
borderRadius: BorderRadius.circular(12),
@ -379,10 +426,10 @@ class RegionMapState()
builder: (context, constraints) {
final size = constraints.biggest;
if (_size != size || _view == null) {
// A new box: the group shown whole in it.
// A new box: the layer shown whole in it.
_flight.stop();
_size = size;
_view = _View.fit(_frameOf(_group), size);
_view = _View.fit(_frameOf(_level), size);
}
return Listener(
onPointerSignal: _picksRegions ? _scrolled : null,
@ -391,20 +438,42 @@ class RegionMapState()
onTapUp: (details) => _tapped(details.localPosition),
onScaleStart: _picksRegions ? _pinchStarted : null,
onScaleUpdate: _picksRegions ? _pinched : null,
child: CustomPaint(
size: size,
painter: _MapPainter(
geometry: _geometry,
view: _view!,
land: colors.surface,
border: colors.outline,
markable: _markable,
fills: {
?widget.selected: colors.primary,
?widget.wrong: colors.error,
?widget.right: theme.extras.success,
},
),
child: Stack(
children: [
CustomPaint(
size: size,
painter: _MapPainter(
view: _view!,
land: [
...world.land,
if (country != null) ...shown.land,
],
context: country == null
? const []
: [
for (final shape in world.regions)
if (shape.code != country) shape,
],
regions: regions,
landColor: colors.surface,
border: colors.outline,
markable: _markable,
fills: fills,
),
),
Positioned(
left: 8,
right: 8,
bottom: 12,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _zoomNote ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: const _ZoomNote(),
),
),
),
],
),
),
);
@ -412,14 +481,20 @@ class RegionMapState()
),
),
);
final header = widget.map.groups.isEmpty
? null
: _Header(
map: widget.map,
group: _group,
onBack: () => _open(null),
onChoose: _choose,
);
final above = atlas.above(_level);
final header = _Header(
level: _level,
above: above,
opens: [
if (widget.countryTaps == CountryTaps.pickOrOpen &&
_level.group != null)
for (final MapEntry(key: country, value: map) in atlas.inside.entries)
if (_level.group!.members.contains(country)) map,
],
onBack: above == null ? null : () => _open(above),
onChoose: _choose,
onOpen: (map) => _open((map: map, group: null)),
);
return LayoutBuilder(
builder: (context, constraints) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -427,7 +502,8 @@ class RegionMapState()
? MainAxisSize.max
: MainAxisSize.min,
children: [
if (header != null) ...[header, const SizedBox(height: 8)],
header,
const SizedBox(height: 8),
if (constraints.hasBoundedHeight)
Expanded(child: canvas)
else
@ -438,13 +514,16 @@ class RegionMapState()
}
}
/// Above the map: on the whole map a chip per group, which opens it; in a
/// group, Back to the whole map and the group's name.
/// Above the map: Back to the layer [above], if any; on a whole map a chip
/// per group, which opens it; in a group its name, and a chip for each map
/// within a country of it that one may [opens] (Japan's prefectures).
class const _Header({
required final GeoMap map,
required final MapGroup? group,
required final VoidCallback onBack,
required final MapLevel level,
required final MapLevel? above,
required final List<GeoMap> opens,
required final VoidCallback? onBack,
required final ValueChanged<MapGroup> onChoose,
required final ValueChanged<GeoMap> onOpen,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -452,35 +531,79 @@ class const _Header({
final theme = Theme.of(context);
return SizedBox(
height: 40,
child: switch (group) {
null => ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: map.groups.length,
separatorBuilder: (context, _) => const SizedBox(width: 8),
itemBuilder: (context, index) => ActionChip(
label: Text(mapGroupName(l10n, map.groups[index].id)),
onPressed: () => onChoose(map.groups[index]),
),
),
final group => Row(
children: [
child: Row(
children: [
if (above != null) ...[
TextButton.icon(
onPressed: onBack,
icon: const Icon(LucideIcons.chevronLeft, size: 18),
label: Text(mapName(l10n, map.id)),
label: Text(_levelName(l10n, above!)),
),
const SizedBox(width: 8),
Expanded(
child: Text(
],
Expanded(
child: switch (level.group) {
null => ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: level.map.groups.length,
separatorBuilder: (context, _) => const SizedBox(width: 8),
itemBuilder: (context, index) => ActionChip(
label: Text(mapGroupName(l10n, level.map.groups[index].id)),
onPressed: () => onChoose(level.map.groups[index]),
),
),
final group => Text(
mapGroupName(l10n, group.id),
style: theme.textTheme.titleSmall,
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
),
},
),
for (final map in opens) ...[
const SizedBox(width: 8),
ActionChip(
label: Text(mapInsideName(l10n, map.id)),
onPressed: () => onOpen(map),
),
],
],
),
);
}
static String _levelName(AppLocalizations l10n, MapLevel level) =>
switch (level.group) {
final group? => mapGroupName(l10n, group.id),
null => mapName(l10n, level.map.id),
};
}
/// That scrolling with Ctrl (⌘ on a Mac) held zooms.
class const _ZoomNote() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.inverseSurface,
borderRadius: BorderRadius.circular(16),
),
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
theme.platform == TargetPlatform.macOS
? l10n.mapZoomNoteMac
: l10n.mapZoomNote,
style: theme.textTheme.labelLarge?.copyWith(
color: colors.onInverseSurface,
),
),
),
),
);
}
}
@ -640,6 +763,8 @@ final class _MapGeometry(final GeoMap map) {
/// A region's polygons as paths, and where it is marked when small.
final class _RegionShape(final MapRegion region) {
String get code => region.code;
late final polygons = [
for (final polygon in region.polygons) _PolygonShape(polygon),
];
@ -750,9 +875,14 @@ final class _PolygonShape(final Polygon polygon) {
}
class const _MapPainter({
required final _MapGeometry geometry,
required final _View view,
required final Color land,
required final List<_PolygonShape> land,
/// Drawn around the regions, never marked: the world around a country's
/// map.
required final List<_RegionShape> context,
required final List<_RegionShape> regions,
required final Color landColor,
required final Color border,
required final Set<String>? markable,
required final Map<String, Color> fills,
@ -771,8 +901,8 @@ class const _MapPainter({
..style = PaintingStyle.stroke
..strokeWidth = 0.6
..color = border;
final landPaint = Paint()..color = land;
for (final polygon in geometry.land) {
final landPaint = Paint()..color = landColor;
for (final polygon in land) {
final turns = polygon.turnsTo(view.lon);
if (polygon.shows(visible, turns)) {
canvas.drawPath(
@ -781,15 +911,15 @@ class const _MapPainter({
);
}
}
for (final shape in geometry.regions) {
for (final shape in [...context, ...regions]) {
final path = shape.pathIn(visible, view.lon)?.transform(matrix);
if (path != null) {
canvas
..drawPath(
path,
fills[shape.region.code] == null
fills[shape.code] == null
? landPaint
: (Paint()..color = fills[shape.region.code]!),
: (Paint()..color = fills[shape.code]!),
)
..drawPath(path, stroke);
}
@ -799,8 +929,8 @@ class const _MapPainter({
..strokeWidth = 1.5
..color = border;
final area = Offset.zero & size;
for (final shape in geometry.regions) {
final code = shape.region.code;
for (final shape in regions) {
final code = shape.code;
if (markable != null && !markable!.contains(code) ||
!shape.isTiny(view, size)) {
continue;
@ -818,9 +948,11 @@ class const _MapPainter({
@override
bool shouldRepaint(_MapPainter oldDelegate) =>
oldDelegate.geometry != geometry ||
oldDelegate.view != view ||
oldDelegate.land != land ||
!listEquals(oldDelegate.land, land) ||
!listEquals(oldDelegate.context, context) ||
!listEquals(oldDelegate.regions, regions) ||
oldDelegate.landColor != landColor ||
oldDelegate.border != border ||
!setEquals(oldDelegate.markable, markable) ||
!mapEquals(oldDelegate.fills, fills);

View file

@ -128,6 +128,10 @@ class StudySessionBloc(
/// What wrong answers are drawn from, while words are quizzed.
var _pools = const QuizPools(meanings: [], readings: [], expressions: []);
/// The regions the round's map questions point at, which a map opens
/// around: on Japan when they are all prefectures.
var _mapRegions = <String>{};
final _ratings = <String, Rating>{};
final _graduated = <String>{};
var _cramIndex = 0;
@ -210,6 +214,9 @@ class StudySessionBloc(
_tagging = await _tags.read();
final now = clock.now().toUtc();
_pools = QuizPools.of([for (final card in _cardsById.values) card.content]);
_mapRegions = {
for (final card in _cardsById.values) ?card.content.mapRegion,
};
_setAside = _buriedOf(_cardsById.values, now);
if (_options.mode == LearnMode.cram) {
_clearingBacklog = false;
@ -511,6 +518,7 @@ class StudySessionBloc(
exceptId: card.id,
)
: const [],
mapRegions: card.content.map == null ? const {} : _mapRegions,
studysetId: _studysetOf[id] ?? _studysetId,
tags: _tagging.of(id),
asksBack: _asksBack(card),
@ -1130,6 +1138,10 @@ final class const StudySessionStateShowing({
/// A character's words in the studyset, to show with its answer.
final List<ExampleWord> examples = const [],
/// For a map question, the regions the round's map questions point at,
/// which its map opens around.
final Set<String> mapRegions = const {},
/// The studyset it is in.
required final String studysetId,
@ -1202,6 +1214,7 @@ final class const StudySessionStateShowing({
typesReading: typesReading,
writes: writes,
examples: examples,
mapRegions: mapRegions,
studysetId: studysetId,
tags: tags,
asksBack: asksBack,
@ -1230,6 +1243,7 @@ final class const StudySessionStateShowing({
typesReading,
writes,
examples,
mapRegions,
studysetId,
tags,
asksBack,

View file

@ -10,7 +10,7 @@ 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/jmdict_tags.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/atlas.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/geo_map/widget/region_names.dart';
@ -1353,6 +1353,10 @@ class const _Item({
class const MapBody({
required final Card card,
/// The regions the session's map questions point at, which the map opens
/// around.
required final Set<String> around,
/// The row above the card's content.
required final Widget header,
required final bool checked,
@ -1376,10 +1380,7 @@ class _MapBodyState() extends State<MapBody> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = widget.card.content;
final right = content.answers
.where((answer) => answer.correct && answer.region != null)
.firstOrNull
?.region;
final right = content.mapRegion;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -1404,12 +1405,18 @@ class _MapBodyState() extends State<MapBody> {
Expanded(
child: MapOf(
mapId: content.map!,
builder: (context, map) => Column(
builder: (context, atlas) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: RegionMap(
map: map,
atlas: atlas,
around: widget.around,
// A prefecture's question opens Japan at a tap,
// a country's picks it.
countryTaps: atlas.mapOf(right) == atlas.world
? CountryTaps.pick
: CountryTaps.open,
selected: widget.checked ? null : _picked,
right: widget.checked ? right : null,
wrong: widget.checked && _picked != right
@ -1422,7 +1429,7 @@ class _MapBodyState() extends State<MapBody> {
),
const SizedBox(height: 8),
_MapCaption(
map: map,
atlas: atlas,
picked: _picked,
right: right,
checked: widget.checked,
@ -1460,7 +1467,7 @@ class _MapBodyState() extends State<MapBody> {
/// What is picked on the map, or once checked whether it was right and
/// what was: one centred line.
class const _MapCaption({
required final GeoMap map,
required final Atlas atlas,
required final String? picked,
required final String? right,
required final bool checked,
@ -1472,8 +1479,12 @@ class const _MapCaption({
final muted = theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
String? name(String? code) => switch (map.region(code)) {
final region? => switch (regionOtherName(context, map, region)) {
String? name(String? code) => switch (atlas.region(code)) {
final region? => switch (regionOtherName(
context,
atlas.mapOf(code)!,
region,
)) {
final other? => '${regionName(context, region)} ($other)',
null => regionName(context, region),
},

View file

@ -469,6 +469,7 @@ class const _Showing(
CardType.multipleChoice when card.content.map != null => MapBody(
key: key,
card: card,
around: state.mapRegions,
header: cardHeader,
checked: state.answerShown,
onCheck: show,

View file

@ -128,6 +128,23 @@ void main() {
});
});
test("a map question's region is its right answer's; other cards have "
'none', () {
final where = CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
);
expect(where.mapRegion, 'JP-13');
expect(where.withField(CardField.map, null).mapRegion, isNull);
expect(CardContent(front: text('猫'), back: none).mapRegion, isNull);
});
test('previews the answer as its type has it', () {
expect(
CardContent(

View file

@ -582,8 +582,10 @@ void main() {
expect(saved.back, RichContent.empty());
});
testWidgets("a map question can be on the world's countries; changing the "
'map drops the region picked on the other', (tester) async {
testWidgets("a map question on the world's countries names the world's "
"map; a country is named in the studyset's language too", (
tester,
) async {
await cards.create('japanese', card('Where is Cyprus?', 'east'));
await showStudyset(
tester,
@ -594,12 +596,9 @@ void main() {
await tester.tap(find.text('Map'));
await settle(tester);
expect(find.text('Countries of the world'), findsNothing);
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
await settle(tester);
await tester.ensureVisible(find.text('Countries of the world'));
await tester.tap(find.text('Countries of the world'));
await settle(tester);
expect(find.text('No region picked yet'), findsOneWidget);
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('CY');
await settle(tester);
// Beside the app's language, the studyset's: Japanese.
@ -612,4 +611,45 @@ void main() {
expect(saved.answers.single.region, 'CY');
expect(saved.answers.single.content.plainText, 'Cyprus');
});
testWidgets("a map question opens on the layer the studyset's map "
'questions share: Japan, for Tokyo and Osaka', (tester) async {
for (final (question, region, name) in [
('Where is Tokyo?', 'JP-13', 'Tokyo'),
('Where is Osaka?', 'JP-27', 'Ōsaka'),
]) {
await cards.create(
'japanese',
CardContent(
type: CardType.multipleChoice,
front: RichContent.plain(question),
back: RichContent.empty(),
answers: [
CardAnswer(
content: RichContent.plain(name),
correct: true,
region: region,
),
],
map: 'japan',
),
);
}
await cards.create('japanese', card('Where is Kyoto?', 'west'));
await showStudyset(tester, maps: await loadedMaps(tester));
await open(tester, 'Where is Kyoto?');
await tester.tap(find.text('Map'));
await settle(tester);
expect(tester.widget<RegionMap>(find.byType(RegionMap)).around, {
'JP-13',
'JP-27',
});
expect(find.widgetWithText(TextButton, 'East Asia'), findsOneWidget);
expect(
find.widgetWithText(ActionChip, 'Kansai', skipOffstage: false),
findsOneWidget,
);
});
}

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