fix(map): a region is picked on a screen of its own, and a country's map offered once it is picked
The composer showed the map inside its scrolling page, so zooming and scrolling fought over the wheel. A map question's answer is now a field that opens the map in the whole window, as a studyset's description is written, with Save in the app bar; nothing scrolls there, so the wheel alone zooms. The Prefectures chip stood beside East Asia's countries before Japan was picked; it now shows once Japan, or a prefecture, is. 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
43bf1d2987
commit
3181223940
13 changed files with 327 additions and 110 deletions
|
|
@ -100,7 +100,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
||||||
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files, a section each, the first three with Show all), Progress (only once anything in it was studied: how much is learnt, what is due today, the study time this week from the reviews' durations, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
|
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files, a section each, the first three with Show all), Progress (only once anything in it was studied: how much is learnt, what is due today, the study time this week from the reviews' durations, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
|
||||||
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse in the flashcards' section, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse in the flashcards' section, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
|
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
|
||||||
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
|
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
|
||||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -925,6 +925,7 @@
|
||||||
"mapUnknown": "Diese Landkarte braucht eine neuere Version der App.",
|
"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; wo Ringe dicht liegen, zoome hinein.",
|
"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",
|
"mapNonePicked": "Noch keine Region gewählt",
|
||||||
|
"mapPickTitle": "Region wählen",
|
||||||
"mapNameJapan": "Japan",
|
"mapNameJapan": "Japan",
|
||||||
"mapNameWorld": "Welt",
|
"mapNameWorld": "Welt",
|
||||||
"mapPrefectures": "Präfekturen",
|
"mapPrefectures": "Präfekturen",
|
||||||
|
|
@ -951,6 +952,7 @@
|
||||||
"mapGroupOceania": "Ozeanien",
|
"mapGroupOceania": "Ozeanien",
|
||||||
"composerPlaceholderQuestion": "Frage hinzufügen",
|
"composerPlaceholderQuestion": "Frage hinzufügen",
|
||||||
"composerLabelRegion": "Antwort",
|
"composerLabelRegion": "Antwort",
|
||||||
|
"composerRegionPick": "Tippen, um die Region auf der Karte zu wählen",
|
||||||
|
|
||||||
"homeStreakTooltip": "Lernserie",
|
"homeStreakTooltip": "Lernserie",
|
||||||
"homeArrange": "Widgets anordnen",
|
"homeArrange": "Widgets anordnen",
|
||||||
|
|
|
||||||
|
|
@ -1030,6 +1030,7 @@
|
||||||
"mapUnknown": "This map needs a newer version of the app.",
|
"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; where rings crowd, zoom in.",
|
"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",
|
"mapNonePicked": "No region picked yet",
|
||||||
|
"mapPickTitle": "Pick the region",
|
||||||
"mapNameJapan": "Japan",
|
"mapNameJapan": "Japan",
|
||||||
"mapNameWorld": "World",
|
"mapNameWorld": "World",
|
||||||
"mapPrefectures": "Prefectures",
|
"mapPrefectures": "Prefectures",
|
||||||
|
|
@ -1056,6 +1057,7 @@
|
||||||
"mapGroupOceania": "Oceania",
|
"mapGroupOceania": "Oceania",
|
||||||
"composerPlaceholderQuestion": "Add question",
|
"composerPlaceholderQuestion": "Add question",
|
||||||
"composerLabelRegion": "Answer",
|
"composerLabelRegion": "Answer",
|
||||||
|
"composerRegionPick": "Tap to pick the region on the map",
|
||||||
|
|
||||||
"homeStreakTooltip": "Study streak",
|
"homeStreakTooltip": "Study streak",
|
||||||
"homeArrange": "Arrange widgets",
|
"homeArrange": "Arrange widgets",
|
||||||
|
|
|
||||||
|
|
@ -5458,6 +5458,12 @@ abstract class AppLocalizations {
|
||||||
/// **'No region picked yet'**
|
/// **'No region picked yet'**
|
||||||
String get mapNonePicked;
|
String get mapNonePicked;
|
||||||
|
|
||||||
|
/// No description provided for @mapPickTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick the region'**
|
||||||
|
String get mapPickTitle;
|
||||||
|
|
||||||
/// No description provided for @mapNameJapan.
|
/// No description provided for @mapNameJapan.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5614,6 +5620,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Answer'**
|
/// **'Answer'**
|
||||||
String get composerLabelRegion;
|
String get composerLabelRegion;
|
||||||
|
|
||||||
|
/// No description provided for @composerRegionPick.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap to pick the region on the map'**
|
||||||
|
String get composerRegionPick;
|
||||||
|
|
||||||
/// No description provided for @homeStreakTooltip.
|
/// No description provided for @homeStreakTooltip.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3419,6 +3419,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get mapNonePicked => 'Noch keine Region gewählt';
|
String get mapNonePicked => 'Noch keine Region gewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapPickTitle => 'Region wählen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapNameJapan => 'Japan';
|
String get mapNameJapan => 'Japan';
|
||||||
|
|
||||||
|
|
@ -3497,6 +3500,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerLabelRegion => 'Antwort';
|
String get composerLabelRegion => 'Antwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerRegionPick =>
|
||||||
|
'Tippen, um die Region auf der Karte zu wählen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeStreakTooltip => 'Lernserie';
|
String get homeStreakTooltip => 'Lernserie';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3393,6 +3393,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get mapNonePicked => 'No region picked yet';
|
String get mapNonePicked => 'No region picked yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mapPickTitle => 'Pick the region';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get mapNameJapan => 'Japan';
|
String get mapNameJapan => 'Japan';
|
||||||
|
|
||||||
|
|
@ -3471,6 +3474,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerLabelRegion => 'Answer';
|
String get composerLabelRegion => 'Answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerRegionPick => 'Tap to pick the region on the map';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeStreakTooltip => 'Study streak';
|
String get homeStreakTooltip => 'Study streak';
|
||||||
|
|
||||||
|
|
|
||||||
65
halkyon_learn_flutter/lib/src/core/widget/chevron_field.dart
Normal file
65
halkyon_learn_flutter/lib/src/core/widget/chevron_field.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A form's field whose value is set on a screen of its own: it shows the
|
||||||
|
/// [value], up to [maxLines] lines, or the [placeholder] in italics while
|
||||||
|
/// there is none, and a chevron leads on, as tapping opens that screen.
|
||||||
|
/// So the form never holds what needs a whole window (an editor, a map).
|
||||||
|
class const ChevronField({
|
||||||
|
/// What the field holds, above it.
|
||||||
|
required final String label,
|
||||||
|
|
||||||
|
/// The value as text; null while there is none.
|
||||||
|
required final String? value,
|
||||||
|
|
||||||
|
/// What shows while there is no value, inviting to set one.
|
||||||
|
required final String placeholder,
|
||||||
|
|
||||||
|
/// Opens the screen that sets the value.
|
||||||
|
required final VoidCallback onTap,
|
||||||
|
final int maxLines = 3,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value ?? placeholder,
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: value == null
|
||||||
|
? colors.onSurfaceVariant
|
||||||
|
: colors.onSurface,
|
||||||
|
fontStyle: value == null ? FontStyle.italic : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 18,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
|
||||||
|
|
@ -18,8 +19,8 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dic
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.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/geo_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/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';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_names.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_picker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
|
|
@ -578,8 +579,9 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A map question's answer: the region to point at, picked on the maps
|
/// A map question's answer: the region to point at, as a field that opens
|
||||||
/// as one, from the layer the studyset's other map questions share.
|
/// the maps as one in the whole window ([pickRegion]), from the region
|
||||||
|
/// picked or the layer the studyset's other map questions share.
|
||||||
class const _MapAnswer({
|
class const _MapAnswer({
|
||||||
required final CardContent content,
|
required final CardContent content,
|
||||||
required final Set<String> around,
|
required final Set<String> around,
|
||||||
|
|
@ -590,50 +592,20 @@ class const _MapAnswer({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
|
||||||
final picked = content.answers.firstOrNull?.region;
|
final picked = content.answers.firstOrNull?.region;
|
||||||
return Column(
|
return MapOf(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
builder: (context, atlas) => ChevronField(
|
||||||
children: [
|
label: l10n.composerLabelRegion,
|
||||||
Text(l10n.composerLabelRegion, style: theme.textTheme.labelLarge),
|
value: pickedRegionName(context, atlas, picked),
|
||||||
const SizedBox(height: 4),
|
placeholder: l10n.composerRegionPick,
|
||||||
Text(
|
maxLines: 1,
|
||||||
l10n.mapPickRegion,
|
onTap: () async {
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
if (await pickRegion(context, initial: picked, around: around)
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
case final code? when code != picked) {
|
||||||
),
|
onPicked(atlas.mapOf(code)!, atlas.region(code)!);
|
||||||
),
|
}
|
||||||
const SizedBox(height: 8),
|
|
||||||
MapOf(
|
|
||||||
builder: (context, atlas) => Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
RegionMap(
|
|
||||||
atlas: atlas,
|
|
||||||
selected: picked,
|
|
||||||
around: around,
|
|
||||||
onRegionTapped: (code) =>
|
|
||||||
onPicked(atlas.mapOf(code)!, atlas.region(code)!),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
switch (atlas.region(picked)) {
|
|
||||||
final region? => switch (regionOtherName(
|
|
||||||
context,
|
|
||||||
atlas.mapOf(picked)!,
|
|
||||||
region,
|
|
||||||
)) {
|
|
||||||
final other? => '${regionName(context, region)} · $other',
|
|
||||||
null => regionName(context, region),
|
|
||||||
},
|
},
|
||||||
null => l10n.mapNonePicked,
|
|
||||||
},
|
|
||||||
style: theme.textTheme.titleSmall,
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// What tapping a country with a map of its own (Japan: its prefectures)
|
/// What tapping a country with a map of its own (Japan: its prefectures)
|
||||||
/// does.
|
/// does.
|
||||||
enum CountryTaps() {
|
enum CountryTaps() {
|
||||||
/// Picks the country, and a chip opens its map: while writing a
|
/// Picks the country, and once it or a region within it is picked, a
|
||||||
/// question, which may be about either.
|
/// chip opens its map: while writing a question, which may be about
|
||||||
|
/// either.
|
||||||
pickOrOpen,
|
pickOrOpen,
|
||||||
|
|
||||||
/// Picks the country: a question about the country.
|
/// Picks the country: a question about the country.
|
||||||
|
|
@ -33,7 +34,9 @@ enum CountryTaps() {
|
||||||
/// its own, its prefectures, in groups too (Kantō, Kansai …), which opens
|
/// 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
|
/// 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
|
/// can hardly hit on a phone is picked where it is large enough: Cyprus in
|
||||||
/// Europe rather than on the world, Tokyo in Kantō.
|
/// Europe rather than on the world, Tokyo in Kantō. A country's own map is
|
||||||
|
/// offered only once the country is picked, so the prefectures don't stand
|
||||||
|
/// beside East Asia's other countries as if they were one.
|
||||||
///
|
///
|
||||||
/// Even there some are too small to hit (Liechtenstein, Malta, the
|
/// Even there some are too small to hit (Liechtenstein, Malta, the
|
||||||
/// Caribbean's islands): they get a ring, which picks them. A tap picks the
|
/// Caribbean's islands): they get a ring, which picks them. A tap picks the
|
||||||
|
|
@ -42,7 +45,7 @@ enum CountryTaps() {
|
||||||
/// the least a finger is sure to hit) or outline within 8 dp. Where rings
|
/// the least a finger is sure to hit) or outline within 8 dp. Where rings
|
||||||
/// crowd, one zooms in, on any layer: pinching, or scrolling with Ctrl (⌘
|
/// crowd, one zooms in, on any layer: pinching, or scrolling with Ctrl (⌘
|
||||||
/// on a Mac) held, as maps on web pages do, so plain scrolling still scrolls
|
/// on a Mac) held, as maps on web pages do, so plain scrolling still scrolls
|
||||||
/// the page; scrolling without it says so. Drags pan. In a page that
|
/// the page; scrolling without it says so, unless [wheelZooms]. Drags pan. In a page that
|
||||||
/// scrolls, the map is 4:3 but never taller than
|
/// scrolls, the map is 4:3 but never taller than
|
||||||
/// [RegionMapState.screenShare] of the screen, so all of it shows while
|
/// [RegionMapState.screenShare] of the screen, so all of it shows while
|
||||||
/// zooming.
|
/// zooming.
|
||||||
|
|
@ -76,6 +79,10 @@ class const RegionMap({
|
||||||
|
|
||||||
/// The region picked wrongly, once checked.
|
/// The region picked wrongly, once checked.
|
||||||
final String? wrong,
|
final String? wrong,
|
||||||
|
|
||||||
|
/// Whether the mouse's wheel zooms without Ctrl (or ⌘): where nothing
|
||||||
|
/// around the map scrolls.
|
||||||
|
final bool wheelZooms = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -165,6 +172,16 @@ class RegionMapState()
|
||||||
_MapGeometry _geometryOf(GeoMap map) =>
|
_MapGeometry _geometryOf(GeoMap map) =>
|
||||||
_geometries[map] ??= _MapGeometry(map);
|
_geometries[map] ??= _MapGeometry(map);
|
||||||
|
|
||||||
|
/// Whether the region picked is [country] or lies within it: only then
|
||||||
|
/// does a chip offer the country's own map.
|
||||||
|
bool _pickedIn(String country) => switch (widget.atlas.mapOf(
|
||||||
|
widget.selected,
|
||||||
|
)) {
|
||||||
|
null => false,
|
||||||
|
final map =>
|
||||||
|
widget.selected == country || widget.atlas.countryOf(map) == country,
|
||||||
|
};
|
||||||
|
|
||||||
/// Whether a tap picks a region here, rather than a group.
|
/// Whether a tap picks a region here, rather than a group.
|
||||||
bool get _picksRegions => _level.group != null || _level.map.groups.isEmpty;
|
bool get _picksRegions => _level.group != null || _level.map.groups.isEmpty;
|
||||||
|
|
||||||
|
|
@ -355,13 +372,14 @@ class RegionMapState()
|
||||||
|
|
||||||
/// A mouse's wheel with Ctrl (or ⌘) held zooms around the pointer, as
|
/// 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
|
/// pinching does, and the page around doesn't scroll; without, the page
|
||||||
/// scrolls and a note says how to zoom.
|
/// scrolls and a note says how to zoom. Where nothing around scrolls
|
||||||
|
/// ([RegionMap.wheelZooms]), the wheel alone zooms.
|
||||||
void _scrolled(PointerSignalEvent event) {
|
void _scrolled(PointerSignalEvent event) {
|
||||||
if (event is! PointerScrollEvent || _view == null || _size == null) {
|
if (event is! PointerScrollEvent || _view == null || _size == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final keys = HardwareKeyboard.instance;
|
final keys = HardwareKeyboard.instance;
|
||||||
if (!keys.isControlPressed && !keys.isMetaPressed) {
|
if (!widget.wheelZooms && !keys.isControlPressed && !keys.isMetaPressed) {
|
||||||
_noteZoomKeys();
|
_noteZoomKeys();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -496,7 +514,8 @@ class RegionMapState()
|
||||||
if (widget.countryTaps == CountryTaps.pickOrOpen &&
|
if (widget.countryTaps == CountryTaps.pickOrOpen &&
|
||||||
_level.group != null)
|
_level.group != null)
|
||||||
for (final MapEntry(key: country, value: map) in atlas.inside.entries)
|
for (final MapEntry(key: country, value: map) in atlas.inside.entries)
|
||||||
if (_level.group!.members.contains(country)) map,
|
if (_level.group!.members.contains(country) && _pickedIn(country))
|
||||||
|
map,
|
||||||
],
|
],
|
||||||
onBack: above == null ? null : () => _open(above),
|
onBack: above == null ? null : () => _open(above),
|
||||||
onChoose: _choose,
|
onChoose: _choose,
|
||||||
|
|
@ -529,7 +548,8 @@ class RegionMapState()
|
||||||
|
|
||||||
/// Above the map: Back to the layer [above], if any; on a whole map a chip
|
/// 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
|
/// 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).
|
/// within a country of it that one may [opens] (Japan's prefectures, once
|
||||||
|
/// Japan is picked).
|
||||||
class const _Header({
|
class const _Header({
|
||||||
required final MapLevel level,
|
required final MapLevel level,
|
||||||
required final MapLevel? above,
|
required final MapLevel? above,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.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';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Picks a region on the maps in the whole window, over what opened it, as
|
||||||
|
/// a studyset's description is written: the map fills what the window
|
||||||
|
/// leaves, so nothing around it scrolls and the wheel alone zooms. Opens on
|
||||||
|
/// the region [initial], else around the regions [around]; completes with
|
||||||
|
/// the code of the region picked once saved, or with null when left
|
||||||
|
/// without saving.
|
||||||
|
Future<String?> pickRegion(
|
||||||
|
BuildContext context, {
|
||||||
|
required String? initial,
|
||||||
|
Set<String> around = const {},
|
||||||
|
}) => Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (context) => _RegionPicker(initial: initial, around: around),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class const _RegionPicker({
|
||||||
|
required final String? initial,
|
||||||
|
required final Set<String> around,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_RegionPicker> createState() => _RegionPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegionPickerState() extends State<_RegionPicker> {
|
||||||
|
late var _picked = widget.initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.mapPickTitle),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _picked == null
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(_picked),
|
||||||
|
child: Text(l10n.commonSave),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: MapOf(
|
||||||
|
builder: (context, atlas) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.mapPickRegion,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: RegionMap(
|
||||||
|
atlas: atlas,
|
||||||
|
selected: _picked,
|
||||||
|
around: widget.around,
|
||||||
|
wheelZooms: true,
|
||||||
|
onRegionTapped: (code) => setState(() => _picked = code),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
pickedRegionName(context, atlas, _picked) ??
|
||||||
|
l10n.mapNonePicked,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The region [code] by its name, with its other name after a dot where
|
||||||
|
/// it has one (Tokyo · 東京都); null for none.
|
||||||
|
String? pickedRegionName(BuildContext context, Atlas atlas, 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),
|
||||||
|
},
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
|
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/field_label.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/field_label.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||||
|
|
@ -590,48 +591,16 @@ class const _DescriptionField({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colors = theme.colorScheme;
|
|
||||||
final text = description.plainText.trim();
|
final text = description.plainText.trim();
|
||||||
final empty = description.isBlank;
|
return ChevronField(
|
||||||
return InputDecorator(
|
label: l10n.studysetSheetDescription,
|
||||||
decoration: InputDecoration(
|
value: description.isBlank
|
||||||
labelText: l10n.studysetSheetDescription,
|
? null
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
|
||||||
),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
empty
|
|
||||||
? l10n.studysetSheetDescriptionAdd
|
|
||||||
: text.isNotEmpty
|
: text.isNotEmpty
|
||||||
? text
|
? text
|
||||||
: l10n.studysetSheetDescriptionAdded,
|
: l10n.studysetSheetDescriptionAdded,
|
||||||
maxLines: 3,
|
placeholder: l10n.studysetSheetDescriptionAdd,
|
||||||
overflow: TextOverflow.ellipsis,
|
onTap: onTap,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: empty ? colors.onSurfaceVariant : colors.onSurface,
|
|
||||||
fontStyle: empty ? FontStyle.italic : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
LucideIcons.chevronRight,
|
|
||||||
size: 18,
|
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
|
@ -122,6 +123,12 @@ void main() {
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the map from a map question's answer.
|
||||||
|
Future<void> pickOnMap(WidgetTester tester) async {
|
||||||
|
await tester.tap(find.byType(ChevronField));
|
||||||
|
await settle(tester);
|
||||||
|
}
|
||||||
|
|
||||||
/// Chooses [item] of the card's ⋮ menu.
|
/// Chooses [item] of the card's ⋮ menu.
|
||||||
Future<void> action(WidgetTester tester, String item) async {
|
Future<void> action(WidgetTester tester, String item) async {
|
||||||
await tester.tap(find.byTooltip('More actions'));
|
await tester.tap(find.byTooltip('More actions'));
|
||||||
|
|
@ -637,19 +644,32 @@ void main() {
|
||||||
expect(saved.back, RichContent.empty());
|
expect(saved.back, RichContent.empty());
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a map question takes the region tapped as its answer', (
|
testWidgets('a map question takes the region tapped on a screen of its '
|
||||||
tester,
|
'own, where nothing scrolls, as its answer once saved', (tester) async {
|
||||||
) async {
|
|
||||||
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
||||||
await showStudyset(tester, maps: await loadedMaps(tester));
|
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||||
await open(tester, 'Where is Tokyo?');
|
await open(tester, 'Where is Tokyo?');
|
||||||
|
|
||||||
await tester.tap(find.text('Map'));
|
await tester.tap(find.text('Map'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
expect(find.byType(RegionMap), findsNothing);
|
||||||
|
await pickOnMap(tester);
|
||||||
expect(find.text('No region picked yet'), findsOneWidget);
|
expect(find.text('No region picked yet'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byType(RegionMap),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(tester.widget<RegionMap>(find.byType(RegionMap)).wheelZooms, true);
|
||||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.byType(RegionMap), findsNothing);
|
||||||
|
expect(find.text('Tokyo · 東京都'), findsOneWidget);
|
||||||
await tester.tap(find.byType(BackButton));
|
await tester.tap(find.byType(BackButton));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
|
|
@ -676,10 +696,13 @@ void main() {
|
||||||
await tester.tap(find.text('Map'));
|
await tester.tap(find.text('Map'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
expect(find.text('Countries of the world'), findsNothing);
|
expect(find.text('Countries of the world'), findsNothing);
|
||||||
|
await pickOnMap(tester);
|
||||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('CY');
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('CY');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||||
|
await settle(tester);
|
||||||
// Beside the app's language, the studyset's: Japanese.
|
// Beside the app's language, the studyset's: Japanese.
|
||||||
expect(find.text('Cyprus · キプロス'), findsOneWidget);
|
expect(find.text('Cyprus · キプロス'), findsOneWidget);
|
||||||
await tester.tap(find.byType(BackButton));
|
await tester.tap(find.byType(BackButton));
|
||||||
|
|
@ -720,6 +743,7 @@ void main() {
|
||||||
|
|
||||||
await tester.tap(find.text('Map'));
|
await tester.tap(find.text('Map'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
await pickOnMap(tester);
|
||||||
|
|
||||||
expect(tester.widget<RegionMap>(find.byType(RegionMap)).around, {
|
expect(tester.widget<RegionMap>(find.byType(RegionMap)).around, {
|
||||||
'JP-13',
|
'JP-13',
|
||||||
|
|
@ -731,4 +755,23 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('leaving the map without saving keeps the answer', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await cards.create('japanese', card('Where is Tokyo?', 'east'));
|
||||||
|
await showStudyset(tester, maps: await loadedMaps(tester));
|
||||||
|
await open(tester, 'Where is Tokyo?');
|
||||||
|
await tester.tap(find.text('Map'));
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
await pickOnMap(tester);
|
||||||
|
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-13');
|
||||||
|
await settle(tester);
|
||||||
|
await tester.tap(find.byType(CloseButton));
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
expect(find.byType(RegionMap), findsNothing);
|
||||||
|
expect(find.text('Tap to pick the region on the map'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Shows the maps at a phone's size, anew unless [key] is the one shown;
|
/// Shows the maps at a phone's size, anew unless [key] is the one shown;
|
||||||
/// what is picked lands in the list.
|
/// what is picked lands in the list, and shows as picked.
|
||||||
Future<List<String>> show(
|
Future<List<String>> show(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
Key? key,
|
Key? key,
|
||||||
|
|
@ -34,6 +34,7 @@ void main() {
|
||||||
..devicePixelRatio = 1;
|
..devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
final picked = <String>[];
|
final picked = <String>[];
|
||||||
|
final shown = key ?? UniqueKey();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: appTheme(Brightness.light),
|
theme: appTheme(Brightness.light),
|
||||||
|
|
@ -42,14 +43,19 @@ void main() {
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: RegionMap(
|
child: StatefulBuilder(
|
||||||
key: key ?? UniqueKey(),
|
builder: (context, setState) => RegionMap(
|
||||||
|
key: shown,
|
||||||
atlas: atlas,
|
atlas: atlas,
|
||||||
countryTaps: countryTaps,
|
countryTaps: countryTaps,
|
||||||
around: around,
|
around: around,
|
||||||
|
selected: picked.lastOrNull,
|
||||||
right: right,
|
right: right,
|
||||||
wrong: wrong,
|
wrong: wrong,
|
||||||
onRegionTapped: readOnly ? null : picked.add,
|
onRegionTapped: readOnly
|
||||||
|
? null
|
||||||
|
: (code) => setState(() => picked.add(code)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -96,11 +102,12 @@ void main() {
|
||||||
|
|
||||||
const tokyo = (139.69, 35.69);
|
const tokyo = (139.69, 35.69);
|
||||||
|
|
||||||
testWidgets('Japan is picked in East Asia, and a chip opens its '
|
testWidgets('Japan is picked in East Asia, and once it is, a chip opens '
|
||||||
'prefectures in layers; Back goes up one', (tester) async {
|
'its prefectures in layers; Back goes up one', (tester) async {
|
||||||
final picked = await show(tester);
|
final picked = await show(tester);
|
||||||
|
|
||||||
await choose(tester, 'East Asia');
|
await choose(tester, 'East Asia');
|
||||||
|
expect(chip('Prefectures'), findsNothing);
|
||||||
await tapAt(tester, tokyo);
|
await tapAt(tester, tokyo);
|
||||||
expect(picked, ['JP']);
|
expect(picked, ['JP']);
|
||||||
|
|
||||||
|
|
@ -130,6 +137,11 @@ void main() {
|
||||||
await tester.tap(back('East Asia'));
|
await tester.tap(back('East Asia'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('East Asia'), findsOneWidget);
|
expect(find.text('East Asia'), findsOneWidget);
|
||||||
|
// Osaka is within Japan; Seoul isn't.
|
||||||
|
expect(chip('Prefectures'), findsOneWidget);
|
||||||
|
await tapAt(tester, (126.98, 37.57));
|
||||||
|
expect(picked.last, 'KR');
|
||||||
|
expect(chip('Prefectures'), findsNothing);
|
||||||
await tester.tap(back('World'));
|
await tester.tap(back('World'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(chip('Europe'), findsOneWidget);
|
expect(chip('Europe'), findsOneWidget);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue