diff --git a/android/app/src/main/res/drawable-nodpi/splash_logo.png b/android/app/src/main/res/drawable-nodpi/splash_logo.png
new file mode 100644
index 0000000..d7bf856
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/splash_logo.png differ
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
deleted file mode 100644
index f74085f..0000000
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/drawable-v31/splash_icon.xml b/android/app/src/main/res/drawable-v31/splash_icon.xml
new file mode 100644
index 0000000..9c5fb34
--- /dev/null
+++ b/android/app/src/main/res/drawable-v31/splash_icon.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 304732f..fb0df78 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,13 @@
-
+
-
-
-
-
+
+
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..48f8561
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..faacd32
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,6 @@
+
+
+
+ #FF010101
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 06952be..5bac2ea 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -13,6 +13,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..0003ee2
--- /dev/null
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..68e8e89
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+
+ #FFFFFFFF
+
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..fe9257e
--- /dev/null
+++ b/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,9 @@
+
+
+
+ 127dp
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index cb1ef88..f626cad 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -13,6 +13,10 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/docs/architecture.md b/docs/architecture.md
index 97510f2..18a18ec 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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()`
diff --git a/docs/data-layer.md b/docs/data-layer.md
index d8f4f7e..69744cf 100644
--- a/docs/data-layer.md
+++ b/docs/data-layer.md
@@ -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:
diff --git a/docs/features.md b/docs/features.md
index 4187f07..55ea9ee 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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`.
+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`; 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.
@@ -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)`.
@@ -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
diff --git a/docs/onboarding-flow.md b/docs/onboarding-flow.md
index 6d80b31..ab80097 100644
--- a/docs/onboarding-flow.md
+++ b/docs/onboarding-flow.md
@@ -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.
diff --git a/docs/routing.md b/docs/routing.md
index af84373..5bb97d8 100644
--- a/docs/routing.md
+++ b/docs/routing.md
@@ -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:
@@ -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(...))
@@ -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.
diff --git a/docs/state-management.md b/docs/state-management.md
index 41ac456..877a6f9 100644
--- a/docs/state-management.md
+++ b/docs/state-management.md
@@ -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-/archive/refs/heads/main.zip`) and folder names (`html--main`).
+### Startup progress (`lib/data/startup_stage.dart`)
+
+| Provider | Type | Purpose |
+| --- | --- | --- |
+| `startupStageProvider` | `NotifierProvider` | 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 |
diff --git a/docs/testing.md b/docs/testing.md
index 1524bbc..0e60af5 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -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
diff --git a/integration_test/background_interaction_test.dart b/integration_test/background_interaction_test.dart
index be34779..b5fe3a3 100644
--- a/integration_test/background_interaction_test.dart
+++ b/integration_test/background_interaction_test.dart
@@ -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 -
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 391a902..e784354 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -2,6 +2,8 @@
+ MinimumOSVersion
+ 15.0
CFBundleDevelopmentRegion
en
CFBundleExecutable
diff --git a/ios/Podfile b/ios/Podfile
index 16e3165..5f86caf 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -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'
@@ -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
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 823c7ec..26042d6 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1,28 +1,72 @@
PODS:
+ - device_info_plus (0.0.1):
+ - Flutter
- Flutter (1.0.0)
+ - integration_test (0.0.1):
+ - Flutter
- open_filex (0.0.2):
- Flutter
+ - package_info_plus (0.4.5):
+ - Flutter
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - share_plus (0.0.1):
+ - Flutter
+ - shared_preferences_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
+ - url_launcher_ios (0.0.1):
+ - Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
+ - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
+ - integration_test (from `.symlinks/plugins/integration_test/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
+ - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
+ - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+ - share_plus (from `.symlinks/plugins/share_plus/ios`)
+ - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+ - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
EXTERNAL SOURCES:
+ device_info_plus:
+ :path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
+ integration_test:
+ :path: ".symlinks/plugins/integration_test/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
+ package_info_plus:
+ :path: ".symlinks/plugins/package_info_plus/ios"
+ path_provider_foundation:
+ :path: ".symlinks/plugins/path_provider_foundation/darwin"
+ share_plus:
+ :path: ".symlinks/plugins/share_plus/ios"
+ shared_preferences_foundation:
+ :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+ url_launcher_ios:
+ :path: ".symlinks/plugins/url_launcher_ios/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
+ device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+ integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
+ package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
+ path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+ share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+ shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+ url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
-PODFILE CHECKSUM: ec1ad48fafee7989ae02b93f14e4d03d1f0e05bb
+PODFILE CHECKSUM: ec7febc55a194e213ced0d0eaf1ba6793163cfd3
-COCOAPODS: 1.16.2
+COCOAPODS: 1.17.0
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 1f9431b..62881ff 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -356,7 +356,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -442,7 +442,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -491,7 +491,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
new file mode 100644
index 0000000..a16059b
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.004",
+ "green" : "0.004",
+ "red" : "0.004"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
index 9da19ea..0bb5cee 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
index 9da19ea..4340449 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
index 9da19ea..7d26c0c 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
index 89c2725..ea7cfa7 100644
--- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -1,5 +1,11 @@
# Launch Screen Assets
-You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+The three `LaunchImage*.png` files are the app logo at 127pt (1x/2x/3x),
+generated from `../AppIcon.appiconset/4training logo_1024x1024.png` with the
+white corners made transparent (so it also works on the dark launch
+background, see `../LaunchBackground.colorset`). 127pt matches the logo size
+of the Android splash screen (`android/app/src/main/res/values/dimens.xml`).
-You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
+To regenerate after changing the logo, resize the transparent-corner source
+to 127, 254 and 381 px and replace the files here; `Contents.json` needs no
+change as long as the file names stay the same.
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
index f2e259c..180dbb6 100644
--- a/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -19,7 +19,7 @@
-
+
@@ -32,6 +32,9 @@
-
+
+
+
+
diff --git a/lib/data/bulk_language_download.dart b/lib/data/bulk_language_download.dart
index c168e4a..3b2e468 100644
--- a/lib/data/bulk_language_download.dart
+++ b/lib/data/bulk_language_download.dart
@@ -4,6 +4,26 @@ const kMaxParallelLanguageDownloads = 4;
typedef LanguageDownloadFn = Future Function(String languageCode);
+/// One completed language of a running bulk download
+class BulkDownloadProgress {
+ /// How many of [total] languages are finished (successfully or not)
+ final int completed;
+ final int total;
+
+ /// The language that just finished, and whether it succeeded
+ final String languageCode;
+ final bool success;
+
+ const BulkDownloadProgress({
+ required this.completed,
+ required this.total,
+ required this.languageCode,
+ required this.success,
+ });
+}
+
+typedef BulkDownloadProgressFn = void Function(BulkDownloadProgress progress);
+
class BulkDownloadResult {
final int successCount;
final int errorCount;
@@ -19,13 +39,19 @@ class BulkDownloadResult {
/// Download or update [languageCodes] keeping at most [maxConcurrent] in
/// flight at any time. Uses a worker pool: as soon as one download finishes a
/// new one is started, so a slow language never blocks idle slots.
+///
+/// [onProgress] is called once per language as soon as it has finished,
+/// whether it succeeded or not, so a caller can show "n of m" while the
+/// batch is still running.
Future downloadLanguagesInParallel(
Iterable languageCodes, {
required LanguageDownloadFn download,
int maxConcurrent = kMaxParallelLanguageDownloads,
+ BulkDownloadProgressFn? onProgress,
}) async {
assert(maxConcurrent > 0);
final queue = Queue.of(languageCodes);
+ final total = queue.length;
var successCount = 0;
var errorCount = 0;
var lastSuccessCode = '';
@@ -48,6 +74,12 @@ Future downloadLanguagesInParallel(
} else {
errorCount++;
}
+ onProgress?.call(BulkDownloadProgress(
+ completed: successCount + errorCount,
+ total: total,
+ languageCode: code,
+ success: success,
+ ));
}
}
diff --git a/lib/data/startup_stage.dart b/lib/data/startup_stage.dart
new file mode 100644
index 0000000..1ceae7e
--- /dev/null
+++ b/lib/data/startup_stage.dart
@@ -0,0 +1,52 @@
+import 'package:app4training/l10n/l10n.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// Which part of its work `StartupPage.init()` is currently in.
+///
+/// Only init() itself reports a stage, right before it starts the work of
+/// that stage - so the caption under the startup spinner never claims
+/// progress that hasn't been made. See docs/routing.md ("Why the loading
+/// is staged") for what each stage does.
+enum StartupStage {
+ /// init() hasn't reported anything yet
+ starting,
+
+ /// Step 1: one stat() per language to find out which ones are on the device
+ checkingLanguages,
+
+ /// Step 2: fully loading the app language (the menu needs it)
+ loadingAppLanguage,
+
+ /// Still step 2: the app language is in, the language of the worksheet
+ /// we resume isn't yet
+ loadingRecentPage;
+
+ static String getLocalized(BuildContext context, StartupStage stage) {
+ switch (stage) {
+ case StartupStage.starting:
+ return context.l10n.loading;
+ case StartupStage.checkingLanguages:
+ return context.l10n.startupCheckingLanguages;
+ case StartupStage.loadingAppLanguage:
+ return context.l10n.startupLoadingAppLanguage;
+ case StartupStage.loadingRecentPage:
+ return context.l10n.startupLoadingRecentPage;
+ }
+ }
+}
+
+class StartupStageNotifier extends Notifier {
+ @override
+ StartupStage build() => StartupStage.starting;
+
+ /// Called by StartupPage.init() whenever it moves on to the next stage
+ void report(StartupStage stage) {
+ state = stage;
+ }
+}
+
+/// The stage StartupPage.init() is in - drives the caption of its spinner
+final startupStageProvider =
+ NotifierProvider(
+ StartupStageNotifier.new);
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 7a1164d..823bfa9 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -883,6 +883,42 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!'**
String get pdfNotAvailable;
+
+ /// Caption under the spinner on the startup screen
+ ///
+ /// In en, this message translates to:
+ /// **'Loading'**
+ String get loading;
+
+ /// Caption under the spinner while a worksheet is being loaded
+ ///
+ /// In en, this message translates to:
+ /// **'Loading content...'**
+ String get loadingContent;
+
+ /// Caption under the startup spinner while the app finds out which languages are on the device
+ ///
+ /// In en, this message translates to:
+ /// **'Checking downloaded languages'**
+ String get startupCheckingLanguages;
+
+ /// Caption under the startup spinner while the app language is being loaded
+ ///
+ /// In en, this message translates to:
+ /// **'Loading app language'**
+ String get startupLoadingAppLanguage;
+
+ /// Caption under the startup spinner while the language of the worksheet the user last read is being loaded
+ ///
+ /// In en, this message translates to:
+ /// **'Loading your last worksheet'**
+ String get startupLoadingRecentPage;
+
+ /// Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34'
+ ///
+ /// In en, this message translates to:
+ /// **'{completed} of {total}'**
+ String downloadProgress(int completed, int total);
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 4b478f1..c72acc6 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -509,4 +509,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pdfNotAvailable =>
'Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!';
+
+ @override
+ String get loading => 'Wird geladen';
+
+ @override
+ String get loadingContent => 'Inhalt wird geladen...';
+
+ @override
+ String get startupCheckingLanguages => 'Überprüfe heruntergeladene Sprachen';
+
+ @override
+ String get startupLoadingAppLanguage => 'Lade App-Sprache';
+
+ @override
+ String get startupLoadingRecentPage => 'Lade dein letztes Arbeitsblatt';
+
+ @override
+ String downloadProgress(int completed, int total) {
+ return '$completed von $total';
+ }
}
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index c50e29d..7362d02 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -508,4 +508,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pdfNotAvailable =>
'Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!';
+
+ @override
+ String get loading => 'Loading';
+
+ @override
+ String get loadingContent => 'Loading content...';
+
+ @override
+ String get startupCheckingLanguages => 'Checking downloaded languages';
+
+ @override
+ String get startupLoadingAppLanguage => 'Loading app language';
+
+ @override
+ String get startupLoadingRecentPage => 'Loading your last worksheet';
+
+ @override
+ String downloadProgress(int completed, int total) {
+ return '$completed of $total';
+ }
}
diff --git a/lib/l10n/locales/app_de.arb b/lib/l10n/locales/app_de.arb
index 2dae244..8c1231e 100644
--- a/lib/l10n/locales/app_de.arb
+++ b/lib/l10n/locales/app_de.arb
@@ -339,5 +339,37 @@
"openPdf": "PDF öffnen",
"openInBrowser": "Im Browser öffnen",
"shareLink": "Link teilen",
- "pdfNotAvailable": "Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!"
+ "pdfNotAvailable": "Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!",
+ "loading": "Wird geladen",
+ "@loading": {
+ "description": "Caption under the spinner on the startup screen"
+ },
+ "loadingContent": "Inhalt wird geladen...",
+ "@loadingContent": {
+ "description": "Caption under the spinner while a worksheet is being loaded"
+ },
+ "startupCheckingLanguages": "Überprüfe heruntergeladene Sprachen",
+ "@startupCheckingLanguages": {
+ "description": "Caption under the startup spinner while the app finds out which languages are on the device"
+ },
+ "startupLoadingAppLanguage": "Lade App-Sprache",
+ "@startupLoadingAppLanguage": {
+ "description": "Caption under the startup spinner while the app language is being loaded"
+ },
+ "startupLoadingRecentPage": "Lade dein letztes Arbeitsblatt",
+ "@startupLoadingRecentPage": {
+ "description": "Caption under the startup spinner while the language of the worksheet the user last read is being loaded"
+ },
+ "downloadProgress": "{completed} von {total}",
+ "@downloadProgress": {
+ "description": "Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34'",
+ "placeholders": {
+ "completed": {
+ "type": "int"
+ },
+ "total": {
+ "type": "int"
+ }
+ }
+ }
}
diff --git a/lib/l10n/locales/app_en.arb b/lib/l10n/locales/app_en.arb
index e4fa8ad..996b648 100644
--- a/lib/l10n/locales/app_en.arb
+++ b/lib/l10n/locales/app_en.arb
@@ -339,5 +339,37 @@
"openPdf": "Open PDF",
"openInBrowser": "Open in browser",
"shareLink": "Share link",
- "pdfNotAvailable": "Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!"
-}
\ No newline at end of file
+ "pdfNotAvailable": "Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!",
+ "loading": "Loading",
+ "@loading": {
+ "description": "Caption under the spinner on the startup screen"
+ },
+ "loadingContent": "Loading content...",
+ "@loadingContent": {
+ "description": "Caption under the spinner while a worksheet is being loaded"
+ },
+ "startupCheckingLanguages": "Checking downloaded languages",
+ "@startupCheckingLanguages": {
+ "description": "Caption under the startup spinner while the app finds out which languages are on the device"
+ },
+ "startupLoadingAppLanguage": "Loading app language",
+ "@startupLoadingAppLanguage": {
+ "description": "Caption under the startup spinner while the app language is being loaded"
+ },
+ "startupLoadingRecentPage": "Loading your last worksheet",
+ "@startupLoadingRecentPage": {
+ "description": "Caption under the startup spinner while the language of the worksheet the user last read is being loaded"
+ },
+ "downloadProgress": "{completed} of {total}",
+ "@downloadProgress": {
+ "description": "Caption next to the progress ring while all languages are being downloaded, e.g. '3 of 34'",
+ "placeholders": {
+ "completed": {
+ "type": "int"
+ },
+ "total": {
+ "type": "int"
+ }
+ }
+ }
+}
diff --git a/lib/routes/startup_page.dart b/lib/routes/startup_page.dart
index e78a2aa..8823e15 100644
--- a/lib/routes/startup_page.dart
+++ b/lib/routes/startup_page.dart
@@ -3,6 +3,7 @@ import 'dart:collection';
import 'package:app4training/background/background_scheduler.dart';
import 'package:app4training/data/app_language.dart';
+import 'package:app4training/data/startup_stage.dart';
import 'package:app4training/features/perf/perf_logger.dart';
import 'package:app4training/routes/error_page.dart';
import 'package:flutter/foundation.dart';
@@ -20,10 +21,34 @@ const int _maxParallelLanguageLoads = 3;
/// while we're initializing the data in the background.
/// In case the user is new: Lead him to the onboarding / resume onboarding
/// in case onboarding got interrupted in between
-class StartupPage extends ConsumerWidget {
+class StartupPage extends ConsumerStatefulWidget {
final Function? initFunction; // For testing (is there a better solution?)
const StartupPage({super.key, this.initFunction});
+ @override
+ ConsumerState createState() => _StartupPageState();
+}
+
+class _StartupPageState extends ConsumerState {
+ /// Completes once we have pushed the next route (or with init()'s error)
+ late final Future _navigation;
+
+ @override
+ void initState() {
+ super.initState();
+ // init() reports where it is through startupStageProvider, and a provider
+ // must not be modified while the widget tree is building. So the first
+ // frame (spinner with its initial caption) goes out before init() starts.
+ _navigation = Future.microtask(() {
+ final Future initResult =
+ (widget.initFunction != null) ? widget.initFunction!() : init();
+ return initResult;
+ }).then((String navigateTo) {
+ if (!mounted) return;
+ unawaited(Navigator.pushReplacementNamed(context, navigateTo));
+ });
+ }
+
/// Initialize and return the route where to continue now
///
/// This runs while the user is staring at the loading spinner, so it does
@@ -38,13 +63,15 @@ class StartupPage extends ConsumerWidget {
/// background: nothing on the first screen depends on them, and the
/// widgets that do (language selection, drawer translation icons)
/// rebuild by themselves once a language arrives.
- Future init(WidgetRef ref) async {
+ Future init() async {
if (ref.read(sharedPrefsProvider).getString('appLanguage') == null) {
// First app usage: Let's start onboarding
return '/onboarding/1';
}
+ final StartupStageNotifier stage = ref.read(startupStageProvider.notifier);
// Step 1: Which languages are on the device?
+ stage.report(StartupStage.checkingLanguages);
final List availableLanguages =
ref.read(availableLanguagesProvider);
await PerfLogger.span(
@@ -78,14 +105,26 @@ class StartupPage extends ConsumerWidget {
// Step 2: Load what the first screen needs - the app language for the menu
// and, if we resume a recent page, the language that page is written in.
+ stage.report(StartupStage.loadingAppLanguage);
final Set neededNow = {appLangCode, if (resumeRecentPage) lang};
- await PerfLogger.span(
- 'startup.initNeededNow',
- () => Future.wait([
- for (String languageCode in neededNow)
- ref.read(languageProvider(languageCode).notifier).init()
- // TODO: look at return value and show snackBar on error
- ]),
+ final Future appLanguageLoaded =
+ ref.read(languageProvider(appLangCode).notifier).init();
+ final List> loads = [appLanguageLoaded];
+ if (neededNow.length > 1) {
+ // Both load in parallel. Once the app language is in, all we're still
+ // waiting for is the worksheet's language - but only say so if that
+ // one is in fact still loading.
+ bool recentLanguageLoaded = false;
+ loads.add(ref
+ .read(languageProvider(lang).notifier)
+ .init()
+ .whenComplete(() => recentLanguageLoaded = true));
+ unawaited(appLanguageLoaded.then((_) {
+ if (!recentLanguageLoaded) stage.report(StartupStage.loadingRecentPage);
+ }, onError: (_) {})); // errors surface through the Future.wait below
+ }
+ // TODO: look at the return values and show snackBar on error
+ await PerfLogger.span('startup.initNeededNow', () => Future.wait(loads),
data: () => {'languages': neededNow.length});
// Step 3: Everything else may take its time. We hand over the controllers
@@ -130,41 +169,32 @@ class StartupPage extends ConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
- // When we're finished with loading: Go to the recently opened page
- Future initResult =
- ((initFunction != null) ? initFunction!() : init(ref));
+ Widget build(BuildContext context) {
return FutureBuilder(
- future: initResult.then(
- (String navigateTo) => {
- if (context.mounted)
- {Navigator.pushReplacementNamed(context, navigateTo)},
- },
- ),
- initialData: "Loading",
- builder: (BuildContext context, AsyncSnapshot snapshot) {
+ future: _navigation,
+ builder: (BuildContext context, AsyncSnapshot snapshot) {
if (kDebugMode) debugPrint(snapshot.connectionState.toString());
- switch (snapshot.connectionState) {
- case ConnectionState.none:
- case ConnectionState.waiting:
- case ConnectionState.active:
- return loadingAnimation('Loading');
- case ConnectionState.done:
- if (kDebugMode) {
- debugPrint('Done, hasData: ${snapshot.hasData},'
- ' Error: ${snapshot.hasError}');
- }
- if (snapshot.hasError) {
- // TODO do something more helpful for the user ("try again...")
- return ErrorPage(snapshot.error.toString());
- } else {
- // This is actually never called because as soon
- // as we push the new route he's out of here...
- return loadingAnimation('Redirecting ...');
- }
+ if (snapshot.hasError) {
+ // TODO do something more helpful for the user ("try again...")
+ return ErrorPage(snapshot.error.toString());
}
+ // Once init() is done we have already pushed the next route; this
+ // page just stays as it is while that route animates in.
+ return const LoadingAnimation(caption: _StartupCaption());
},
);
}
}
+
+/// Names the stage init() is in. Watching the stage here, and only here,
+/// keeps a stage change from rebuilding anything but this text.
+class _StartupCaption extends ConsumerWidget {
+ const _StartupCaption();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Text(
+ StartupStage.getLocalized(context, ref.watch(startupStageProvider)));
+ }
+}
diff --git a/lib/routes/view_page.dart b/lib/routes/view_page.dart
index 8801e6b..45ebe2a 100644
--- a/lib/routes/view_page.dart
+++ b/lib/routes/view_page.dart
@@ -61,7 +61,7 @@ class ViewPage extends ConsumerWidget {
case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
- return loadingAnimation("Loading content...");
+ return loadingAnimation(context.l10n.loadingContent);
case ConnectionState.done:
if (kDebugMode) {
debugPrint('Done, hasData: ${snapshot.hasData},'
diff --git a/lib/widgets/download_language_button.dart b/lib/widgets/download_language_button.dart
index c0810ac..7af050a 100644
--- a/lib/widgets/download_language_button.dart
+++ b/lib/widgets/download_language_button.dart
@@ -70,8 +70,10 @@ class _DownloadLanguageButtonState
}
}
-/// Button to download all languages. Shows a CircularProgressIndicator while
-/// the download is in progress (that's why the class is stateful)
+/// Button to download all languages. While the batch runs it turns into a
+/// determinate progress ring with an "n of m" caption (that's why the class
+/// is stateful): 34 languages on a slow connection can take minutes, and a
+/// bare spinner gives no hint whether anything is happening.
class DownloadAllLanguagesButton extends ConsumerStatefulWidget {
const DownloadAllLanguagesButton({super.key});
@@ -84,17 +86,30 @@ class _DownloadAllLanguagesButtonState
extends ConsumerState {
bool _isLoading = false;
+ /// How many of [_total] languages of the running batch are finished
+ int _completed = 0;
+ int _total = 0;
+
@override
Widget build(BuildContext context) {
- return _isLoading
- ? const Center(
- child: SizedBox(
- height: 24, width: 24, child: CircularProgressIndicator()))
- : IconButton(
+ if (_isLoading) {
+ return Row(mainAxisSize: MainAxisSize.min, children: [
+ SizedBox(
+ height: 24,
+ width: 24,
+ child: CircularProgressIndicator(
+ value: _total == 0 ? null : _completed / _total)),
+ const SizedBox(width: 4),
+ Text(context.l10n.downloadProgress(_completed, _total),
+ style: Theme.of(context).textTheme.bodyMedium),
+ ]);
+ }
+ // Same footprint as the other header buttons while idle - the table cell
+ // deliberately has no fixed width so that the row above can grow
+ return SizedBox(
+ width: 32,
+ child: IconButton(
onPressed: () async {
- setState(() {
- _isLoading = true;
- });
// Get l10n now as we can't access context after async gap later
final l10n = context.l10n;
final codesToDownload = [
@@ -102,10 +117,19 @@ class _DownloadAllLanguagesButtonState
if (!ref.read(languageProvider(languageCode)).downloaded)
languageCode,
];
+ setState(() {
+ _isLoading = true;
+ _completed = 0;
+ _total = codesToDownload.length;
+ });
final result = await downloadLanguagesInParallel(
codesToDownload,
download: (code) =>
ref.read(languageProvider(code).notifier).download(),
+ onProgress: (progress) {
+ if (!mounted) return;
+ setState(() => _completed = progress.completed);
+ },
);
if (result.successCount > 0) {
// Show info message in snackbar
@@ -128,6 +152,6 @@ class _DownloadAllLanguagesButtonState
});
},
icon: const Icon(Icons.download),
- padding: EdgeInsets.zero);
+ padding: EdgeInsets.zero));
}
}
diff --git a/lib/widgets/languages_table.dart b/lib/widgets/languages_table.dart
index 6db05d6..a0fc4b3 100644
--- a/lib/widgets/languages_table.dart
+++ b/lib/widgets/languages_table.dart
@@ -96,10 +96,10 @@ class LanguagesTable extends ConsumerWidget {
style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(
height: 32, width: 32, child: UpdateAllLanguagesButton()),
+ // No fixed width: while a bulk download runs this shows a
+ // progress ring with an "n of m" caption next to it
const SizedBox(
- height: 32,
- width: 32,
- child: DownloadAllLanguagesButton()),
+ height: 32, child: DownloadAllLanguagesButton()),
const SizedBox(
height: 32, width: 32, child: DeleteAllLanguagesButton()),
])
diff --git a/lib/widgets/loading_animation.dart b/lib/widgets/loading_animation.dart
index c76f013..ad5efc3 100644
--- a/lib/widgets/loading_animation.dart
+++ b/lib/widgets/loading_animation.dart
@@ -1,17 +1,30 @@
import 'package:flutter/material.dart';
-Widget loadingAnimation(String msg) {
- return Scaffold(
- body: Center(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- const Spacer(flex: 10),
- const Expanded(child: CircularProgressIndicator()),
- const Spacer(),
- Expanded(child: Text(msg)),
- const Spacer(flex: 10)
- ],
- ),
- ));
+/// Full-screen spinner with a [caption] underneath it.
+///
+/// The caption is a widget so that it can rebuild on its own (e.g. a
+/// Consumer watching a provider) without touching the spinner.
+class LoadingAnimation extends StatelessWidget {
+ final Widget caption;
+ const LoadingAnimation({required this.caption, super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ const Spacer(flex: 10),
+ const Expanded(child: CircularProgressIndicator()),
+ const Spacer(),
+ Expanded(child: caption),
+ const Spacer(flex: 10)
+ ],
+ ),
+ ));
+ }
}
+
+/// [LoadingAnimation] with a fixed text as its caption
+Widget loadingAnimation(String msg) => LoadingAnimation(caption: Text(msg));
diff --git a/test/bulk_language_download_test.dart b/test/bulk_language_download_test.dart
index 76abc70..a83ab4e 100644
--- a/test/bulk_language_download_test.dart
+++ b/test/bulk_language_download_test.dart
@@ -105,6 +105,40 @@ void main() {
expect(result.successCount, 4);
});
+ test('reports each language as it completes, failures included', () async {
+ final reports = [];
+ final result = await downloadLanguagesInParallel(
+ ['de', 'en', 'fr'],
+ maxConcurrent: 1, // one at a time, so the order is deterministic
+ download: (code) async => code != 'en',
+ onProgress: reports.add,
+ );
+
+ expect(
+ [for (final r in reports) (r.completed, r.total, r.languageCode, r.success)],
+ [(1, 3, 'de', true), (2, 3, 'en', false), (3, 3, 'fr', true)],
+ );
+ expect(result.successCount, 2);
+ expect(result.errorCount, 1);
+ });
+
+ test('a throwing download advances the counter instead of stalling it',
+ () async {
+ final reports = [];
+ await downloadLanguagesInParallel(
+ ['de', 'en', 'fr'],
+ maxConcurrent: 1,
+ download: (code) async {
+ if (code == 'en') throw Exception('boom');
+ return true;
+ },
+ onProgress: reports.add,
+ );
+
+ expect([for (final r in reports) r.completed], [1, 2, 3]);
+ expect([for (final r in reports) r.success], [true, false, true]);
+ });
+
test('uses default maxConcurrent constant', () async {
expect(kMaxParallelLanguageDownloads, 4);
});
diff --git a/test/download_language_button_test.dart b/test/download_language_button_test.dart
index d6c357d..d048848 100644
--- a/test/download_language_button_test.dart
+++ b/test/download_language_button_test.dart
@@ -1,8 +1,11 @@
+import 'dart:async';
+
import 'package:app4training/background/background_test.dart';
import 'package:app4training/data/app_language.dart';
import 'package:app4training/data/globals.dart';
import 'package:app4training/data/languages.dart';
import 'package:app4training/l10n/generated/app_localizations.dart';
+import 'package:app4training/l10n/generated/app_localizations_de.dart';
import 'package:app4training/widgets/download_language_button.dart';
import 'package:file/memory.dart';
import 'package:flutter/material.dart';
@@ -12,6 +15,18 @@ import 'package:flutter_test/flutter_test.dart';
import 'app_language_test.dart';
import 'languages_test.dart';
+/// Lets the test decide when the download of each language finishes
+class GatedDownloadLanguageController extends TestLanguageController {
+ GatedDownloadLanguageController(this.gates) : super(downloadedLanguages: []);
+ final Map> gates;
+
+ @override
+ Future download() async {
+ await gates[languageCode]!.future;
+ return super.download();
+ }
+}
+
class TestDownloadLanguageButton extends ConsumerWidget {
final String languageCode;
final bool highlight;
@@ -81,6 +96,60 @@ void main() {
expect(find.text('34 Sprachen heruntergeladen'), findsOneWidget);
});
+ testWidgets('DownloadAllLanguagesButton shows how far the batch is',
+ (WidgetTester tester) async {
+ final gates = {
+ for (final code in ['de', 'en', 'fr']) code: Completer()
+ };
+ final ref = ProviderContainer(overrides: [
+ appLanguageProvider.overrideWith(() => TestAppLanguage('de')),
+ availableLanguagesProvider.overrideWithValue(['de', 'en', 'fr']),
+ languageProvider
+ .overrideWith2((langCode) => GatedDownloadLanguageController(gates)),
+ ]);
+ final l10n = AppLocalizationsDe();
+
+ await tester.pumpWidget(UncontrolledProviderScope(
+ container: ref,
+ child: MaterialApp(
+ locale: ref.read(appLanguageProvider).locale,
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider),
+ home: const Scaffold(body: DownloadAllLanguagesButton()))));
+
+ CircularProgressIndicator indicator() => tester
+ .widget(
+ find.byType(CircularProgressIndicator));
+
+ // Let a released download finish (first pump) and draw the caption that
+ // its setState() asked for (second pump)
+ Future finish(String code) async {
+ gates[code]!.complete();
+ await tester.pump();
+ await tester.pump();
+ }
+
+ await tester.tap(find.byType(DownloadAllLanguagesButton));
+ await tester.pump();
+ expect(find.text(l10n.downloadProgress(0, 3)), findsOneWidget);
+ expect(indicator().value, 0);
+
+ await finish('de');
+ expect(find.text(l10n.downloadProgress(1, 3)), findsOneWidget);
+ expect(indicator().value, closeTo(1 / 3, 0.001));
+
+ await finish('en');
+ expect(find.text(l10n.downloadProgress(2, 3)), findsOneWidget);
+ expect(indicator().value, closeTo(2 / 3, 0.001));
+
+ // Once the last one is in, the button is back and the summary shows
+ await finish('fr');
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ expect(find.byIcon(Icons.download), findsOneWidget);
+ expect(find.text('3 Sprachen heruntergeladen'), findsOneWidget);
+ });
+
testWidgets('Test highlighted DownloadLanguageButton',
(WidgetTester tester) async {
final ref = ProviderContainer(overrides: [
diff --git a/test/download_languages_page_test.dart b/test/download_languages_page_test.dart
index c4e379e..3d821ae 100644
--- a/test/download_languages_page_test.dart
+++ b/test/download_languages_page_test.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:app4training/data/app_language.dart';
import 'package:app4training/data/globals.dart';
import 'package:app4training/data/languages.dart';
@@ -7,6 +9,7 @@ import 'package:app4training/l10n/generated/app_localizations_de.dart';
import 'package:app4training/l10n/generated/app_localizations_en.dart';
import 'package:app4training/routes/onboarding/download_languages_page.dart';
import 'package:app4training/routes/routes.dart';
+import 'package:app4training/widgets/download_language_button.dart';
import 'package:app4training/widgets/languages_table.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -15,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_language_test.dart';
+import 'download_language_button_test.dart';
import 'languages_test.dart';
import 'routes_test.dart';
import 'updates_test.dart';
@@ -33,6 +37,7 @@ class TestDownloadLanguagesPage extends ConsumerWidget {
supportedLocales: AppLocalizations.supportedLocales,
onGenerateRoute: (settings) => generateRoutes(settings),
navigatorObservers: [navigatorObserver],
+ scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider),
home: const DownloadLanguagesPage(),
);
}
@@ -127,6 +132,53 @@ void main() {
expect(listEquals(testObserver.replacedRoutes, ['/onboarding/1']), isTrue);
});
+ testWidgets('Download-all progress is shown on the onboarding screen', (
+ WidgetTester tester,
+ ) async {
+ SharedPreferences.setMockInitialValues({});
+ final prefs = await SharedPreferences.getInstance();
+ final gates = {
+ for (final code in ['de', 'en', 'fr']) code: Completer()
+ };
+ final ref = ProviderContainer(
+ overrides: [
+ appLanguageProvider.overrideWith(() => TestAppLanguage('en')),
+ availableLanguagesProvider.overrideWithValue(['de', 'en', 'fr']),
+ languageProvider.overrideWith2(
+ (languageCode) => GatedDownloadLanguageController(gates),
+ ),
+ languageStatusProvider.overrideWith2(
+ (languageCode) => TestLanguageStatus(),
+ ),
+ sharedPrefsProvider.overrideWith((ref) => prefs),
+ ],
+ );
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: ref,
+ child: TestDownloadLanguagesPage(TestObserver()),
+ ),
+ );
+ final l10n = AppLocalizationsEn();
+
+ await tester.tap(find.byType(DownloadAllLanguagesButton));
+ await tester.pump();
+ expect(find.text(l10n.downloadProgress(0, 3)), findsOneWidget);
+
+ // One pump lets the download finish, the next draws the new caption
+ gates['de']!.complete();
+ await tester.pump();
+ await tester.pump();
+ expect(find.text(l10n.downloadProgress(1, 3)), findsOneWidget);
+
+ gates['en']!.complete();
+ gates['fr']!.complete();
+ await tester.pump();
+ await tester.pump();
+ expect(find.text(l10n.downloadedNLanguages(3)), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsNothing);
+ });
+
testWidgets('Test skipping third onboarding step', (
WidgetTester tester,
) async {
diff --git a/test/startup_page_test.dart b/test/startup_page_test.dart
index cc5ba78..e0a8be0 100644
--- a/test/startup_page_test.dart
+++ b/test/startup_page_test.dart
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:app4training/background/background_scheduler.dart';
import 'package:app4training/data/globals.dart';
import 'package:app4training/data/languages.dart';
+import 'package:app4training/data/startup_stage.dart';
import 'package:app4training/l10n/generated/app_localizations.dart';
+import 'package:app4training/l10n/generated/app_localizations_en.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -17,18 +19,27 @@ import 'languages_test.dart';
/// test decide when each of them finishes loading
class GatedLanguageController extends TestLanguageController {
GatedLanguageController(this.initCalls, this.gates,
- {super.downloadedLanguages})
+ {super.downloadedLanguages, this.lazyInitGate})
: super(initReturns: true);
final List initCalls;
final Map> gates;
+ /// If set, every lazyInit() waits for this before answering
+ final Completer? lazyInitGate;
+
@override
Future init() async {
initCalls.add(languageCode);
await gates[languageCode]!.future;
return super.init();
}
+
+ @override
+ Future lazyInit() async {
+ if (lazyInitGate != null) await lazyInitGate!.future;
+ return super.lazyInit();
+ }
}
void main() {
@@ -68,14 +79,17 @@ void main() {
UncontrolledProviderScope(
container: ref,
child: MaterialApp(
+ locale: const Locale('de'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
home: const StartupPage(),
onGenerateRoute: generateRoutes,
),
),
);
- // First there should be the loading animation
+ // First there should be the loading animation, in the app language
expect(find.byType(CircularProgressIndicator), findsOneWidget);
- expect(find.text('Loading'), findsOneWidget);
+ expect(find.text('Wird geladen'), findsOneWidget);
await tester.pump();
expect(route, equals('/home')); // Now we went on to this route
expect(ref.read(backgroundSchedulerProvider), true);
@@ -102,6 +116,9 @@ void main() {
UncontrolledProviderScope(
container: ref,
child: MaterialApp(
+ locale: const Locale('de'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
home: const StartupPage(),
onGenerateRoute: generateRoutes,
),
@@ -168,6 +185,9 @@ void main() {
UncontrolledProviderScope(
container: ref,
child: MaterialApp(
+ locale: const Locale('de'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
home: const StartupPage(),
onGenerateRoute: generateRoutes,
),
@@ -194,6 +214,183 @@ void main() {
expect(initCalls.length, 3);
});
+ testWidgets('The caption names the stage init() is in', (
+ WidgetTester tester,
+ ) async {
+ SharedPreferences.setMockInitialValues({
+ 'appLanguage': 'en',
+ 'checkFrequency': 'weekly',
+ 'recentPage': 'Healing',
+ 'recentLang': 'de',
+ });
+ final prefs = await SharedPreferences.getInstance();
+ route = null;
+ final l10n = AppLocalizationsEn();
+ final initCalls = [];
+ final lazyInitGate = Completer();
+ final gates = {
+ for (final languageCode in ['en', 'de'])
+ languageCode: Completer()
+ };
+ final ref = ProviderContainer(
+ overrides: [
+ languageProvider.overrideWith2(
+ (languageCode) => GatedLanguageController(
+ initCalls,
+ gates,
+ downloadedLanguages: ['en', 'de'],
+ lazyInitGate: lazyInitGate,
+ ),
+ ),
+ backgroundSchedulerProvider.overrideWith(
+ () => TestBackgroundScheduler(),
+ ),
+ sharedPrefsProvider.overrideWithValue(prefs),
+ ],
+ );
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: ref,
+ child: MaterialApp(
+ locale: const Locale('en'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: const StartupPage(),
+ onGenerateRoute: generateRoutes,
+ ),
+ ),
+ );
+
+ // Stage 1: finding out which languages are on the device
+ await tester.pump();
+ expect(find.text(l10n.startupCheckingLanguages), findsOneWidget);
+ expect(initCalls, isEmpty);
+
+ // Stage 2: loading the app language
+ lazyInitGate.complete();
+ await tester.pump();
+ expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget);
+ expect(initCalls.toSet(), equals({'en', 'de'}));
+ expect(route, isNull);
+
+ // Stage 3: the app language is in, the worksheet's language isn't yet
+ gates['en']!.complete();
+ await tester.pump();
+ expect(find.text(l10n.startupLoadingRecentPage), findsOneWidget);
+ expect(route, isNull);
+
+ gates['de']!.complete();
+ await tester.pump();
+ expect(route, equals('/view/Healing/de'));
+ });
+
+ testWidgets('The recent-page stage is not claimed once nothing is left', (
+ WidgetTester tester,
+ ) async {
+ SharedPreferences.setMockInitialValues({
+ 'appLanguage': 'en',
+ 'checkFrequency': 'weekly',
+ 'recentPage': 'Healing',
+ 'recentLang': 'de',
+ });
+ final prefs = await SharedPreferences.getInstance();
+ route = null;
+ final l10n = AppLocalizationsEn();
+ final gates = {
+ for (final languageCode in ['en', 'de'])
+ languageCode: Completer()
+ };
+ final ref = ProviderContainer(
+ overrides: [
+ languageProvider.overrideWith2(
+ (languageCode) => GatedLanguageController(
+ [],
+ gates,
+ downloadedLanguages: ['en', 'de'],
+ ),
+ ),
+ backgroundSchedulerProvider.overrideWith(
+ () => TestBackgroundScheduler(),
+ ),
+ sharedPrefsProvider.overrideWithValue(prefs),
+ ],
+ );
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: ref,
+ child: MaterialApp(
+ locale: const Locale('en'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: const StartupPage(),
+ onGenerateRoute: generateRoutes,
+ ),
+ ),
+ );
+ await tester.pump();
+ expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget);
+
+ // The worksheet's language lands first: we're still on the app language
+ gates['de']!.complete();
+ await tester.pump();
+ expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget);
+ expect(route, isNull);
+
+ // ... and when that arrives there is nothing left to wait for
+ gates['en']!.complete();
+ await tester.pump();
+ expect(route, equals('/view/Healing/de'));
+ expect(find.text(l10n.startupLoadingRecentPage), findsNothing);
+ });
+
+ testWidgets('Reporting a stage changes the caption but does not restart init',
+ (WidgetTester tester) async {
+ SharedPreferences.setMockInitialValues({'appLanguage': 'de'});
+ final prefs = await SharedPreferences.getInstance();
+ route = null;
+ final l10n = AppLocalizationsEn();
+ int initCalls = 0;
+ final Completer gate = Completer();
+ Future countingInit() {
+ initCalls++;
+ return gate.future;
+ }
+
+ final ref = ProviderContainer(
+ overrides: [sharedPrefsProvider.overrideWithValue(prefs)],
+ );
+ await tester.pumpWidget(
+ UncontrolledProviderScope(
+ container: ref,
+ child: MaterialApp(
+ locale: const Locale('en'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: StartupPage(initFunction: countingInit),
+ onGenerateRoute: generateRoutes,
+ ),
+ ),
+ );
+ await tester.pump();
+ expect(find.text(l10n.loading), findsOneWidget);
+ expect(initCalls, 1);
+
+ ref
+ .read(startupStageProvider.notifier)
+ .report(StartupStage.loadingAppLanguage);
+ await tester.pump();
+ expect(find.text(l10n.startupLoadingAppLanguage), findsOneWidget);
+ expect(find.text(l10n.loading), findsNothing);
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+ expect(initCalls, 1);
+ expect(route, isNull);
+
+ gate.complete('/home');
+ await tester.pump();
+ expect(route, equals('/home'));
+ expect(initCalls, 1);
+ });
+
testWidgets('Test failing initFunction', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({'appLanguage': 'de'});
final prefs = await SharedPreferences.getInstance();
@@ -248,6 +445,9 @@ void main() {
UncontrolledProviderScope(
container: ref,
child: MaterialApp(
+ locale: const Locale('de'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
home: const StartupPage(),
onGenerateRoute: generateRoutes,
),
@@ -282,6 +482,9 @@ void main() {
UncontrolledProviderScope(
container: ref,
child: MaterialApp(
+ locale: const Locale('de'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
home: const StartupPage(),
onGenerateRoute: generateRoutes,
),
diff --git a/test/view_page_test.dart b/test/view_page_test.dart
index 5faa959..c4a7a48 100644
--- a/test/view_page_test.dart
+++ b/test/view_page_test.dart
@@ -43,7 +43,7 @@ void main() {
expect(prefs.getString('recentLang'), isNull);
// First there should be the loading animation
expect(find.byType(CircularProgressIndicator), findsOneWidget);
- expect(find.text('Loading content...'), findsOneWidget);
+ expect(find.text('Inhalt wird geladen...'), findsOneWidget);
// In Riverpod v3, FutureProvider needs additional frames to settle
await tester.pumpAndSettle();