Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 0 additions & 12 deletions android/app/src/main/res/drawable-v21/launch_background.xml

This file was deleted.

10 changes: 10 additions & 0 deletions android/app/src/main/res/drawable-v31/splash_icon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icon for the Android 12+ system splash screen. The system draws it on a
288dp canvas and masks it to a 192dp circle (no icon background colour
is set), so the square logo is inset far enough to sit inside that
circle - 288dp * (1 - 2 * 28%) = 127dp, see values/dimens.xml. Without
this the system would show the launcher icon: upscaled from 48dp and
with its corners cut off by the circular mask. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_logo"
android:inset="28%" />
19 changes: 10 additions & 9 deletions android/app/src/main/res/drawable/launch_background.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Window background while the Flutter engine starts: shown from process
start until the first Flutter frame (on Android 12+ after the system
splash screen, see values-v31/styles.xml). Logo centered on the same
background colour as the app's first frame. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />

<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item android:drawable="@color/splash_background" />
<item
android:width="@dimen/splash_logo_size"
android:height="@dimen/splash_logo_size"
android:gravity="center"
android:drawable="@drawable/splash_logo" />
</layer-list>
9 changes: 9 additions & 0 deletions android/app/src/main/res/values-night-v31/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark-mode twin of values-v31/styles.xml -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
</style>
</resources>
6 changes: 6 additions & 0 deletions android/app/src/main/res/values-night/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Scaffold background of the Flutter dark theme (FlexThemeData.dark,
FlexScheme.red): #010101 -->
<color name="splash_background">#FF010101</color>
</resources>
6 changes: 5 additions & 1 deletion android/app/src/main/res/values-night/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- Same logo as LaunchTheme: FlutterActivity switches to this theme
in onCreate(), i.e. before the first Flutter frame. With a plain
colour here the logo would vanish while the engine is still
starting up - the longest part of a cold start on a slow device. -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
12 changes: 12 additions & 0 deletions android/app/src/main/res/values-v31/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ draws a system splash screen before our window is up;
brand it with our logo and background colour so it looks the same as
launch_background, which takes over until Flutter's first frame.
NormalTheme is inherited from values/styles.xml. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
</style>
</resources>
8 changes: 8 additions & 0 deletions android/app/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Background of the native splash screen. Keep this identical to the
scaffold background of the Flutter light theme (lib/design/theme.dart,
FlexScheme.red: #FFFFFF) so that the first Flutter frame doesn't
change the colour under the logo. Dark mode: values-night/colors.xml -->
<color name="splash_background">#FFFFFFFF</color>
</resources>
9 changes: 9 additions & 0 deletions android/app/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Size of the logo on the splash screen shown before Flutter's first
frame. 127dp is what the Android 12+ system splash renders our icon
at (drawable/splash_icon.xml: 288dp icon canvas, 28% inset on each
side), so the hand-off from the system splash to this window
background doesn't visibly resize the logo. -->
<dimen name="splash_logo_size">127dp</dimen>
</resources>
6 changes: 5 additions & 1 deletion android/app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- Same logo as LaunchTheme: FlutterActivity switches to this theme
in onCreate(), i.e. before the first Flutter frame. With a plain
colour here the logo would vanish while the engine is still
starting up - the longest part of a cold start on a slow device. -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ which is mitigated by overriding `fileSystemProvider` and `httpClientProvider` i

## Lifecycle

0. **Native splash screen** (no Dart code running yet)

- Covers Android process start, Flutter engine initialization and the `await`s at the top of `main()` - the single longest blank stretch of a cold start, and the one no Dart change can shorten. It shows the app logo on the same background colour as the first Flutter frame, so the hand-over doesn't flash (`values/colors.xml`: `#FFFFFF` light / `#010101` dark, i.e. the scaffold colours of the two themes in `lib/design/theme.dart`).
- Android: `drawable/launch_background.xml` is the `windowBackground` of both `LaunchTheme` (the system's starting window) and `NormalTheme` (the activity's own window - `FlutterActivity` switches to it in `onCreate()`, well before the first Flutter frame, so a plain colour there would drop the logo for the longest part of the wait); it centers `drawable-nodpi/splash_logo.png` (one 508 px bitmap, scaled by dp) at `@dimen/splash_logo_size` (127dp). On Android 12+ the system draws its own splash first: `values-v31/styles.xml` gives it the same colour and `drawable-v31/splash_icon.xml`, the logo inset by 28% so it sits inside the system's circular mask at the same 127dp - without that the system would upscale the launcher icon and cut its corners off.
- iOS: `LaunchScreen.storyboard` centers `LaunchImage` (127pt, 1x/2x/3x) on the `LaunchBackground` colour set (light/dark).
- All resources are hand-maintained, no splash generator package; the bitmaps derive from the 1024 px icon in `ios/Runner/Assets.xcassets/AppIcon.appiconset` with the white corners made transparent.

1. **`main()`** (`lib/main.dart`)

- `WidgetsFlutterBinding.ensureInitialized()`
Expand Down
4 changes: 4 additions & 0 deletions docs/data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ The branch name (`main`) is hardcoded; switching branches would require a code c

`init()` calls `_load()` only — no network. `lazyInit()` only checks for `contents.json` existence and returns a sparse `Language(languageCode, {}, [], {}, path, timestamp)` without parsing — used by the background isolate, and by `StartupPage` for all languages before the first frame. Both `lazyInit()` and `_load()` read the path from `ref.read(languageDownloaderProvider).pathFor(languageCode)`.

### Bulk downloads (`lib/data/bulk_language_download.dart`)

`downloadLanguagesInParallel(codes, download: ..., maxConcurrent: kMaxParallelLanguageDownloads, onProgress: ...)` runs a worker pool over the given language codes: at most `maxConcurrent` (4) downloads are in flight, and as soon as one finishes the next one starts, so a slow language never blocks idle slots. A download that returns `false` or throws counts as an error and the batch carries on. It returns a `BulkDownloadResult` (success/error counts, last successful code) for the summary snackbar - and, if `onProgress` is given, reports a `BulkDownloadProgress` (completed, total, language code, success) as each language finishes, which is what the "n of m" caption of `DownloadAllLanguagesButton` renders. Both "download all" and "update all" buttons use it.

### Inside `LanguageDownloaderImpl` (`lib/data/language_downloader.dart`)

The downloader owns the atomicity, concurrency, and crash-recovery guarantees so callers don't need to reason about partial state. One `download(langCode)` call performs:
Expand Down
8 changes: 4 additions & 4 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ What's on each screen, what each widget does, and where to find it.
## Screens (`lib/routes/`)

### `StartupPage`
Initial loading screen. Computes `init()` (see [routing.md](routing.md)), shows a `loadingAnimation('Loading')` spinner while the future is pending, navigates with `pushReplacementNamed` once decided. The `initFunction` constructor parameter is exposed only for tests so that `StartupPage` can drive `Navigator` against a `Completer<String>`.
Initial loading screen. Runs `init()` (see [routing.md](routing.md)) and shows a `LoadingAnimation` while it is pending, then navigates with `pushReplacementNamed` once decided. The caption under the spinner names the stage `init()` is in (`startupStageProvider`, localized `startupCheckingLanguages` / `startupLoadingAppLanguage` / `startupLoadingRecentPage`; `loading` until the first stage is reported). The `initFunction` constructor parameter is exposed only for tests so that `StartupPage` can drive `Navigator` against a `Completer<String>`; with it, the caption stays at `loading`.

### `HomePage` (`/home`)
Minimal — an `AppBar(title: '4training')`, the `MainDrawer`, and a `TableOfContent` body with the localized "What this app is" intro at the top.
Expand Down Expand Up @@ -76,7 +76,7 @@ Below the table: `diskUsage` total (calculated asynchronously — a `…` placeh

### Language buttons
- **`DownloadLanguageButton`** (`ConsumerStatefulWidget`): icon with internal `_isLoading` flag — swaps to `CircularProgressIndicator` during `LanguageController.download()`. Optional `highlight` flag wraps it in a tinted rounded box (used during onboarding).
- **`DownloadAllLanguagesButton`**: same idea, iterates `availableLanguagesProvider`.
- **`DownloadAllLanguagesButton`**: iterates `availableLanguagesProvider` through `downloadLanguagesInParallel()`. While the batch runs the icon is replaced by a *determinate* progress ring plus an "n of m" caption (`downloadProgress`), fed by the helper's `onProgress` callback; a failed language advances the counter like a successful one. The header cell of `LanguagesTable` has no fixed width for this reason - the control is 32 px wide while idle and grows while downloading. The end-of-batch snackbars are unchanged.
- **`DeleteLanguageButton`**: deleting the *current app language* is "discouraged" — the icon turns `inversePrimary` and clicking shows a `ConfirmDeletionDialog`.
- **`DeleteAllLanguagesButton`**: skips the current app language without prompting.
- **`UpdateLanguageButton`**: same loading pattern, calls `download(force: true)`.
Expand Down Expand Up @@ -109,8 +109,8 @@ The wrapper `ShareService` (and `shareProvider`) exists *purely* for testability
### `ErrorMessage`
A reusable card with an icon, title, and message. Used by `ErrorPage` and by `ViewPage`'s error states.

### `loadingAnimation(msg)`
Function (not a class) returning a `Scaffold` with a centered `CircularProgressIndicator` + label. Used during startup and when fetching page content.
### `LoadingAnimation`
A `Scaffold` with a centered `CircularProgressIndicator` and a `caption` *widget* underneath - a widget rather than a string so that the caption can rebuild on its own (`StartupPage` passes a `Consumer` watching `startupStageProvider`). `loadingAnimation(msg)` is the shorthand for a fixed, already localized text; `ViewPage` uses it with `loadingContent`.

## Sharing assets

Expand Down
2 changes: 1 addition & 1 deletion docs/onboarding-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The page is wrapped in `LayoutBuilder + SingleChildScrollView + ConstrainedBox(m

File: `lib/routes/onboarding/download_languages_page.dart`.

- Renders the same `LanguagesTable` widget used on the settings page, but with `highlightLang: appLanguage.languageCode` so the user's app-language download button is visually highlighted.
- Renders the same `LanguagesTable` widget used on the settings page, but with `highlightLang: appLanguage.languageCode` so the user's app-language download button is visually highlighted. Because it is the same table, "download all" shows its "n of m" progress here too - this is the first-run path where a user is most likely to download everything.
- "Continue" is disabled-looking until the app language is downloaded:
- Implementation note: we don't set `onPressed: null` (which would also disable click handling). We pass a manually-greyed `ButtonStyle` (`onSurface.withOpacity(0.12)` background, `0.38` foreground) so the button stays clickable. Clicking it while not yet downloaded shows a `MissingAppLanguageDialog` warning.
- Once the app language is downloaded, "Continue" routes to `getNextRoute(ref)`. Currently that always returns `/home` — for v0.9 this will branch to `/onboarding/3` if `checkFrequency` isn't set yet.
Expand Down
12 changes: 12 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ StartupPage.init():
return '/onboarding/1' # first time

# step 1: which languages are on the device? one stat() each, in parallel
stage.report(checkingLanguages)
await Future.wait(languageProvider(code).notifier.lazyInit() for all codes)

if app language is not yet downloaded:
Expand All @@ -45,8 +46,11 @@ StartupPage.init():
navigateTo = '/home'

# step 2: fully load only what the first screen renders
stage.report(loadingAppLanguage)
await Future.wait(languageProvider(code).notifier.init()
for code in {appLanguage, recentLang?})
# once the app language is in and recentLang is still loading:
# stage.report(loadingRecentPage)

# step 3: the remaining downloaded languages, unawaited, 3 at a time
unawaited(_loadRemainingLanguages(...))
Expand All @@ -65,6 +69,14 @@ Only the app language (for the menu) and the language of the resumed worksheet a

Step 3 gets the `LanguageController`s handed to it rather than the `WidgetRef`: `StartupPage` is disposed by `pushReplacementNamed` while that work is still running, and a disposed `WidgetRef` must not be used.

### Telling the user which stage we're in

On a slow device a single stage can take seconds, and a static caption then looks like a hang. So `init()` reports a `StartupStage` (`lib/data/startup_stage.dart`, exposed as `startupStageProvider`) right before it starts each stage, and the caption under the spinner renders the localized name of that stage. The rule is: only report a stage the code is actually in. That is why `loadingRecentPage` is reported from a continuation of the app language's `init()` and only if the worksheet's language is still loading at that moment - if it landed first there is nothing left to wait for and the caption stays on `loadingAppLanguage`.

Two consequences for the widget:
- Only the caption (a small `Consumer` inside `LoadingAnimation`) watches the provider, so a stage change rebuilds nothing but that text - in particular it never rebuilds `StartupPage` itself, which would restart `init()`.
- Riverpod forbids modifying a provider while the widget tree is building, so `StartupPage` is a `ConsumerStatefulWidget` that starts `init()` from a microtask in `initState`: the first frame (spinner, generic `loading` caption) goes out, then `init()` runs.

## Navigation primitives

- **`Navigator.pushNamed`** for normal in-app navigation.
Expand Down
8 changes: 8 additions & 0 deletions docs/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ This page is the index of every provider in the app — what it holds, what it d

`Globals` (a static-only class in the same file) holds the constant URLs (`https://github.com/4training/html-<lang>/archive/refs/heads/main.zip`) and folder names (`html-<lang>-main`).

### Startup progress (`lib/data/startup_stage.dart`)

| Provider | Type | Purpose |
| --- | --- | --- |
| `startupStageProvider` | `NotifierProvider<StartupStageNotifier, StartupStage>` | Which stage `StartupPage.init()` is in (`starting` / `checkingLanguages` / `loadingAppLanguage` / `loadingRecentPage`). Drives the caption under the startup spinner; only `init()` calls `report()` |

`StartupStage.getLocalized(context, stage)` maps a stage to its localized caption, following the `AutomaticUpdates.getLocalized` pattern. See [routing.md](routing.md), "Telling the user which stage we're in".

### App language (`lib/data/app_language.dart`)

| Provider | Type | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Almost every test:

Uses a `TestObserver extends NavigatorObserver` to record `didPush` and `didReplace` calls. The asserts then check that, given a starting state, the right `pushReplacementNamed` was invoked. This is the cleanest way to verify `StartupPage`'s decision matrix end-to-end.

`startup_page_test.dart` additionally pins the staged loading described in [routing.md](routing.md): a `GatedLanguageController` holds each `init()` open until the test releases it, so the test can assert that navigation happens once the app language and the recent page's language are loaded — while another downloaded language is still loading in the background.
`startup_page_test.dart` additionally pins the staged loading described in [routing.md](routing.md): a `GatedLanguageController` holds each `init()` open until the test releases it, so the test can assert that navigation happens once the app language and the recent page's language are loaded — while another downloaded language is still loading in the background. The same controller can also hold `lazyInit()` open (`lazyInitGate`), which is how the stage-caption tests walk `init()` through its stages one at a time and check the caption at each of them.

## Integration test

Expand Down
2 changes: 1 addition & 1 deletion integration_test/background_interaction_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ void main() async {
languageDownloaderProvider
.overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)),
], child: const App4Training()));
expect(find.text('Loading'), findsOneWidget);
expect(find.text('Wird geladen'), findsOneWidget);
await tester.pumpAndSettle();

// The languageStatusProvider haven't been loaded into memory yet -
Expand Down
2 changes: 2 additions & 0 deletions ios/Flutter/AppFrameworkInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
Expand Down
5 changes: 4 additions & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
platform :ios, '15.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down Expand Up @@ -39,5 +39,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end
Loading
Loading