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();