feat(app): a studyset's languages, and Chinese and Korean fonts

The studyset sheet gains Languages, as before: off until "Set
languages", then the language of the cards' fronts and of their backs
(22 languages, Chinese in its two scripts). A studyset without its own
takes those of the nearest studyset above it, both together; set for the
first time, they start from those it took. The payload held both fields
already.

They set its text: Noto Sans SC, TC and KR join Noto Sans JP (bundled,
never fetched; 34 MB more, fonts 42 MB in all, your call), and since
Han characters take the shapes of the first font that has them, every
screen inside a studyset (its screen, cards, notes, paths, studying it)
puts the font of its front's language first, else of its back's,
Japanese otherwise. Every studyset route goes through one helper now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 23:30:31 +02:00
commit b1ef9b9d3a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 901 additions and 65 deletions

View file

@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **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. `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 fills in kana and kanji in their Japanese shapes; each is one variable font holding every weight, and their OFL licences join the licence list. Scripts neither covers (Chinese, Korean, Arabic, …) fall back to the device's own fonts. 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`, `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. `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`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs 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.
- **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 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.
- **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.

View file

@ -6,9 +6,13 @@ every device, whatever fonts it has.
| File | Font | Source | Licence |
|---|---|---|---|
| `NotoSans-Variable.ttf`, `NotoSans-Italic-Variable.ttf` | Noto Sans (Latin, Greek incl. polytonic, Cyrillic), every weight | [notofonts/latin-greek-cyrillic](https://github.com/notofonts/latin-greek-cyrillic) release NotoSans-v2.015, `unhinted/slim-variable-ttf` | SIL Open Font License 1.1, `OFL-NotoSans.txt` |
| `NotoSansJP-Variable.otf` | Noto Sans JP (kana, kanji in Japanese shapes), every weight | [notofonts/noto-cjk](https://github.com/notofonts/noto-cjk) `Sans/Variable/OTF/Subset/NotoSansJP-VF.otf`, commit f8d157532fbf (2026-10-08) | SIL Open Font License 1.1, `OFL-NotoSansJP.txt` |
| `NotoSansJP-Variable.otf`, `NotoSansSC-Variable.otf`, `NotoSansTC-Variable.otf`, `NotoSansKR-Variable.otf` | Noto Sans JP, SC, TC, KR (Japanese, Simplified and Traditional Chinese, Korean), every weight | [notofonts/noto-cjk](https://github.com/notofonts/noto-cjk) `Sans/Variable/OTF/Subset/NotoSans{JP,SC,TC,KR}-VF.otf`, commit f8d157532fbf (2026-10-08) | SIL Open Font License 1.1, `OFL-NotoSansCJK.txt` |
Noto Sans is the app's font; Noto Sans JP fills in the glyphs Noto Sans lacks
(`lib/src/core/theme/app_fonts.dart`). Noto Sans JP alone would not do: it has
no accented or polytonic Greek. The files are renamed only; their glyphs are
unchanged. The licences are shown in the app's licence list.
Noto Sans is the app's font; the CJK fonts fill in the glyphs it lacks, the
one matching a studyset's language first, Japanese otherwise
(`lib/src/core/theme/app_fonts.dart`): they share most Han characters but draw
them differently. Noto Sans JP alone would not do as the app's font: it has
no accented or polytonic Greek. Scripts none of them covers (Arabic, Hebrew,
Thai, Devanagari, …) come from the device's own fonts. The files are renamed
only; their glyphs are unchanged. The licences are shown in the app's licence
list.

View file

@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -307,6 +307,35 @@
"studysetSheetDescriptionAdd": "Tippen, um eine Beschreibung hinzuzufügen",
"studysetSheetDescriptionAdded": "Beschreibung hinzugefügt — zum Bearbeiten tippen",
"studysetSheetInheritColor": "Erben",
"studysetSheetLanguages": "Sprachen",
"studysetSheetSetLanguages": "Sprachen festlegen",
"studysetSheetLanguagesHelper": "In welcher Sprache die Seiten der Karten sind. Chinesisch, Japanisch und Koreanisch erscheinen dann in ihren eigenen Schriftzeichen.",
"studysetSheetLanguagesInherited": "Ausgeschaltet übernimmt es die Sprachen des Lernsets, in dem es liegt.",
"studysetSheetFrontLanguage": "Sprache der Vorderseite",
"studysetSheetBackLanguage": "Sprache der Rückseite",
"studysetSheetLanguageNotSet": "Nicht festgelegt",
"languageArabic": "Arabisch",
"languageGerman": "Deutsch",
"languageGreek": "Griechisch",
"languageEnglish": "Englisch",
"languageSpanish": "Spanisch",
"languageFrench": "Französisch",
"languageHebrew": "Hebräisch",
"languageHindi": "Hindi",
"languageItalian": "Italienisch",
"languageJapanese": "Japanisch",
"languageKorean": "Koreanisch",
"languageDutch": "Niederländisch",
"languagePolish": "Polnisch",
"languagePortuguese": "Portugiesisch",
"languageRussian": "Russisch",
"languageSwedish": "Schwedisch",
"languageThai": "Thailändisch",
"languageTurkish": "Türkisch",
"languageUkrainian": "Ukrainisch",
"languageVietnamese": "Vietnamesisch",
"languageChineseSimplified": "Chinesisch (vereinfacht)",
"languageChineseTraditional": "Chinesisch (traditionell)",
"fieldRequired": "Pflichtfeld",
"exploreTitle": "Entdecken",

View file

@ -339,6 +339,35 @@
"studysetSheetDescriptionAdd": "Tap to add a description",
"studysetSheetDescriptionAdded": "Description added — tap to edit",
"studysetSheetInheritColor": "Inherit",
"studysetSheetLanguages": "Languages",
"studysetSheetSetLanguages": "Set languages",
"studysetSheetLanguagesHelper": "Which language each side of the cards is in. Chinese, Japanese and Korean then show in their own character shapes.",
"studysetSheetLanguagesInherited": "Off, it takes the languages of the studyset it is in.",
"studysetSheetFrontLanguage": "Front language",
"studysetSheetBackLanguage": "Back language",
"studysetSheetLanguageNotSet": "Not set",
"languageArabic": "Arabic",
"languageGerman": "German",
"languageGreek": "Greek",
"languageEnglish": "English",
"languageSpanish": "Spanish",
"languageFrench": "French",
"languageHebrew": "Hebrew",
"languageHindi": "Hindi",
"languageItalian": "Italian",
"languageJapanese": "Japanese",
"languageKorean": "Korean",
"languageDutch": "Dutch",
"languagePolish": "Polish",
"languagePortuguese": "Portuguese",
"languageRussian": "Russian",
"languageSwedish": "Swedish",
"languageThai": "Thai",
"languageTurkish": "Turkish",
"languageUkrainian": "Ukrainian",
"languageVietnamese": "Vietnamese",
"languageChineseSimplified": "Chinese (Simplified)",
"languageChineseTraditional": "Chinese (Traditional)",
"fieldRequired": "required",
"exploreTitle": "Explore",

View file

@ -1900,6 +1900,180 @@ abstract class AppLocalizations {
/// **'Inherit'**
String get studysetSheetInheritColor;
/// No description provided for @studysetSheetLanguages.
///
/// In en, this message translates to:
/// **'Languages'**
String get studysetSheetLanguages;
/// No description provided for @studysetSheetSetLanguages.
///
/// In en, this message translates to:
/// **'Set languages'**
String get studysetSheetSetLanguages;
/// No description provided for @studysetSheetLanguagesHelper.
///
/// In en, this message translates to:
/// **'Which language each side of the cards is in. Chinese, Japanese and Korean then show in their own character shapes.'**
String get studysetSheetLanguagesHelper;
/// No description provided for @studysetSheetLanguagesInherited.
///
/// In en, this message translates to:
/// **'Off, it takes the languages of the studyset it is in.'**
String get studysetSheetLanguagesInherited;
/// No description provided for @studysetSheetFrontLanguage.
///
/// In en, this message translates to:
/// **'Front language'**
String get studysetSheetFrontLanguage;
/// No description provided for @studysetSheetBackLanguage.
///
/// In en, this message translates to:
/// **'Back language'**
String get studysetSheetBackLanguage;
/// No description provided for @studysetSheetLanguageNotSet.
///
/// In en, this message translates to:
/// **'Not set'**
String get studysetSheetLanguageNotSet;
/// No description provided for @languageArabic.
///
/// In en, this message translates to:
/// **'Arabic'**
String get languageArabic;
/// No description provided for @languageGerman.
///
/// In en, this message translates to:
/// **'German'**
String get languageGerman;
/// No description provided for @languageGreek.
///
/// In en, this message translates to:
/// **'Greek'**
String get languageGreek;
/// No description provided for @languageEnglish.
///
/// In en, this message translates to:
/// **'English'**
String get languageEnglish;
/// No description provided for @languageSpanish.
///
/// In en, this message translates to:
/// **'Spanish'**
String get languageSpanish;
/// No description provided for @languageFrench.
///
/// In en, this message translates to:
/// **'French'**
String get languageFrench;
/// No description provided for @languageHebrew.
///
/// In en, this message translates to:
/// **'Hebrew'**
String get languageHebrew;
/// No description provided for @languageHindi.
///
/// In en, this message translates to:
/// **'Hindi'**
String get languageHindi;
/// No description provided for @languageItalian.
///
/// In en, this message translates to:
/// **'Italian'**
String get languageItalian;
/// No description provided for @languageJapanese.
///
/// In en, this message translates to:
/// **'Japanese'**
String get languageJapanese;
/// No description provided for @languageKorean.
///
/// In en, this message translates to:
/// **'Korean'**
String get languageKorean;
/// No description provided for @languageDutch.
///
/// In en, this message translates to:
/// **'Dutch'**
String get languageDutch;
/// No description provided for @languagePolish.
///
/// In en, this message translates to:
/// **'Polish'**
String get languagePolish;
/// No description provided for @languagePortuguese.
///
/// In en, this message translates to:
/// **'Portuguese'**
String get languagePortuguese;
/// No description provided for @languageRussian.
///
/// In en, this message translates to:
/// **'Russian'**
String get languageRussian;
/// No description provided for @languageSwedish.
///
/// In en, this message translates to:
/// **'Swedish'**
String get languageSwedish;
/// No description provided for @languageThai.
///
/// In en, this message translates to:
/// **'Thai'**
String get languageThai;
/// No description provided for @languageTurkish.
///
/// In en, this message translates to:
/// **'Turkish'**
String get languageTurkish;
/// No description provided for @languageUkrainian.
///
/// In en, this message translates to:
/// **'Ukrainian'**
String get languageUkrainian;
/// No description provided for @languageVietnamese.
///
/// In en, this message translates to:
/// **'Vietnamese'**
String get languageVietnamese;
/// No description provided for @languageChineseSimplified.
///
/// In en, this message translates to:
/// **'Chinese (Simplified)'**
String get languageChineseSimplified;
/// No description provided for @languageChineseTraditional.
///
/// In en, this message translates to:
/// **'Chinese (Traditional)'**
String get languageChineseTraditional;
/// No description provided for @fieldRequired.
///
/// In en, this message translates to:

View file

@ -1127,6 +1127,95 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSheetInheritColor => 'Erben';
@override
String get studysetSheetLanguages => 'Sprachen';
@override
String get studysetSheetSetLanguages => 'Sprachen festlegen';
@override
String get studysetSheetLanguagesHelper =>
'In welcher Sprache die Seiten der Karten sind. Chinesisch, Japanisch und Koreanisch erscheinen dann in ihren eigenen Schriftzeichen.';
@override
String get studysetSheetLanguagesInherited =>
'Ausgeschaltet übernimmt es die Sprachen des Lernsets, in dem es liegt.';
@override
String get studysetSheetFrontLanguage => 'Sprache der Vorderseite';
@override
String get studysetSheetBackLanguage => 'Sprache der Rückseite';
@override
String get studysetSheetLanguageNotSet => 'Nicht festgelegt';
@override
String get languageArabic => 'Arabisch';
@override
String get languageGerman => 'Deutsch';
@override
String get languageGreek => 'Griechisch';
@override
String get languageEnglish => 'Englisch';
@override
String get languageSpanish => 'Spanisch';
@override
String get languageFrench => 'Französisch';
@override
String get languageHebrew => 'Hebräisch';
@override
String get languageHindi => 'Hindi';
@override
String get languageItalian => 'Italienisch';
@override
String get languageJapanese => 'Japanisch';
@override
String get languageKorean => 'Koreanisch';
@override
String get languageDutch => 'Niederländisch';
@override
String get languagePolish => 'Polnisch';
@override
String get languagePortuguese => 'Portugiesisch';
@override
String get languageRussian => 'Russisch';
@override
String get languageSwedish => 'Schwedisch';
@override
String get languageThai => 'Thailändisch';
@override
String get languageTurkish => 'Türkisch';
@override
String get languageUkrainian => 'Ukrainisch';
@override
String get languageVietnamese => 'Vietnamesisch';
@override
String get languageChineseSimplified => 'Chinesisch (vereinfacht)';
@override
String get languageChineseTraditional => 'Chinesisch (traditionell)';
@override
String get fieldRequired => 'Pflichtfeld';

View file

@ -1121,6 +1121,95 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSheetInheritColor => 'Inherit';
@override
String get studysetSheetLanguages => 'Languages';
@override
String get studysetSheetSetLanguages => 'Set languages';
@override
String get studysetSheetLanguagesHelper =>
'Which language each side of the cards is in. Chinese, Japanese and Korean then show in their own character shapes.';
@override
String get studysetSheetLanguagesInherited =>
'Off, it takes the languages of the studyset it is in.';
@override
String get studysetSheetFrontLanguage => 'Front language';
@override
String get studysetSheetBackLanguage => 'Back language';
@override
String get studysetSheetLanguageNotSet => 'Not set';
@override
String get languageArabic => 'Arabic';
@override
String get languageGerman => 'German';
@override
String get languageGreek => 'Greek';
@override
String get languageEnglish => 'English';
@override
String get languageSpanish => 'Spanish';
@override
String get languageFrench => 'French';
@override
String get languageHebrew => 'Hebrew';
@override
String get languageHindi => 'Hindi';
@override
String get languageItalian => 'Italian';
@override
String get languageJapanese => 'Japanese';
@override
String get languageKorean => 'Korean';
@override
String get languageDutch => 'Dutch';
@override
String get languagePolish => 'Polish';
@override
String get languagePortuguese => 'Portuguese';
@override
String get languageRussian => 'Russian';
@override
String get languageSwedish => 'Swedish';
@override
String get languageThai => 'Thai';
@override
String get languageTurkish => 'Turkish';
@override
String get languageUkrainian => 'Ukrainian';
@override
String get languageVietnamese => 'Vietnamese';
@override
String get languageChineseSimplified => 'Chinese (Simplified)';
@override
String get languageChineseTraditional => 'Chinese (Traditional)';
@override
String get fieldRequired => 'required';

View file

@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
@ -144,11 +145,10 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
builder: (context, state) {
final person = PersonScope.of(context);
final studysetId = state.pathParameters['studysetId']!;
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) => StudySessionBloc(
person.studysets,
person.cards,
@ -206,11 +206,10 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
/// through; a studyset gone says so.
Widget _pathRunner(BuildContext context, String studysetId, String pathId) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) =>
PathRunnerBloc(person.paths, person.pathProgress, studysetId, pathId)
..add(const PathRunnerEventStarted()),
@ -223,11 +222,10 @@ Widget _pathRunner(BuildContext context, String studysetId, String pathId) {
/// Leaving closes its bloc, which saves what is unsaved.
Widget _pathEditor(BuildContext context, String studysetId, String pathId) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) => PathEditorBloc(
person.paths,
person.tags,
@ -249,11 +247,10 @@ Widget _stepEditor(
String stepId,
) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) =>
StepEditorBloc(person.paths, pathId, stepId)
..add(const StepEditorEventStarted()),
@ -262,15 +259,34 @@ Widget _stepEditor(
);
}
Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
id: id,
items: PersonScope.of(context).studysets.watchAll,
Widget _studysetScreen(BuildContext context, String id) => _inStudyset(
context,
id,
(studyset, all) => StudysetScreen(studyset: studyset, all: all),
wayBack: (
label: AppLocalizations.of(context).studysetBackToLibrary,
location: RoutePaths.library,
),
);
/// The studyset [id] among the person's, for [builder]'s screen, its text
/// set for the studyset's languages ([StudysetLanguageScope]); a studyset
/// gone (deleted, or an old link) says so, leading back by [wayBack].
Widget _inStudyset(
BuildContext context,
String id,
Widget Function(Studyset studyset, List<Studyset> all) builder, {
({String label, String location})? wayBack,
}) => FoundById<Studyset>(
id: id,
items: PersonScope.of(context).studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
wayBack: wayBack,
builder: (studyset, all) => StudysetLanguageScope(
studyset: studyset,
all: all,
child: builder(studyset, all),
),
);
/// The note [noteId] of the studyset [studysetId], or a new one without; a
@ -278,11 +294,10 @@ Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
/// its bloc, which saves what is unsaved.
Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (_, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(_, _) => BlocProvider(
create: (context) =>
NoteEditorBloc(person.notes, person.tags, studysetId, noteId: noteId)
..add(const NoteEditorEventStarted()),
@ -295,11 +310,10 @@ Widget _noteEditor(BuildContext context, String studysetId, {String? noteId}) {
/// given; a studyset gone (deleted, or an old link) says so.
Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
final person = PersonScope.of(context);
return FoundById<Studyset>(
id: studysetId,
items: person.studysets.watchAll,
idOf: (studyset) => studyset.id,
builder: (studyset, _) => BlocProvider(
return _inStudyset(
context,
studysetId,
(studyset, _) => BlocProvider(
create: (context) =>
CardComposerBloc(person.cards, person.tags, studysetId)
..add(const CardComposerEventStarted()),

View file

@ -0,0 +1,56 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
/// The languages a studyset's cards can be in, as BCP 47 tags: Chinese in
/// its two scripts, as they need different fonts (`appFontFallbackFor`).
const contentLanguages = [
'ar',
'de',
'el',
'en',
'es',
'fr',
'he',
'hi',
'it',
'ja',
'ko',
'nl',
'pl',
'pt',
'ru',
'sv',
'th',
'tr',
'uk',
'vi',
'zh-Hans',
'zh-Hant',
];
/// The language [tag] named in the app's language; a tag the app doesn't
/// list (from a newer version, say) as itself.
String contentLanguageName(AppLocalizations l10n, String tag) => switch (tag) {
'ar' => l10n.languageArabic,
'de' => l10n.languageGerman,
'el' => l10n.languageGreek,
'en' => l10n.languageEnglish,
'es' => l10n.languageSpanish,
'fr' => l10n.languageFrench,
'he' => l10n.languageHebrew,
'hi' => l10n.languageHindi,
'it' => l10n.languageItalian,
'ja' => l10n.languageJapanese,
'ko' => l10n.languageKorean,
'nl' => l10n.languageDutch,
'pl' => l10n.languagePolish,
'pt' => l10n.languagePortuguese,
'ru' => l10n.languageRussian,
'sv' => l10n.languageSwedish,
'th' => l10n.languageThai,
'tr' => l10n.languageTurkish,
'uk' => l10n.languageUkrainian,
'vi' => l10n.languageVietnamese,
'zh-Hans' => l10n.languageChineseSimplified,
'zh-Hant' => l10n.languageChineseTraditional,
_ => tag,
};

View file

@ -6,20 +6,55 @@ import 'package:flutter/services.dart';
/// text looks alike everywhere and needs nothing the device may lack.
const appFontFamily = 'Noto Sans';
/// What fills in where [appFontFamily] has no glyph: Noto Sans JP, for kana
/// and kanji in their Japanese shapes. Noto Sans JP alone can't be the app's
/// font: it lacks accented and polytonic Greek.
const appFontFallback = ['Noto Sans JP'];
/// The fonts for Chinese, Japanese and Korean, which fill in where
/// [appFontFamily] has no glyph. They share most Han characters but draw
/// them differently, and the first in a list that has a character draws it:
/// so Japanese comes first unless a studyset's language asks for another
/// ([appFontFallbackFor]). The scripts none of them covers (Arabic, Hebrew,
/// Thai, …) come from the device's own fonts.
const _japanese = 'Noto Sans JP';
const _simplifiedChinese = 'Noto Sans SC';
const _traditionalChinese = 'Noto Sans TC';
const _korean = 'Noto Sans KR';
/// What fills in where [appFontFamily] has no glyph, with no language
/// given: Japanese shapes first, then Chinese and Korean (Hangul only the
/// Korean font has).
const appFontFallback = [
_japanese,
_simplifiedChinese,
_traditionalChinese,
_korean,
];
/// [appFontFallback] for text in [language], a BCP 47 tag: its own font
/// first when it is Chinese, Japanese or Korean, so that the Han characters
/// it shares with the others take its shapes; for any other language, or
/// none, [appFontFallback].
List<String> appFontFallbackFor(String? language) {
final tag = language?.toLowerCase() ?? '';
final first = switch (tag.split('-')) {
['ja', ...] => _japanese,
['ko', ...] => _korean,
['zh', ...] when ['hant', 'tw', 'hk', 'mo'].any(tag.split('-').contains) =>
_traditionalChinese,
['zh', ...] => _simplifiedChinese,
_ => null,
};
return switch (first) {
null => appFontFallback,
final first => [first, ...appFontFallback.where((font) => font != first)],
};
}
/// Adds the bundled fonts' licences to the app's licence list, beside the
/// packages' own: the SIL Open Font License asks that it travels with them.
void registerAppFontLicenses() => LicenseRegistry.addLicense(() async* {
for (final (family, file) in const [
('Noto Sans', 'assets/fonts/OFL-NotoSans.txt'),
('Noto Sans JP', 'assets/fonts/OFL-NotoSansJP.txt'),
]) {
yield LicenseEntryWithLineBreaks([
family,
], await rootBundle.loadString(file));
}
appFontFamily,
], await rootBundle.loadString('assets/fonts/OFL-NotoSans.txt'));
yield LicenseEntryWithLineBreaks(
appFontFallback,
await rootBundle.loadString('assets/fonts/OFL-NotoSansCJK.txt'),
);
});

View file

@ -117,3 +117,30 @@ String? colorNameOf(String id, List<Studyset> all) {
}
return null;
}
/// The languages of a studyset's cards: of their fronts and of their backs,
/// as BCP 47 tags; either null when not given.
typedef StudysetLanguages = ({String? front, String? back});
/// The languages the studyset [id] is in: its own when it gives any, else
/// those of the nearest studyset it sits in that does; none when no one
/// does. A studyset gives its languages together, so a sub-studyset never
/// mixes its own front with an inherited back.
StudysetLanguages languagesOf(String id, List<Studyset> all) {
StudysetLanguages? given(Studyset? studyset) => switch (studyset?.content) {
final content?
when content.frontLanguage != null || content.backLanguage != null =>
(front: content.frontLanguage, back: content.backLanguage),
_ => null,
};
final studyset = all.where((each) => each.id == id).firstOrNull;
if (given(studyset) case final own?) {
return own;
}
for (final ancestor in ancestorsOf(id, all).reversed) {
if (given(ancestor) case final inherited?) {
return inherited;
}
}
return (front: null, back: null);
}

View file

@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:material_ui/material_ui.dart';
/// [child], what shows inside the studyset [studyset] (its screen, cards,
/// notes, paths, studying it), with text set for the studyset's languages
/// ([languagesOf], its own or inherited): Chinese, Japanese and Korean in
/// their own character shapes, the font of the front's language first,
/// else of the back's. Other languages change nothing.
class const StudysetLanguageScope({
required final Studyset studyset,
/// All the person's studysets, for the languages it inherits.
required final List<Studyset> all,
required final Widget child,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final (:front, :back) = languagesOf(studyset.id, all);
final fallback = switch (appFontFallbackFor(front)) {
final forFront when !listEquals(forFront, appFontFallback) => forFront,
_ => appFontFallbackFor(back),
};
if (listEquals(fallback, appFontFallback)) {
return child;
}
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
textTheme: theme.textTheme.apply(fontFamilyFallback: fallback),
primaryTextTheme: theme.primaryTextTheme.apply(
fontFamilyFallback: fallback,
),
),
child: child,
);
}
}

View file

@ -60,6 +60,7 @@ class _StudysetRailState() extends State<StudysetRail> {
context,
insideAnother: true,
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
inheritedLanguages: languagesOf(widget.parent.id, widget.all),
);
if (content != null) {
bloc.add(StudysetListEventCreateRequested(content));
@ -73,6 +74,7 @@ class _StudysetRailState() extends State<StudysetRail> {
initial: topic.content,
insideAnother: true,
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
inheritedLanguages: languagesOf(widget.parent.id, widget.all),
);
if (content != null && content != topic.content) {
bloc.add(StudysetListEventContentChanged(topic.id, content));

View file

@ -1424,6 +1424,9 @@ class const _Options({
initial: studyset.content,
insideAnother: parentId != null,
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
inheritedLanguages: parentId == null
? (front: null, back: null)
: languagesOf(parentId, all),
);
if (content != null && content != studyset.content) {
bloc.add(StudysetListEventContentChanged(studyset.id, content));

View file

@ -2,29 +2,33 @@ import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
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/theme/palette_color.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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/palette_color_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Creates a studyset's content, or edits [initial]: its title, secondary
/// title, description and colour. Completes with the new content once the
/// person saves, or with null when they close the sheet; closing it with
/// changes asks first.
/// title, description, colour and languages. Completes with the new content
/// once the person saves, or with null when they close the sheet; closing it
/// with changes asks first.
///
/// A studyset [insideAnother] can take the colour of the studyset it is in
/// ([inheritedColorName], shown meanwhile), and a new one does at first; at
/// the top level a new studyset is cobalt. What the sheet doesn't show (the
/// licence, the languages) keeps its value.
/// the top level a new studyset is cobalt. Its languages are off until set,
/// taking those of the studyset it is in ([inheritedLanguages], which they
/// start from once set). What the sheet doesn't show keeps its value.
Future<StudysetContent?> showStudysetSheet(
BuildContext context, {
StudysetContent? initial,
bool insideAnother = false,
String? inheritedColorName,
StudysetLanguages inheritedLanguages = (front: null, back: null),
}) => showAdaptiveSheet<StudysetContent>(
context,
// Swiping would discard changes without asking.
@ -33,6 +37,7 @@ Future<StudysetContent?> showStudysetSheet(
initial: initial,
insideAnother: insideAnother,
inheritedColorName: inheritedColorName,
inheritedLanguages: inheritedLanguages,
),
);
@ -43,6 +48,7 @@ class const _StudysetSheet({
required final StudysetContent? initial,
required final bool insideAnother,
required final String? inheritedColorName,
required final StudysetLanguages inheritedLanguages,
}) extends StatefulWidget {
@override
State<_StudysetSheet> createState() => _StudysetSheetState();
@ -64,6 +70,16 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
null => widget.insideAnother ? null : _defaultColor.name,
};
/// Whether the studyset gives languages of its own: off, it takes those
/// of the studyset it is in, or has none at the top level.
late var _setLanguages =
widget.initial?.frontLanguage != null ||
widget.initial?.backLanguage != null;
/// The languages chosen, kept while switched off, for switching back on.
late String? _frontLanguage = widget.initial?.frontLanguage;
late String? _backLanguage = widget.initial?.backLanguage;
@override
void initState() {
super.initState();
@ -91,8 +107,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
license: initial?.license,
licenseHolder: initial?.licenseHolder,
licenseYear: initial?.licenseYear,
frontLanguage: initial?.frontLanguage,
backLanguage: initial?.backLanguage,
frontLanguage: _setLanguages ? _frontLanguage : null,
backLanguage: _setLanguages ? _backLanguage : null,
studyOptions: initial?.studyOptions,
bibliography: initial?.bibliography ?? const [],
);
@ -104,7 +120,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
_title.text.trim().isNotEmpty ||
_secondaryTitle.text.trim().isNotEmpty ||
_description != RichContent.empty() ||
_colorName != (widget.insideAnother ? null : _defaultColor.name),
_colorName != (widget.insideAnother ? null : _defaultColor.name) ||
(_setLanguages && (_frontLanguage ?? _backLanguage) != null),
};
/// The colour the picker shows: the studyset's own, or while it takes the
@ -243,6 +260,47 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
],
],
),
const SizedBox(height: 24),
Text(
l10n.studysetSheetLanguages,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.studysetSheetSetLanguages),
subtitle: Text(
widget.insideAnother
? '${l10n.studysetSheetLanguagesHelper} '
'${l10n.studysetSheetLanguagesInherited}'
: l10n.studysetSheetLanguagesHelper,
),
value: _setLanguages,
onChanged: (on) => setState(() {
_setLanguages = on;
// Set for the first time, they start from those it took.
if (on && _frontLanguage == null && _backLanguage == null) {
_frontLanguage = widget.inheritedLanguages.front;
_backLanguage = widget.inheritedLanguages.back;
}
}),
),
if (_setLanguages) ...[
const SizedBox(height: 12),
_LanguageDropdown(
label: l10n.studysetSheetFrontLanguage,
value: _frontLanguage,
onChanged: (language) =>
setState(() => _frontLanguage = language),
),
const SizedBox(height: 12),
_LanguageDropdown(
label: l10n.studysetSheetBackLanguage,
value: _backLanguage,
onChanged: (language) =>
setState(() => _backLanguage = language),
),
],
const SizedBox(height: 16),
],
),
@ -251,6 +309,41 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
}
}
/// Chooses the language of one side of the cards, by its name in the app's
/// language, or none.
class const _LanguageDropdown({
required final String label,
required final String? value,
required final ValueChanged<String?> onChanged,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final languages = [
for (final tag in contentLanguages)
(tag: tag, name: contentLanguageName(l10n, tag)),
]..sort((a, b) => a.name.compareTo(b.name));
return DropdownButtonFormField<String?>(
initialValue: value,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
items: [
DropdownMenuItem(child: Text(l10n.studysetSheetLanguageNotSet)),
for (final language in languages)
DropdownMenuItem(value: language.tag, child: Text(language.name)),
// A language from a newer app version stays chosen, by its tag.
if (value case final other? when !contentLanguages.contains(other))
DropdownMenuItem(value: other, child: Text(other)),
],
onChanged: onChanged,
);
}
}
/// The description as a field: its text, up to three lines, or a word that
/// there is one without text (only an image, say), or an invitation to
/// write one in italics; a chevron leads on, as tapping opens the editor.

View file

@ -149,10 +149,11 @@ flutter:
- assets/maps/japan_prefectures.geojson
# The bundled fonts' licences, for the licence list (see assets/fonts/ATTRIBUTION.md).
- assets/fonts/OFL-NotoSans.txt
- assets/fonts/OFL-NotoSansJP.txt
- assets/fonts/OFL-NotoSansCJK.txt
# The app's own fonts, the same on every device and never fetched: Noto Sans for
# Latin, Greek and Cyrillic, Noto Sans JP behind it for kana and kanji
# Latin, Greek and Cyrillic; behind it Noto Sans JP, SC, TC and KR for Japanese,
# Chinese and Korean, in the order a studyset's language asks for
# (core/theme/app_fonts.dart). Each file holds every weight (variable fonts).
fonts:
- family: Noto Sans
@ -163,6 +164,15 @@ flutter:
- family: Noto Sans JP
fonts:
- asset: assets/fonts/NotoSansJP-Variable.otf
- family: Noto Sans SC
fonts:
- asset: assets/fonts/NotoSansSC-Variable.otf
- family: Noto Sans TC
fonts:
- asset: assets/fonts/NotoSansTC-Variable.otf
- family: Noto Sans KR
fonts:
- asset: assets/fonts/NotoSansKR-Variable.otf
# The app's icons, written by tool/app_icons.py: it prepares the images derived from
# the kingfisher in build/icons/ first, then runs `dart run flutter_launcher_icons`

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