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:
parent
8b9f3174a6
commit
1df60c8830
25 changed files with 975 additions and 391 deletions
|
|
@ -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ō",
|
||||
|
|
|
|||
|
|
@ -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ō",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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ō';
|
||||
|
||||
|
|
|
|||
|
|
@ -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ō';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
final code = _meant(hits).region.code;
|
||||
if (_picksRegions || _pickedAtOnce(code)) {
|
||||
_pick(code);
|
||||
return;
|
||||
}
|
||||
// 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 = widget.map.groupsOf(code)
|
||||
final groups = _level.map.groupsOf(code)
|
||||
..sort(
|
||||
(a, b) => _degreesBetween(
|
||||
_frameOf(a).center,
|
||||
(a, b) =>
|
||||
_degreesBetween(
|
||||
_frameOf((map: _level.map, group: a)).center,
|
||||
at,
|
||||
).compareTo(_degreesBetween(_frameOf(b).center, at)),
|
||||
).compareTo(
|
||||
_degreesBetween(_frameOf((map: _level.map, group: b)).center, at),
|
||||
),
|
||||
);
|
||||
_open(groups.first);
|
||||
}
|
||||
_open((map: _level.map, group: 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) {
|
||||
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);
|
||||
/// 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;
|
||||
}
|
||||
final keys = HardwareKeyboard.instance;
|
||||
if (!keys.isControlPressed && !keys.isMetaPressed) {
|
||||
_noteZoomKeys();
|
||||
return;
|
||||
}
|
||||
GestureBinding.instance.pointerSignalResolver.register(event, (event) {
|
||||
_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,
|
||||
),
|
||||
_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,34 +438,62 @@ class RegionMapState()
|
|||
onTapUp: (details) => _tapped(details.localPosition),
|
||||
onScaleStart: _picksRegions ? _pinchStarted : null,
|
||||
onScaleUpdate: _picksRegions ? _pinched : null,
|
||||
child: CustomPaint(
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: size,
|
||||
painter: _MapPainter(
|
||||
geometry: _geometry,
|
||||
view: _view!,
|
||||
land: colors.surface,
|
||||
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: {
|
||||
?widget.selected: colors.primary,
|
||||
?widget.wrong: colors.error,
|
||||
?widget.right: theme.extras.success,
|
||||
},
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
final header = widget.map.groups.isEmpty
|
||||
? null
|
||||
: _Header(
|
||||
map: widget.map,
|
||||
group: _group,
|
||||
onBack: () => _open(null),
|
||||
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(
|
||||
|
|
@ -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(
|
||||
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(
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue