diff --git a/.fvmrc b/.fvmrc index 049b27d..16a96ef 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.41.6" + "flutter": "3.47.2" } diff --git a/ONBOARDING.md b/ONBOARDING.md index 57f3478..ed0757d 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -115,7 +115,7 @@ The detailed docs live in `docs/`. Read them in this order if you're new: 1. **[docs/content-rendering.md](docs/content-rendering.md)** — HTML rendering, the `sanitize()` workarounds, `flutter_html_table` filter in `main.dart`. 1. **[docs/onboarding-flow.md](docs/onboarding-flow.md)** — the three onboarding pages and their persistence side-effects. 1. **[docs/features.md](docs/features.md)** — pages, widgets, drawer, share menu, language switcher. -1. **[docs/background-tasks.md](docs/background-tasks.md)** — `workmanager` isolate, the result-sync trick, why parts are commented out. +1. **[docs/background-tasks.md](docs/background-tasks.md)** — `workmanager` isolate, the two-phase check/download flow, the settings matrix, and the result-sync trick. 1. **[docs/localization.md](docs/localization.md)** — `.arb` workflow, `context.l10n` extension, in-app language vs. content language. 1. **[docs/testing.md](docs/testing.md)** — unit/widget tests, fixtures, integration test, CI. 1. **[docs/conventions.md](docs/conventions.md)** — coding conventions, lints, do/don't. @@ -131,7 +131,7 @@ These are the load-bearing oddities — read these before changing code in their - **`LanguageDownloader`** (`lib/data/language_downloader.dart`) is the in-house module that downloads + unzips a language's HTML + PDF into a staging directory and swaps it into place atomically. Failed downloads never destroy prior offline content, concurrent `download()` calls are serialized (peak memory bounded by two zips), and a `.staging` leftover from a crashed run is cleaned up on the next attempt. It replaced the third-party `download_assets` package and is wired up in `main.dart` and the background isolate via `languageDownloaderProvider.overrideWithValue(...)`. - **`flutter_html` error filter.** `main.dart` installs a `FlutterError.onError` shim that swallows four specific assertions thrown by `flutter_html_table` 3.0.0. Read the long docstring there before touching it — the four assertion variants are documented in detail. - **HTML pre-processing.** `widgets/html_view.dart` calls `sanitize()` to fix bugs in `flutter_html` (e.g. percent table widths, fuzzy translations, stylized subtitles). Some workarounds are also in the HTML generator (`pywikitools`) upstream. -- **Background task is half-disabled.** Big chunks of `BackgroundScheduler.schedule()` and `Workmanager().initialize` in `main.dart` are commented out, gated on a "version 0.9" milestone. Don't delete them — they are the working scaffolding for the next release. +- **Background task is enabled and settings-driven.** `BackgroundScheduler.schedule()` registers a periodic `workmanager` task at the `CheckFrequency` interval (or cancels it when `never`), and `backgroundMain()` runs a two-phase flow: `backgroundCheck` then a settings-gated `backgroundDownload` (see the `AutomaticUpdates` × connectivity matrix in `docs/background-tasks.md`). Scheduling is owned by `BackgroundScheduler`, triggered from startup / onboarding step 3 / the check-frequency setting — `main.dart` only calls `Workmanager().initialize(backgroundTask)`. - **No package on pub.dev.** `pubspec.yaml` has `publish_to: 'none'`. - **License is AGPL** with an Apple App Store exception. See `LICENSE` and `COPYING.iOS`. - **`test` is pinned to ^1.29.0** because 1.30+ needs a newer `test_api` than `flutter_test` allows. @@ -170,8 +170,8 @@ ______________________________________________________________________ ## 8. Roadmap context (from README) -- **0.9**: enable automatic background updates (scaffolding present but commented out). +- **0.9**: enable automatic background updates — **done**: the periodic task is + scheduled from `CheckFrequency` and downloads per `AutomaticUpdates` (see + `docs/background-tasks.md`). - **1.0**: solid release. - iOS planned for 2024. - -If you see code with `TODO for version 0.9`, that's why. diff --git a/analysis_options.yaml b/analysis_options.yaml index 4d8b6eb..0bda550 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,11 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 880dfbd..1916de9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + **Note:** Currently the periodic registration of the background task is **commented out** (gated behind "version 0.9"). -> The task is wired up and tested in the integration test, but is not actually being scheduled at app launch. +> **Note:** The periodic task is scheduled by `BackgroundScheduler.schedule()` +> at the `CheckFrequency` interval (not scheduled when `never`), and its run is +> gated by the `AutomaticUpdates` setting (check-only, or download on WiFi / +> always). See `docs/background-tasks.md`. ## Layered overview diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 1a2a826..46a84f2 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -1,6 +1,10 @@ # Background Tasks -The app has a `workmanager`-based periodic background task that **checks** for content updates while the app is closed. Note: as of v0.8 the *scheduling* of this task is **disabled** (commented out, gated for v0.9). The task implementation is complete and integration-tested; only the registration is dormant. +The app has a `workmanager`-based periodic background task that **checks** for +content updates while the app is closed and, when the user's settings allow it, +**downloads** the languages that have updates. The task is scheduled at the +`CheckFrequency` interval (and not scheduled at all when the user picks +`never`); what it does once it runs is driven by the `AutomaticUpdates` setting. ## What runs in the background @@ -25,49 +29,141 @@ void backgroundTask() { ### `backgroundMain()` 1. Gets a fresh `SharedPreferences` instance (the background isolate has its own memory). 2. Resolves `getApplicationDocumentsDirectory()` and builds a real `LanguageDownloaderImpl` with a fresh `Dio` and the local `FileSystem`. -3. Builds a new `ProviderContainer` with `sharedPrefsProvider` **and** `languageDownloaderProvider` overridden — the background isolate has the same atomicity and concurrency guarantees as the foreground path. -4. Calls `backgroundCheck(ref)`. -5. Currently, the task only checks for updates, never auto-downloads. - -### `backgroundCheck(ProviderContainer ref)` +3. Builds a new `ProviderContainer` with `sharedPrefsProvider`, `languageDownloaderProvider` **and** `connectivityServiceProvider` overridden — the background isolate has the same atomicity and concurrency guarantees as the foreground path, plus a way to check the connection type without a `BuildContext`. +4. Calls **`backgroundRun(ref)`**, which first + [skips the run if the check frequency isn't due yet](#skipping-runs-before-the-check-frequency-is-due) + and otherwise runs the two-phase flow: **`backgroundCheck(ref)`** (phase 1) then **`backgroundDownload(ref)`** (phase 2). + +### Skipping runs before the check frequency is due +`backgroundRun(ref, {now})` exits early — no check, no download, no network +calls — unless the user's `CheckFrequency` interval has (roughly) elapsed: + +- `CheckFrequency.never` → always skip (a stray run, e.g. from the native iOS + registration, must not touch the network). +- Otherwise it compares `now` with **`mostRecentCheck(ref)`**: the *most recent* + `lastCheckedTimestamp` over all downloaded languages. (Not the oldest like + `lastCheckedProvider`: `backgroundCheck` doesn't re-check languages that + already have `updatesAvailable`, so their timestamps stay old and no run + would ever be skipped.) No downloaded language → the run goes ahead. +- The run is skipped only if **less than 90% of the interval** has elapsed. The + 10% tolerance matters: the OS schedules the next run relative to when the + previous run *started*, while `lastChecked` is written a few seconds later — + a strict comparison would skip correctly-scheduled runs. + +**Why it exists — iOS runs at a fixed daily frequency.** On iOS the periodic +task is registered natively in `AppDelegate.swift` +(`WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "backgroundTask", +frequency: 24h)`, the shortest `CheckFrequency`). The Dart +`registerPeriodicTask(... initialDelay: interval ~/ 2)` only affects the first +run; after that `workmanager_apple` reschedules the task daily regardless of +the chosen frequency (and iOS may run it later than requested, never earlier). +With `weekly`, the daily wake-ups on days 1–6 therefore exit early and the one +on day ~7 checks. With `daily` nothing changes. + +**On Android no run is skipped**: WorkManager already runs the task at the +requested interval, and the 10% tolerance absorbs the scheduling jitter. + +`backgroundTestMain()` deliberately doesn't go through `backgroundRun`: its fake +language was just "downloaded", so the run the integration test needs would be +skipped. +The decision is unit-tested per `CheckFrequency` in +`test/background_check_frequency_test.dart`, driving `backgroundRun` with an +injected `now` and a counting `MockClient`. + +### `backgroundCheck(ProviderContainer ref)` — phase 1 For each language code in `availableLanguagesProvider`: 1. `languageProvider(code).notifier.lazyInit()` — minimal disk check, no JSON parse. 2. Skip if not downloaded. -3. `languageStatusProvider(code).check()` — same GitHub Commits API call as the foreground. +3. `languageStatusProvider(code).check()` — same GitHub Commits API call as the foreground. This refreshes `updatesAvailable-` / `lastChecked-` in prefs. 4. Bail out of the loop if the rate limit (`apiRateLimitExceeded`) is hit. +### `backgroundDownload(ProviderContainer ref)` — phase 2 +Reads `automaticUpdatesProvider` (from the isolate's own prefs) and decides +whether to download the languages that phase 1 flagged with `updatesAvailable`: + +| `AutomaticUpdates` | metered (mobile) | unmetered (WiFi/ethernet) | +| --------------------- | ---------------- | ------------------------- | +| `never` | no download | no download | +| `requireConfirmation` | no download¹ | no download¹ | +| `onlyOnWifi` | no download | download | +| `yesAlways` | download | download | + +¹ `requireConfirmation` deliberately leaves `updatesAvailable` set so the +foreground can surface it — see [Confirming updates in the foreground](#confirming-updates-in-the-foreground). + +For `onlyOnWifi`, connectivity is queried via `connectivityServiceProvider` +(`ConnectivityService.isUnmetered()`). "Unmetered" means WiFi **or** ethernet — +the user's real intent behind `onlyOnWifi` is "don't burn mobile data". When +downloading, each language with `updatesAvailable` is re-downloaded via +`languageDownloader.download(code)` in a per-language `try`/`catch`, so one +language's failure never aborts the whole run. After each download the language's +`languageStatusProvider` is invalidated so `updatesAvailable` resets to false. + +### Connectivity (`lib/data/connectivity_service.dart`) +`ConnectivityService.isUnmetered()` wraps the `connectivity_plus` package +(Android needs the `ACCESS_NETWORK_STATE` permission). It is exposed as +`connectivityServiceProvider`, a `MustOverrideProvider` — the real +`ConnectivityServiceImpl` is wired up in `main.dart` and in `backgroundMain()`, +and tests inject `FakeConnectivityService` (a controllable `unmetered` flag, +living in `lib/background/background_test.dart` next to `FakeLanguageDownloader`). + ### Debug logging `writeLog(message)` appends to `/background.log` so the integration test (and human debuggers) can confirm the task ran. Marked `TODO: Remove later`. ## Scheduling (`lib/background/background_scheduler.dart`) -The current code is essentially: +`BackgroundScheduler.schedule()` registers (or cancels) the periodic task +according to the `CheckFrequency` setting: ```dart -class BackgroundScheduler extends Notifier { - @override - bool build() => false; - - Future schedule() async { - /* TODO Enable this with version 0.9 - cancelByUniqueName('backgroundTask') - interval = checkFrequency.getDuration() // null if 'never' - if interval == null: state = false; return - Workmanager().registerPeriodicTask('backgroundTask', 'backgroundTask', - constraints: Constraints(networkType: NetworkType.connected), - initialDelay: interval ~/ 2) - state = true - */ +Future schedule() async { + // idempotent re-scheduling: always cancel the prior registration first + await Workmanager().cancelByUniqueName('backgroundTask'); + final interval = ref.read(checkFrequencyProvider).getDuration(); // null == never + if (interval == null) { + state = false; // CheckFrequency.never -> task stays cancelled + return; } + await Workmanager().registerPeriodicTask('backgroundTask', 'backgroundTask', + constraints: Constraints(networkType: NetworkType.connected), + initialDelay: interval ~/ 2); + state = true; } ``` -`schedule()` is called from three places (and is currently a no-op): +- The provider's `bool` state reflects whether the task is currently scheduled. +- `CheckFrequency.never` → nothing registered, `state = false`. +- The `NetworkType.connected` constraint guarantees *some* connection when the + task fires (which is why `yesAlways` can download without a connectivity check). +- The `task` name `'backgroundTask'` is what reaches `executeTask` and selects + the `backgroundMain` branch (kept distinct from `'testTask'`). + +`schedule()` is called from three places: - `StartupPage.init()` after a successful startup, - `SetUpdatePrefsPage` after the user submits onboarding step 3, - `CheckFrequencyNotifier.setCheckFrequency` whenever the user changes the frequency. -The `Workmanager().initialize(backgroundTask, isInDebugMode: false)` call in `main()` is also commented out behind the same `TODO enable in version 0.9`. +`main()` calls `Workmanager().initialize(backgroundTask)` to register the isolate +entry point; the periodic scheduling itself is owned entirely by +`BackgroundScheduler` (there is no one-off registration on launch). + +Unit tests can't touch the `workmanager` platform channel, so `schedule()`'s +branching logic is mirrored by `TestBackgroundScheduler` +(`test/background_scheduler_test.dart`), which is asserted across every +`CheckFrequency` and for cancel-before-register re-scheduling. + +## Confirming updates in the foreground + +Under `AutomaticUpdates.requireConfirmation` the background task finds updates but +does not download them. `updatesNeedConfirmationProvider` +(`lib/data/updates.dart`) is true exactly when that setting is active **and** +updates are available. It drives a **persistent indicator**: +- a red dot next to the Settings entry in the drawer (`MainDrawer`), and +- a `ConfirmUpdatesPrompt` on the settings page with a "download now" button that + runs the normal foreground download path for every language with updates. + +After a successful foreground download, `updatesAvailable` resets via the usual +`LanguageStatusNotifier` rebuild, so the indicator and prompt disappear. ## How the foreground learns about background work @@ -90,7 +186,7 @@ There's a comment in `BackgroundResultNotifier.checkForActivity`: "*languageStat Two tests, both running on a real Android emulator (CI uses `reactivecircus/android-emulator-runner@v2`, API level 29): 1. **"Test that background task gets executed"** - - `Workmanager().initialize(backgroundTask, isInDebugMode: false)`. + - `Workmanager().initialize(backgroundTask)`. - `registerOneOffTask(..., 'testTask', initialDelay: 2s)` — `task` argument is what gets passed to `executeTask` and what selects the `backgroundTestMain()` branch. - Main isolate registers a port via `IsolateNameServer.registerPortWithName(port.sendPort, 'test')`. - `backgroundTask` sends `'success'` via `IsolateNameServer.lookupPortByName('test')`. @@ -98,7 +194,12 @@ Two tests, both running on a real Android emulator (CI uses `reactivecircus/andr 2. **"Test synchronization with main isolate"** — full happy-path: mount `App4Training` with `appLanguage='de'`, open settings to warm `languageStatusProvider`, fire the background task, then open a worksheet and verify the `foundBgActivity` snackbar appears. -The fixtures use `MemoryFileSystem` and `FakeLanguageDownloader` from `lib/background/background_test.dart`. +`backgroundTestMain()` mirrors `backgroundMain()`'s two-phase flow (without skipping runs based on `CheckFrequency`) +(`backgroundCheck` then `backgroundDownload`) with a `FakeConnectivityService`, +so the integration test exercises the full check-then-download path +deterministically. The fixtures use `MemoryFileSystem`, +`FakeLanguageDownloader` and `FakeConnectivityService` from +`lib/background/background_test.dart`. ## Why the test fixtures live in `lib/` diff --git a/docs/conventions.md b/docs/conventions.md index 3e4b737..0c270aa 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -57,7 +57,6 @@ The repo's existing code is sparing with comments — but where comments exist, - Don't write comments that restate the code. - **Do** write comments when the *why* is non-obvious — e.g. why an internal Riverpod import is needed, why a button is intentionally clickable while looking disabled. - `// FIXME` is used to mark workarounds that should ideally move upstream (`pywikitools`, `flutter_html`). -- `// TODO for version 0.9` marks the v0.9 milestone code. ## Don'ts @@ -65,6 +64,5 @@ The repo's existing code is sparing with comments — but where comments exist, - **Don't** reintroduce `custom_lint` (removed for analyzer-version conflict). - **Don't** unpin `test` without first reading the comments in `pubspec.yaml`. - **Don't** reintroduce `download_assets` (replaced by the in-house `LanguageDownloader` in `lib/data/language_downloader.dart`, which gives atomic staging-and-swap downloads, a concurrency cap, and no same-filename workaround). -- **Don't** delete commented-out v0.9 code without project agreement — it's the working scaffold for the next release. - **Don't** introduce a navigation library (`go_router`, etc.) — Navigator 1.0 is intentional and it interplays cleanly with the URL-mirroring `/view//` scheme. - **Don't** delete the `flutter_html_table` error filter — the four assertion variants are real and well-documented. diff --git a/docs/features.md b/docs/features.md index 55ea9ee..20ede85 100644 --- a/docs/features.md +++ b/docs/features.md @@ -25,7 +25,7 @@ The main reading screen. Three sections: 1. **App language** — `DropdownButtonAppLanguage`. 2. **`LanguageSettings`** — title + explanation text + `LanguagesTable` (the per-language download/update/delete table). -3. **`UpdateSettings`** — "Last check" timestamp + `CheckNowButton`. The check-frequency and automatic-updates dropdowns are commented out (for v0.9). +3. **`UpdateSettings`** — the `DropdownButtonCheckFrequency` (how often the background task runs) and `DropdownButtonAutomaticUpdates` (whether/when it downloads), "Last check" timestamp, `CheckNowButton`, and a `ConfirmUpdatesPrompt` shown under `requireConfirmation` when updates are waiting. ### `AboutPage` (`/about`) Static text + version. Renders localized strings via `flutter_linkify` so URLs in the strings become tappable (`url_launcher`). diff --git a/docs/onboarding-flow.md b/docs/onboarding-flow.md index ab80097..55634ec 100644 --- a/docs/onboarding-flow.md +++ b/docs/onboarding-flow.md @@ -23,21 +23,24 @@ 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. 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. + - Once the app language is downloaded, "Continue" routes to `getNextRoute(ref)`, which returns `/onboarding/3` if `checkFrequency` isn't set yet (still onboarding) and `/home` otherwise. - "Back" returns to `/onboarding/1`. ## Step 3 — `SetUpdatePrefsPage` (`/onboarding/3`) File: `lib/routes/onboarding/set_update_prefs_page.dart`. -> Currently **bypassed during normal startup** (the corresponding branch in `StartupPage.init()` is commented out, gated for v0.9). The page works end-to-end and is exercised by tests, just not reachable from the regular onboarding chain. +> Reached during onboarding when `checkFrequency` has not been set yet: +> `DownloadLanguagesPage.getNextRoute()` and `StartupPage.init()` both route to +> `/onboarding/3` while `checkFrequency == null`. Submitting the page persists the +> settings and schedules the background task. - `DropdownButtonCheckFrequency` (never / daily / weekly / monthly / 15-min test interval). - `DropdownButtonAutomaticUpdates` (never / requireConfirmation / onlyOnWifi / yesAlways). - "Let's go" button: 1. `automaticUpdatesProvider.notifier.persistNow()` — writes the *current* dropdown value (which may be the default if user didn't change it) to `SharedPreferences`. Without this step, `StartupPage` would think onboarding wasn't completed. 2. `checkFrequencyProvider.notifier.persistNow()` — same. - 3. `backgroundSchedulerProvider.notifier.schedule()` — registers the periodic task (currently a no-op since the body is commented out for v0.9). + 3. `backgroundSchedulerProvider.notifier.schedule()` — registers the periodic task at the chosen `CheckFrequency` interval (or cancels it when `never`). 4. `Navigator.pushReplacementNamed(context, '/home')`. ## Persistence map diff --git a/docs/routing.md b/docs/routing.md index 5bb97d8..b825a2f 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -13,7 +13,7 @@ The app uses **Navigator 1.0** with **named routes** — no `go_router`, no nest | `/about` | `AboutPage` | About text + license + version | | `/onboarding` or `/onboarding/1` | `WelcomePage` | App language selection | | `/onboarding/2` | `DownloadLanguagesPage` | Download required language(s) | -| `/onboarding/3` | `SetUpdatePrefsPage` | Update preferences (currently bypassed in startup flow — gated for v0.9) | +| `/onboarding/3` | `SetUpdatePrefsPage` | Update preferences (reached while `checkFrequency` is unset) | | anything else | `ErrorPage('Unknown route ...')` | Final fallback | `/view` malformed (missing parts) redirects to `/home`. The dispatcher logs every incoming route via `debugPrint`. @@ -38,7 +38,8 @@ StartupPage.init(): if app language is not yet downloaded: return '/onboarding/2' # resume onboarding - # (commented out for v0.9: third onboarding step on missing checkFrequency) + if SharedPreferences['checkFrequency'] is null: + return '/onboarding/3' # third onboarding step if SharedPreferences['recentPage'] && 'recentLang' && language is downloaded: navigateTo = '/view//' # resume last worksheet diff --git a/docs/state-management.md b/docs/state-management.md index 877a6f9..43e202f 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -99,7 +99,7 @@ Disk usage is deliberately **not** part of `Language`: computing it means listin | Provider | Type | Purpose | | --- | --- | --- | | `backgroundResultProvider` | `NotifierProvider` | Tracks whether the background task did something we should surface | -| `backgroundSchedulerProvider` | `NotifierProvider` | State = "task currently scheduled?" (Currently always `false` — body of `schedule()` is commented out for v0.9) | +| `backgroundSchedulerProvider` | `NotifierProvider` | State = "task currently scheduled?" — `true` after `schedule()` registers a periodic task, `false` when `CheckFrequency.never` cancels it | `BackgroundResultNotifier.checkForActivity()` is the trick that lets the foreground detect background work without IPC: it calls `prefs.reload()` and compares persisted `lastChecked-` to the in-memory `LanguageStatus.lastCheckedTimestamp`. If the persisted value is newer, it invalidates the corresponding `languageStatusProvider`. diff --git a/integration_test/background_interaction_test.dart b/integration_test/background_interaction_test.dart index b5fe3a3..99000dc 100644 --- a/integration_test/background_interaction_test.dart +++ b/integration_test/background_interaction_test.dart @@ -33,17 +33,26 @@ void main() async { completer.complete(data); }); await Workmanager().initialize(backgroundTask); - await Workmanager().registerOneOffTask("task-identifier", "testTask", - initialDelay: const Duration(seconds: 2)); + await Workmanager().registerOneOffTask( + "task-identifier", + "testTask", + initialDelay: const Duration(seconds: 2), + ); final fileSystem = MemoryFileSystem(); - await tester.pumpWidget(ProviderScope(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ], child: const App4Training())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + packageInfoProvider.overrideWithValue(packageInfo), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + child: const App4Training(), + ), + ); expect(find.text('Loading'), findsOneWidget); // Wait for the background isolate to finish @@ -67,13 +76,19 @@ void main() async { }); var fileSystem = await createTestFileSystem(); - await tester.pumpWidget(ProviderScope(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ], child: const App4Training())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + packageInfoProvider.overrideWithValue(packageInfo), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + child: const App4Training(), + ), + ); expect(find.text('Wird geladen'), findsOneWidget); await tester.pumpAndSettle(); @@ -87,8 +102,11 @@ void main() async { await tester.pumpAndSettle(); await Workmanager().initialize(backgroundTask); - await Workmanager().registerOneOffTask("task-identifier", "testTask", - initialDelay: const Duration(seconds: 2)); + await Workmanager().registerOneOffTask( + "task-identifier", + "testTask", + initialDelay: const Duration(seconds: 2), + ); // Wait for the background isolate to finish final msg = await completer.future.timeout(const Duration(seconds: 10)); diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index e784354..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -2,8 +2,6 @@ - MinimumOSVersion - 15.0 CFBundleDevelopmentRegion en CFBundleExecutable diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 26042d6..aa08ebe 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,70 +1,26 @@ 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 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 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: ec7febc55a194e213ced0d0eaf1ba6793163cfd3 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..f640d32 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -7,6 +8,17 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Register plugins in the headless engine that runs the background task + WorkmanagerPlugin.setPluginRegistrantCallback { registry in + GeneratedPluginRegistrant.register(with: registry) + } + // Must match BackgroundScheduler's unique name and Info.plist's + // BGTaskSchedulerPermittedIdentifiers. iOS reschedules at this fixed + // frequency (daily, the shortest CheckFrequency) after the first run. + WorkmanagerPlugin.registerPeriodicTask( + withIdentifier: "backgroundTask", + frequency: NSNumber(value: 24 * 60 * 60) + ) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 063b072..9db8bae 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + backgroundTask + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -51,6 +55,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + fetch + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/background/background_result.dart b/lib/background/background_result.dart index ba17ed7..95c41a9 100644 --- a/lib/background/background_result.dart +++ b/lib/background/background_result.dart @@ -51,14 +51,16 @@ class BackgroundResultNotifier extends Notifier { DateTime lcTimestampOrig = ref.read(languageStatusProvider(languageCode)).lastCheckedTimestamp; DateTime? lcTimestamp; - String? lcRaw = - ref.read(sharedPrefsProvider).getString('lastChecked-$languageCode'); + String? lcRaw = ref + .read(sharedPrefsProvider) + .getString('lastChecked-$languageCode'); if (lcRaw != null) { try { lcTimestamp = DateTime.parse(lcRaw).toUtc(); } on FormatException { debugPrint( - 'Error while trying to parse lastChecked timestamp: $lcRaw'); + 'Error while trying to parse lastChecked timestamp: $lcRaw', + ); lcTimestamp = null; } } @@ -66,15 +68,19 @@ class BackgroundResultNotifier extends Notifier { (lcTimestamp.compareTo(DateTime.now()) <= 0) && lcTimestamp.compareTo(lcTimestampOrig) > 0) { // It looks like there has been background activity! - debugPrint("Background activity detected for language '$languageCode': " - 'lastChecked was $lcTimestampOrig, sharedPrefs says $lcTimestamp'); + debugPrint( + "Background activity detected for language '$languageCode': " + 'lastChecked was $lcTimestampOrig, sharedPrefs says $lcTimestamp', + ); foundBgActivity = true; // invalidate the languageStatusProvider so it re-reads its value // from the shared preferences on next access ref.invalidate(languageStatusProvider(languageCode)); } else { - debugPrint("No background activity for language '$languageCode'. " - 'lastChecked: $lcTimestampOrig, sharedPrefs says $lcRaw'); + debugPrint( + "No background activity for language '$languageCode'. " + 'lastChecked: $lcTimestampOrig, sharedPrefs says $lcRaw', + ); } } debugPrint('Checking for background activity done'); @@ -85,5 +91,5 @@ class BackgroundResultNotifier extends Notifier { final backgroundResultProvider = NotifierProvider(() { - return BackgroundResultNotifier(); -}); + return BackgroundResultNotifier(); + }); diff --git a/lib/background/background_scheduler.dart b/lib/background/background_scheduler.dart index 30cce26..5a312e9 100644 --- a/lib/background/background_scheduler.dart +++ b/lib/background/background_scheduler.dart @@ -1,4 +1,7 @@ +import 'package:app4training/data/updates.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:workmanager/workmanager.dart'; /// The state indicates whether our background task is scheduled or not. /// The schedule() method gets called @@ -17,26 +20,30 @@ class BackgroundScheduler extends Notifier { /// /// Make sure TestBackgroundScheduler.schedule() has the same logic Future schedule() async { - /* TODO Enable this with version 0.9 + // Cancel any previously scheduled task first so re-scheduling is idempotent debugPrint('Cancelling all currently scheduled background tasks'); await Workmanager().cancelByUniqueName('backgroundTask'); Duration? interval = ref.read(checkFrequencyProvider).getDuration(); if (interval == null) { + // CheckFrequency.never: leave the task cancelled state = false; return; } - await Workmanager().registerPeriodicTask('backgroundTask', 'backgroundTask', - constraints: Constraints(networkType: NetworkType.connected), - initialDelay: interval ~/ 2); - debugPrint('Succesfully scheduled the background task: $interval'); + await Workmanager().registerPeriodicTask( + 'backgroundTask', + 'backgroundTask', + constraints: Constraints(networkType: NetworkType.connected), + initialDelay: interval ~/ 2, + ); + debugPrint('Successfully scheduled the background task: $interval'); state = true; - */ } } /// Our central access to scheduling the background task. /// The state of it indicates whether the background task is scheduled or not. -final backgroundSchedulerProvider = - NotifierProvider(() { - return BackgroundScheduler(); -}); +final backgroundSchedulerProvider = NotifierProvider( + () { + return BackgroundScheduler(); + }, +); diff --git a/lib/background/background_task.dart b/lib/background/background_task.dart index d9bed1c..31b4b43 100644 --- a/lib/background/background_task.dart +++ b/lib/background/background_task.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:ui'; import 'package:app4training/background/background_test.dart'; +import 'package:app4training/data/connectivity_service.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/data/languages.dart'; @@ -66,12 +67,110 @@ Future backgroundMain() async { overrides: [ sharedPrefsProvider.overrideWithValue(prefs), languageDownloaderProvider.overrideWithValue(languageDownloader), + connectivityServiceProvider.overrideWithValue(ConnectivityServiceImpl()), ], ); + + await backgroundRun(ref); +} + +/// One run of the background task: check for updates, then download them. +/// Exits early (without any network calls) when the user's [CheckFrequency] +/// interval hasn't elapsed since the most recent check - on iOS the task is +/// rescheduled natively at a fixed daily frequency, so this is what makes +/// weekly/monthly actually weekly/monthly there. [now] is for testing. +/// +/// A run is only skipped if less than 90% of the interval has elapsed: runs +/// are scheduled relative to when the previous run *started* while lastChecked +/// is written a bit later, and the OS doesn't fire exactly on time either. +/// This way no run is skipped on Android, where WorkManager already runs at +/// the requested interval. +Future backgroundRun(ProviderContainer ref, {DateTime? now}) async { + final lastChecked = await mostRecentCheck(ref); + final interval = ref.read(checkFrequencyProvider).getDuration(); + now ??= DateTime.now().toUtc(); + if (interval == null) { + await writeLog('CheckFrequency.never: skipping this run'); + return; + } + if (lastChecked != null && now.difference(lastChecked) < interval * 0.9) { + await writeLog('Last check was at $lastChecked: skipping this run'); + return; + } + + // Phase 1: check every downloaded language for updates await backgroundCheck(ref); - // TODO: check automatic updates setting; if necessary check connectivity - // TODO: if all is fine: download languages with updates + // Phase 2: download the languages that have updates, gated by the user's + // AutomaticUpdates setting and (for onlyOnWifi) the current connection type + await backgroundDownload(ref); +} + +/// The most recent lastChecked timestamp of all downloaded languages (UTC), +/// or null if no language is downloaded. +/// +/// Most recent and not oldest (like [lastCheckedProvider]): [backgroundCheck] +/// doesn't re-check languages that already have updates available, so their +/// timestamps would stay old forever and [backgroundRun] would never skip. +Future mostRecentCheck(ProviderContainer ref) async { + DateTime? result; + for (String languageCode in ref.read(availableLanguagesProvider)) { + await ref.read(languageProvider(languageCode).notifier).lazyInit(); + if (!ref.read(languageProvider(languageCode)).downloaded) continue; + final timestamp = + ref.read(languageStatusProvider(languageCode)).lastCheckedTimestamp; + if (result == null || timestamp.isAfter(result)) result = timestamp; + } + return result; +} + +/// Download the languages that have updates available, honoring the user's +/// [AutomaticUpdates] setting and connectivity. Assumes [backgroundCheck] has +/// already run so that `updatesAvailable` reflects the remote state. +/// +/// | AutomaticUpdates | metered | unmetered (WiFi/ethernet) | +/// | ------------------- | ------------ | ------------------------- | +/// | never | no download | no download | +/// | requireConfirmation | no download | no download | +/// | onlyOnWifi | no download | download | +/// | yesAlways | download | download | +Future backgroundDownload(ProviderContainer ref) async { + // Read the setting from the isolate's own SharedPreferences instance + final automaticUpdates = ref.read(automaticUpdatesProvider); + await writeLog('AutomaticUpdates setting: ${automaticUpdates.name}'); + + switch (automaticUpdates) { + case AutomaticUpdates.never: + case AutomaticUpdates.requireConfirmation: + // Never auto-download. requireConfirmation leaves updatesAvailable set + // so the foreground can surface it and let the user confirm. + return; + case AutomaticUpdates.onlyOnWifi: + if (!await ref.read(connectivityServiceProvider).isUnmetered()) { + await writeLog('onlyOnWifi but connection is metered: skipping'); + return; + } + case AutomaticUpdates.yesAlways: + // The periodic task's NetworkType.connected constraint already + // guarantees some connection, so download regardless of its type. + break; + } + + for (String languageCode in ref.read(availableLanguagesProvider)) { + if (!ref.read(languageStatusProvider(languageCode)).updatesAvailable) { + continue; + } + try { + await writeLog('Downloading update for $languageCode in background'); + await ref.read(languageDownloaderProvider).download(languageCode); + // Re-downloading refreshes the download timestamp; re-reading the status + // lets LanguageStatusNotifier.build() reset updatesAvailable to false. + ref.invalidate(languageStatusProvider(languageCode)); + } catch (e) { + // Don't let one language's failure abort the whole run + await writeLog('Error downloading $languageCode in background: $e'); + } + } } /// For the integration test: Simulates that we have @@ -88,9 +187,22 @@ Future backgroundTestMain() async { languageDownloaderProvider.overrideWithValue( FakeLanguageDownloader(fileSystem: fileSystem), ), + // Fake connectivity so the download decision is deterministic in the + // integration test (no real platform channel in the isolate). + connectivityServiceProvider.overrideWithValue( + FakeConnectivityService(unmetered: true), + ), + // Fake the update check so it doesn't hit the live (rate-limited) GitHub + // API - check() still persists a fresh lastChecked timestamp, which is + // what the foreground isolate uses to detect background activity. + httpClientProvider.overrideWithValue(fakeNoUpdatesClient()), ], ); + // Same two-phase flow as backgroundMain(): check, then settings-gated download. + // Deliberately not via backgroundRun(): German was just "downloaded", so it + // would skip this run because of the user's CheckFrequency. await backgroundCheck(ref); + await backgroundDownload(ref); } /// Check for updates for all downloaded languages diff --git a/lib/background/background_test.dart b/lib/background/background_test.dart index 6276b6d..f92b3f7 100644 --- a/lib/background/background_test.dart +++ b/lib/background/background_test.dart @@ -1,7 +1,12 @@ +import 'dart:convert'; + +import 'package:app4training/data/connectivity_service.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/language_downloader.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; import 'package:path/path.dart' as p; /* These are utility functions for the integration test. @@ -19,6 +24,9 @@ class FakeLanguageDownloader implements LanguageDownloader { int downloadCalls = 0; int deleteCalls = 0; + /// The language codes passed to [download], in call order. + final List downloadedLangs = []; + FakeLanguageDownloader({ required this.fileSystem, this.root = '', @@ -36,6 +44,7 @@ class FakeLanguageDownloader implements LanguageDownloader { @override Future download(String langCode) async { downloadCalls += 1; + downloadedLangs.add(langCode); if (throwOnDownload) { throw Exception('Simulated download failure'); } @@ -51,6 +60,33 @@ class FakeLanguageDownloader implements LanguageDownloader { } } +/// A test double for [ConnectivityService] with a controllable result. +/// Set [unmetered] to simulate being on WiFi/ethernet (true) or mobile (false). +/// Lives in `lib/` so the background isolate's integration test can import it +/// too (same rationale as [FakeLanguageDownloader]). +class FakeConnectivityService implements ConnectivityService { + bool unmetered; + int isUnmeteredCalls = 0; + + FakeConnectivityService({this.unmetered = false}); + + @override + Future isUnmetered() async { + isUnmeteredCalls += 1; + return unmetered; + } +} + +/// A fake HTTP client for the background isolate's integration test. +/// Always returns an empty commit list (HTTP 200), so +/// [LanguageStatusNotifier.check] persists a fresh lastChecked timestamp +/// without hitting the live GitHub API (which is rate-limited and makes the +/// test flaky). An empty list keeps updatesAvailable false, so the background +/// download phase stays a no-op. +Client fakeNoUpdatesClient() { + return MockClient((request) async => Response(json.encode([]), 200)); +} + // Simulate a file system where German is downloaded with one worksheet Future createTestFileSystem() async { var fileSystem = MemoryFileSystem(); diff --git a/lib/data/app_language.dart b/lib/data/app_language.dart index 34d5a46..78aa7ae 100644 --- a/lib/data/app_language.dart +++ b/lib/data/app_language.dart @@ -45,7 +45,7 @@ class AppLanguage { static const Map availableAppLanguages = { 'system': 'System default', 'en': 'English (en)', - 'de': 'Deutsch (de)' + 'de': 'Deutsch (de)', }; } @@ -74,8 +74,8 @@ class AppLanguageController extends Notifier { final appLanguageProvider = NotifierProvider(() { - return AppLanguageController(); -}); + return AppLanguageController(); + }); /// wrapper class around `Platform.localeName`: /// - provides the language code (just 'en' instead of 'en_US' e.g.) diff --git a/lib/data/bulk_language_download.dart b/lib/data/bulk_language_download.dart index 3b2e468..92f04e7 100644 --- a/lib/data/bulk_language_download.dart +++ b/lib/data/bulk_language_download.dart @@ -74,18 +74,18 @@ Future downloadLanguagesInParallel( } else { errorCount++; } - onProgress?.call(BulkDownloadProgress( - completed: successCount + errorCount, - total: total, - languageCode: code, - success: success, - )); + onProgress?.call( + BulkDownloadProgress( + completed: successCount + errorCount, + total: total, + languageCode: code, + success: success, + ), + ); } } - await Future.wait([ - for (var i = 0; i < maxConcurrent; i++) worker(), - ]); + await Future.wait([for (var i = 0; i < maxConcurrent; i++) worker()]); return BulkDownloadResult( successCount: successCount, diff --git a/lib/data/categories.dart b/lib/data/categories.dart index dc85b25..c57076b 100644 --- a/lib/data/categories.dart +++ b/lib/data/categories.dart @@ -51,6 +51,6 @@ const Map worksheetCategories = { "The_Role_of_a_Helper_in_Prayer": Category.innerHealingForTrainers, "Leading_a_Prayer_Time": Category.innerHealingForTrainers, "How_to_Continue_After_a_Prayer_Time": Category.innerHealing, -// TODO excluded the following worksheet as it doesn't display correctly -// "Four_Kinds_of_Disciples": Category.essentialsForTrainers + // TODO excluded the following worksheet as it doesn't display correctly + // "Four_Kinds_of_Disciples": Category.essentialsForTrainers }; diff --git a/lib/data/connectivity_service.dart b/lib/data/connectivity_service.dart new file mode 100644 index 0000000..edd7668 --- /dev/null +++ b/lib/data/connectivity_service.dart @@ -0,0 +1,39 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; + +/// Abstraction over connectivity so both the foreground and the background +/// isolate can ask "are we on an unmetered connection right now?" without a +/// [BuildContext], and so tests can inject a controllable result. +/// +/// See [connectivityServiceProvider] (a MustOverrideProvider): the real impl is +/// wired up in `main.dart` and in `backgroundMain()`, and tests inject +/// [FakeConnectivityService]. +abstract interface class ConnectivityService { + /// Whether the current connection is unmetered (WiFi or ethernet), i.e. + /// downloading won't burn the user's mobile data. + /// + /// This is the honest predicate behind [AutomaticUpdates.onlyOnWifi]: the + /// user's intent is "don't use mobile data", so ethernet counts as fine too. + Future isUnmetered(); +} + +/// Whether any of the currently active connections is unmetered (WiFi or +/// ethernet). Split out from [ConnectivityServiceImpl] so the mapping can be +/// unit-tested without touching a platform channel. +bool isUnmeteredConnection(List results) { + return results.any( + (r) => r == ConnectivityResult.wifi || r == ConnectivityResult.ethernet, + ); +} + +/// Real [ConnectivityService] backed by the `connectivity_plus` package. +class ConnectivityServiceImpl implements ConnectivityService { + final Connectivity _connectivity; + + ConnectivityServiceImpl({Connectivity? connectivity}) + : _connectivity = connectivity ?? Connectivity(); + + @override + Future isUnmetered() async { + return isUnmeteredConnection(await _connectivity.checkConnectivity()); + } +} diff --git a/lib/data/exceptions.dart b/lib/data/exceptions.dart index 1bb3687..7f20b93 100644 --- a/lib/data/exceptions.dart +++ b/lib/data/exceptions.dart @@ -47,11 +47,15 @@ class LanguageCorruptedException extends App4TrainingException LanguageCorruptedException(this.languageCode, this.message, [this.exception]); @override - String toString() => AppLocalizationsEn().languageCorrupted(languageCode, - (exception == null) ? message : '$message (${exception.toString()})'); + String toString() => AppLocalizationsEn().languageCorrupted( + languageCode, + (exception == null) ? message : '$message (${exception.toString()})', + ); @override String toLocalizedString(BuildContext context) => - context.l10n.languageCorrupted(context.l10n.getLanguageName(languageCode), - (exception == null) ? message : '$message (${exception.toString()})'); + context.l10n.languageCorrupted( + context.l10n.getLanguageName(languageCode), + (exception == null) ? message : '$message (${exception.toString()})', + ); } diff --git a/lib/data/globals.dart b/lib/data/globals.dart index 519865b..96c150f 100644 --- a/lib/data/globals.dart +++ b/lib/data/globals.dart @@ -1,3 +1,4 @@ +import 'package:app4training/data/connectivity_service.dart'; import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; final sharedPrefsProvider = MustOverrideProvider(); final packageInfoProvider = MustOverrideProvider(); final languageDownloaderProvider = MustOverrideProvider(); +final connectivityServiceProvider = MustOverrideProvider(); /// ignore: non_constant_identifier_names Provider MustOverrideProvider() { @@ -75,7 +77,7 @@ final availableLanguagesProvider = Provider>((ref) { 'es', 'sq', 'en', - 'de' + 'de', ]; }); @@ -117,7 +119,8 @@ class AutomaticUpdatesNotifier extends Notifier { @override AutomaticUpdates build() { return AutomaticUpdates.fromString( - ref.read(sharedPrefsProvider).getString('automaticUpdates')); + ref.read(sharedPrefsProvider).getString('automaticUpdates'), + ); } /// Our one function to change our global setting @@ -134,8 +137,8 @@ class AutomaticUpdatesNotifier extends Notifier { final automaticUpdatesProvider = NotifierProvider(() { - return AutomaticUpdatesNotifier(); -}); + return AutomaticUpdatesNotifier(); + }); /// global constants class Globals { diff --git a/lib/data/language_downloader.dart b/lib/data/language_downloader.dart index 35074a0..013dfc7 100644 --- a/lib/data/language_downloader.dart +++ b/lib/data/language_downloader.dart @@ -35,7 +35,7 @@ List decodeZipEntries(Uint8List zipBytes) { final archive = ZipDecoder().decodeBytes(zipBytes); return [ for (final file in archive) - (path: file.name, bytes: file.isFile ? file.content : null) + (path: file.name, bytes: file.isFile ? file.content : null), ]; } @@ -103,17 +103,18 @@ class LanguageDownloaderImpl implements LanguageDownloader { // Download both zips concurrently final results = await PerfLogger.span( - 'download.fetchZips', - () => Future.wait([ - _dio.get>( - Globals.getRemoteUrlHtml(langCode), - options: Options(responseType: ResponseType.bytes), - ), - _dio.get>( - Globals.getRemoteUrlPdf(langCode), - options: Options(responseType: ResponseType.bytes), - ), - ])); + 'download.fetchZips', + () => Future.wait([ + _dio.get>( + Globals.getRemoteUrlHtml(langCode), + options: Options(responseType: ResponseType.bytes), + ), + _dio.get>( + Globals.getRemoteUrlPdf(langCode), + options: Options(responseType: ResponseType.bytes), + ), + ]), + ); // Extract both zips into staging for (final response in results) { @@ -155,9 +156,11 @@ class LanguageDownloaderImpl implements LanguageDownloader { final bytes = zipData is Uint8List ? zipData : Uint8List.fromList(zipData); // The span includes time spent queueing for a decode slot - on a slow // device that wait is part of what the user experiences - final entries = await PerfLogger.span('download.decodeZip', - () => _zipDecodeLimit.run(() => _decodeZip(bytes)), - data: () => {'zipBytes': bytes.length}); + final entries = await PerfLogger.span( + 'download.decodeZip', + () => _zipDecodeLimit.run(() => _decodeZip(bytes)), + data: () => {'zipBytes': bytes.length}, + ); await PerfLogger.span('download.writeFiles', () async { // An archive holds hundreds of files in a handful of directories, so diff --git a/lib/data/languages.dart b/lib/data/languages.dart index 8e470bf..80f9f26 100644 --- a/lib/data/languages.dart +++ b/lib/data/languages.dart @@ -24,12 +24,15 @@ final _imageReference = RegExp(r'src="files/([^.]+.png)"'); /// Provide image data (base64-encoded) /// Returns empty string in case something went wrong -final imageContentProvider = - FutureProvider.family((ref, res) async { +final imageContentProvider = FutureProvider.family(( + ref, + res, +) async { final String path = ref.watch(languageProvider(res.langCode)).path; if (path == '') { debugPrint( - "Error: Can't load image ${res.name} in language ${res.langCode}"); + "Error: Can't load image ${res.name} in language ${res.langCode}", + ); return ''; } final fileSystem = ref.watch(fileSystemProvider); @@ -48,8 +51,10 @@ final imageContentProvider = /// throws [PageNotFoundException]: Hm, errorneous link? /// throws [LanguageCorruptedException]: oops, /// hope this goes away by deleting + re-downloading the language -final pageContentProvider = - FutureProvider.family((ref, page) async { +final pageContentProvider = FutureProvider.family(( + ref, + page, +) async { final fileSystem = ref.watch(fileSystemProvider); final lang = ref.watch(languageProvider(page.langCode)); if (!lang.downloaded) { @@ -67,25 +72,31 @@ final pageContentProvider = int htmlBytes = 0, imageCount = 0; return PerfLogger.span('page.loadContent', () async { try { - String content = await fileSystem - .file(join(lang.path, pageDetails.fileName)) - .readAsString(); + String content = + await fileSystem + .file(join(lang.path, pageDetails.fileName)) + .readAsString(); htmlBytes = content.length; // Read and encode all images of this page at once: doing that one by // one while building the HTML string meant a series of blocking disk // reads right before the first frame of a page could be painted. final Map imageData = {}; - await Future.wait(_imageReference - .allMatches(content) - .map((match) => match.group(1)!) - .where(lang.images.containsKey) - .toSet() - .map((name) async { - imageData[name] = await ref.watch( - imageContentProvider((name: name, langCode: page.langCode)) - .future); - })); + await Future.wait( + _imageReference + .allMatches(content) + .map((match) => match.group(1)!) + .where(lang.images.containsKey) + .toSet() + .map((name) async { + imageData[name] = await ref.watch( + imageContentProvider(( + name: name, + langCode: page.langCode, + )).future, + ); + }), + ); imageCount = imageData.length; // Load images directly into the HTML: @@ -94,14 +105,18 @@ final pageContentProvider = final String name = match.group(1)!; if (!imageData.containsKey(name)) { debugPrint( - 'Warning: image $name missing (in ${pageDetails.fileName})'); + 'Warning: image $name missing (in ${pageDetails.fileName})', + ); return match.group(0)!; } return 'src="data:image/png;base64,${imageData[name]}"'; }); } on FileSystemException catch (e) { throw LanguageCorruptedException( - page.langCode, 'Error while reading from local storage.', e); + page.langCode, + 'Error while reading from local storage.', + e, + ); } }, data: () => {'htmlBytes': htmlBytes, 'images': imageCount}); }, retry: null); @@ -111,8 +126,8 @@ final pageContentProvider = /// ref.watch(languageProvider('en').notifier) -> get English LanguageController final languageProvider = NotifierProvider.family((arg) { - return LanguageController(); -}); + return LanguageController(); + }); /// How many languages do we have available offline? final countDownloadedLanguagesProvider = Provider((ref) { @@ -151,9 +166,11 @@ class LanguageController extends Notifier { /// Returns true when the language is now available, false if not Future init() async { // The span records how big the language is, but not which one (no PII) - return await PerfLogger.span('language.load', _load, - data: () => - {'pages': state.pages.length, 'images': state.images.length}); + return await PerfLogger.span( + 'language.load', + _load, + data: () => {'pages': state.pages.length, 'images': state.images.length}, + ); } /// Checks whether the language is downloaded to device but doesn't @@ -161,21 +178,30 @@ class LanguageController extends Notifier { /// Returns true when the language is now available, false if not Future lazyInit() async { final downloader = ref.read(languageDownloaderProvider); - String path = - join(downloader.pathFor(languageCode), Globals.getResourcesDir(languageCode)); + String path = join( + downloader.pathFor(languageCode), + Globals.getResourcesDir(languageCode), + ); final stat = await ref .watch(fileSystemProvider) .stat(join(path, 'structure', 'contents.json')); bool downloaded = (stat.type != FileSystemEntityType.notFound); if (kDebugMode) { debugPrint( - "QuickInit trying to load '$languageCode', downloaded: $downloaded"); + "QuickInit trying to load '$languageCode', downloaded: $downloaded", + ); } if (downloaded) { DateTime timestamp = stat.modified.toUtc(); // Always store UTC internally - state = - Language(languageCode, const {}, const [], const {}, path, timestamp); + state = Language( + languageCode, + const {}, + const [], + const {}, + path, + timestamp, + ); return true; } return false; @@ -195,26 +221,33 @@ class LanguageController extends Notifier { try { // Now we store the full path to the language - String path = join(downloader.pathFor(languageCode), - Globals.getResourcesDir(languageCode)); + String path = join( + downloader.pathFor(languageCode), + Globals.getResourcesDir(languageCode), + ); // One stat() answers both questions we have about contents.json: // is the language on the device at all, and when was it stored there? - FileStat stat = - await fileSystem.stat(join(path, 'structure', 'contents.json')); + FileStat stat = await fileSystem.stat( + join(path, 'structure', 'contents.json'), + ); bool downloaded = (stat.type != FileSystemEntityType.notFound); if (kDebugMode) { - debugPrint("Trying to load '$languageCode' from $path," - " downloaded: $downloaded"); + debugPrint( + "Trying to load '$languageCode' from $path," + " downloaded: $downloaded", + ); } if (!downloaded) return false; DateTime timestamp = stat.modified.toUtc(); // Always store UTC internally // Read structure/contents.json as our source of truth: // Which pages are available, what is the order in the menu - var structure = jsonDecode(await fileSystem - .file(join(path, 'structure', 'contents.json')) - .readAsString()); + var structure = jsonDecode( + await fileSystem + .file(join(path, 'structure', 'contents.json')) + .readAsString(), + ); final Map pages = {}; final List pageIndex = []; @@ -223,11 +256,15 @@ class LanguageController extends Notifier { // Go through existing PDF files var pdfPath = join( - downloader.pathFor(languageCode), Globals.getPdfDir(languageCode)); + downloader.pathFor(languageCode), + Globals.getPdfDir(languageCode), + ); var pdfDir = fileSystem.directory(pdfPath); if (await pdfDir.exists()) { - await for (var file - in pdfDir.list(recursive: false, followLinks: false)) { + await for (var file in pdfDir.list( + recursive: false, + followLinks: false, + )) { if (file is File) { pdfFiles.add(file.basename); } else { @@ -245,8 +282,13 @@ class LanguageController extends Notifier { pdfName = join(pdfPath, element['pdf']); pdfFiles.remove(element['pdf']); } - pages[element['page']] = Page(element['page'], element['title'], - element['filename'], element['version'], pdfName); + pages[element['page']] = Page( + element['page'], + element['title'], + element['filename'], + element['version'], + pdfName, + ); } // Consistency checking... @@ -260,8 +302,10 @@ class LanguageController extends Notifier { // Register available images var filesDir = fileSystem.directory(join(path, 'files')); if (await filesDir.exists()) { - await for (var file - in filesDir.list(recursive: false, followLinks: false)) { + await for (var file in filesDir.list( + recursive: false, + followLinks: false, + )) { if (file is File) { images[file.basename] = Image(file.basename); } else { @@ -269,16 +313,21 @@ class LanguageController extends Notifier { } } } - state = - Language(languageCode, pages, pageIndex, images, path, timestamp); + state = Language(languageCode, pages, pageIndex, images, path, timestamp); return true; } catch (e) { String msg = 'Error initializing data structure: $e'; debugPrint(msg); // Delete the whole folder await downloader.delete(languageCode); - state = - Language('', const {}, const [], const {}, '', DateTime.utc(2023)); + state = Language( + '', + const {}, + const [], + const {}, + '', + DateTime.utc(2023), + ); return false; } } @@ -286,8 +335,7 @@ class LanguageController extends Notifier { /// Delete this language from the device. Future deleteResources() async { await ref.read(languageDownloaderProvider).delete(languageCode); - state = - Language('', const {}, const [], const {}, '', DateTime.utc(2023)); + state = Language('', const {}, const [], const {}, '', DateTime.utc(2023)); } /// Download all files for one language via [LanguageDownloader] @@ -311,7 +359,9 @@ class LanguageController extends Notifier { /// error handling if a page we expect to be there can't be loaded because /// a HTML file is missing... Future _checkConsistency( - Directory dir, final Map pages) async { + Directory dir, + final Map pages, + ) async { Set files = {}; await for (var file in dir.list(recursive: false, followLinks: false)) { if (file is File) { @@ -321,7 +371,8 @@ class LanguageController extends Notifier { pages.forEach((key, page) { if (!files.remove(page.fileName)) { debugPrint( - "Warning: Structure mentions ${page.fileName} but the file is missing"); + "Warning: Structure mentions ${page.fileName} but the file is missing", + ); } }); if (files.isNotEmpty) debugPrint("Warning: Found orphaned files $files"); @@ -381,8 +432,14 @@ class Language { /// When were the files downloaded on our device? file system attribute, UTC final DateTime downloadTimestamp; - const Language(this.languageCode, this.pages, this.pageIndex, this.images, - this.path, this.downloadTimestamp); + const Language( + this.languageCode, + this.pages, + this.pageIndex, + this.images, + this.path, + this.downloadTimestamp, + ); /// Returns an list with all the worksheet titles in the menu. /// The list is ordered as identifier -> translated title @@ -408,12 +465,15 @@ class Language { /// walking the whole language directory and statting every single file /// (HTML worksheets, images and PDFs alike), which is far too expensive to /// do for every language at every cold start. Only the settings page needs it. -final languageSizeProvider = - FutureProvider.family((ref, langCode) async { +final languageSizeProvider = FutureProvider.family(( + ref, + langCode, +) async { if (!ref.watch(languageProvider(langCode)).downloaded) return 0; final downloader = ref.watch(languageDownloaderProvider); - final dir = - ref.watch(fileSystemProvider).directory(downloader.pathFor(langCode)); + final dir = ref + .watch(fileSystemProvider) + .directory(downloader.pathFor(langCode)); return calculateMemoryUsage(dir); }); @@ -421,7 +481,7 @@ final languageSizeProvider = final diskUsageProvider = FutureProvider((ref) async { final List sizes = await Future.wait(>[ for (String langCode in ref.watch(availableLanguagesProvider)) - ref.watch(languageSizeProvider(langCode).future) + ref.watch(languageSizeProvider(langCode).future), ]); return sizes.fold(0, (int sum, int size) => sum + size); }); @@ -433,8 +493,9 @@ final diskUsageProvider = FutureProvider((ref) async { Future calculateMemoryUsage(Directory dir) async { if (!await dir.exists()) return 0; final entities = await dir.list(recursive: true, followLinks: false).toList(); - final stats = - await Future.wait(entities.whereType().map((file) => file.stat())); + final stats = await Future.wait( + entities.whereType().map((file) => file.stat()), + ); final sizeInBytes = stats.fold(0, (int sum, FileStat s) => sum + s.size); return (sizeInBytes / 1000).ceil(); // let's never round down } diff --git a/lib/data/startup_stage.dart b/lib/data/startup_stage.dart index 1ceae7e..58706af 100644 --- a/lib/data/startup_stage.dart +++ b/lib/data/startup_stage.dart @@ -49,4 +49,5 @@ class StartupStageNotifier extends Notifier { /// The stage StartupPage.init() is in - drives the caption of its spinner final startupStageProvider = NotifierProvider( - StartupStageNotifier.new); + StartupStageNotifier.new, + ); diff --git a/lib/data/updates.dart b/lib/data/updates.dart index 0f09bab..e5c6067 100644 --- a/lib/data/updates.dart +++ b/lib/data/updates.dart @@ -78,7 +78,8 @@ class CheckFrequencyNotifier extends Notifier { @override CheckFrequency build() { return CheckFrequency.fromString( - ref.read(sharedPrefsProvider).getString('checkFrequency')); + ref.read(sharedPrefsProvider).getString('checkFrequency'), + ); } /// Our one function to change our global setting @@ -97,8 +98,8 @@ class CheckFrequencyNotifier extends Notifier { final checkFrequencyProvider = NotifierProvider(() { - return CheckFrequencyNotifier(); -}); + return CheckFrequencyNotifier(); + }); /// Status of one language: Are there updates available? /// LanguageStatusNotifier.build() is watching the languageProvider, which means @@ -118,7 +119,10 @@ class LanguageStatus { /// When did we check the remote repository last time? final DateTime lastCheckedTimestamp; // UTC const LanguageStatus( - this.updatesAvailable, this.downloadTimestamp, this.lastCheckedTimestamp); + this.updatesAvailable, + this.downloadTimestamp, + this.lastCheckedTimestamp, + ); @override String toString() { @@ -136,7 +140,7 @@ class LanguageStatus { class LanguageStatusNotifier extends Notifier { String _languageCode; LanguageStatusNotifier({String languageCode = ''}) - : _languageCode = languageCode; + : _languageCode = languageCode; @override LanguageStatus build() { @@ -156,14 +160,16 @@ class LanguageStatusNotifier extends Notifier { DateTime? lcTimestamp; // are there updates available for this language? - updatesAvailable = ref + updatesAvailable = + ref .read(sharedPrefsProvider) .getBool('updatesAvailable-$_languageCode') ?? false; // last checked timestamp: When did we check for updates the last time? - String? lcRaw = - ref.read(sharedPrefsProvider).getString('lastChecked-$_languageCode'); + String? lcRaw = ref + .read(sharedPrefsProvider) + .getString('lastChecked-$_languageCode'); if (lcRaw != null) { try { lcTimestamp = DateTime.parse(lcRaw).toUtc(); @@ -183,8 +189,12 @@ class LanguageStatusNotifier extends Notifier { ref .read(sharedPrefsProvider) .setBool('updatesAvailable-$_languageCode', false); - ref.read(sharedPrefsProvider).setString( - 'lastChecked-$_languageCode', lcTimestamp.toIso8601String()); + ref + .read(sharedPrefsProvider) + .setString( + 'lastChecked-$_languageCode', + lcTimestamp.toIso8601String(), + ); } final status = LanguageStatus(updatesAvailable, dlTimestamp, lcTimestamp); @@ -202,7 +212,7 @@ class LanguageStatusNotifier extends Notifier { /// -1: any other error Future check() async { assert(_languageCode != ''); -// TODO assert(ref.read(languageProvider(_languageCode)).downloaded); + // TODO assert(ref.read(languageProvider(_languageCode)).downloaded); // since = since.subtract(const Duration(days: 100)); // for testing var uri = Globals.getCommitsSince(_languageCode, state.downloadTimestamp); debugPrint(uri); @@ -243,9 +253,20 @@ class LanguageStatusNotifier extends Notifier { /// Check for updates for English /// ref.watch(languageStatusProvider('en').notifier).check() final languageStatusProvider = - NotifierProvider.family( - (arg) { - return LanguageStatusNotifier(languageCode: arg); + NotifierProvider.family(( + arg, + ) { + return LanguageStatusNotifier(languageCode: arg); + }); + +/// Whether updates are waiting for the user's explicit confirmation: +/// the user chose [AutomaticUpdates.requireConfirmation] and the background +/// task has found updates it did not auto-download. Drives the persistent +/// "updates available" indicator and the confirm-to-download prompt. +final updatesNeedConfirmationProvider = Provider((ref) { + return ref.watch(automaticUpdatesProvider) == + AutomaticUpdates.requireConfirmation && + ref.watch(updatesAvailableProvider); }); /// Are there updates available in any of our languages? diff --git a/lib/design/theme.dart b/lib/design/theme.dart index 89242a6..2d71b64 100644 --- a/lib/design/theme.dart +++ b/lib/design/theme.dart @@ -12,17 +12,20 @@ const snackBarErrorDuration = Duration(seconds: 10); /// Using the red theme of flex_color_scheme - see /// https://rydmike.com/flexcolorscheme/themesplayground-latest/ /// and select "red tornado" -ThemeData _defaultLightTheme = - FlexThemeData.light(scheme: FlexScheme.red, useMaterial3: true); +ThemeData _defaultLightTheme = FlexThemeData.light( + scheme: FlexScheme.red, + useMaterial3: true, +); /// Customize the design of our app bar AppBarTheme lightAppBarTheme = AppBarTheme( - backgroundColor: lightPrimaryColor, - centerTitle: true, - // increase font size and make it bold - titleTextStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 25), - // let the burger menu icon be white (instead of black) - iconTheme: const IconThemeData(color: Colors.white)); + backgroundColor: lightPrimaryColor, + centerTitle: true, + // increase font size and make it bold + titleTextStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 25), + // let the burger menu icon be white (instead of black) + iconTheme: const IconThemeData(color: Colors.white), +); AppBarTheme darkAppBarTheme = AppBarTheme( backgroundColor: _defaultLightTheme.colorScheme.primary, @@ -32,15 +35,18 @@ AppBarTheme darkAppBarTheme = AppBarTheme( /// Our standard (light) theme ThemeData lightTheme = _defaultLightTheme.copyWith( - // change the primary red color a bit - colorScheme: - _defaultLightTheme.colorScheme.copyWith(primary: lightPrimaryColor), - appBarTheme: lightAppBarTheme); + // change the primary red color a bit + colorScheme: _defaultLightTheme.colorScheme.copyWith( + primary: lightPrimaryColor, + ), + appBarTheme: lightAppBarTheme, +); /// Our dark theme -ThemeData darkTheme = - FlexThemeData.dark(scheme: FlexScheme.red, useMaterial3: true) - .copyWith(appBarTheme: darkAppBarTheme); +ThemeData darkTheme = FlexThemeData.dark( + scheme: FlexScheme.red, + useMaterial3: true, +).copyWith(appBarTheme: darkAppBarTheme); /// Size of smileys (used on "sorry, not yet available" dialogs) const double smileySize = 50; diff --git a/lib/features/share/share_button.dart b/lib/features/share/share_button.dart index 5cb2735..a66ff05 100644 --- a/lib/features/share/share_button.dart +++ b/lib/features/share/share_button.dart @@ -31,94 +31,115 @@ class ShareButton extends ConsumerWidget { ref.watch(languageProvider(currentLang)).pages[currentPage]?.pdfPath; final shareService = ref.watch(shareProvider); // Color for the PDF-related entries (greyed out if PDF is not available) - Color pdfColor = (pdfFile != null) - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).disabledColor; + Color pdfColor = + (pdfFile != null) + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).disabledColor; return MenuAnchor( - controller: menuController, - builder: - (BuildContext context, MenuController controller, Widget? child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.share), - tooltip: 'Share', - ); - }, - menuChildren: [ - Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), - child: Column( + controller: menuController, + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.share), + tooltip: 'Share', + ); + }, + menuChildren: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: Column( + children: [ + Column( children: [ - Column(children: [ - ListTile( - dense: true, - title: Text(context.l10n.openPdf, - style: TextStyle(color: pdfColor)), - leading: ImageIcon(const AssetImage(openPdfImage), - color: pdfColor), - onTap: () async { - if (pdfFile != null) { - menuController.close(); - var result = await shareService.open(pdfFile); - debugPrint( - 'OpenResult: ${result.message}; ${result.type}'); - } else { - await showDialog( - context: context, - builder: (context) { - return const PdfNotAvailableDialog(); - }); - menuController.close(); - } - }), - ListTile( - dense: true, - title: Text(context.l10n.sharePdf, - style: TextStyle(color: pdfColor)), - leading: ImageIcon(const AssetImage(sharePdfImage), - color: pdfColor), - onTap: () async { - if (pdfFile != null) { - menuController.close(); - unawaited(shareService.shareFile(pdfFile)); - } else { - await showDialog( - context: context, - builder: (context) { - return const PdfNotAvailableDialog(); - }); - menuController.close(); - } - }, + ListTile( + dense: true, + title: Text( + context.l10n.openPdf, + style: TextStyle(color: pdfColor), ), - ListTile( - dense: true, - title: Text(context.l10n.openInBrowser), - leading: const Icon(Icons.open_in_browser), - onTap: () async { - menuController.close(); - unawaited(shareService.launchUrl(Uri.parse(url))); - }), - ListTile( - dense: true, - title: Text(context.l10n.shareLink), - leading: const ImageIcon(AssetImage(shareLinkImage)), - onTap: () { + leading: ImageIcon( + const AssetImage(openPdfImage), + color: pdfColor, + ), + onTap: () async { + if (pdfFile != null) { + menuController.close(); + var result = await shareService.open(pdfFile); + debugPrint( + 'OpenResult: ${result.message}; ${result.type}', + ); + } else { + await showDialog( + context: context, + builder: (context) { + return const PdfNotAvailableDialog(); + }, + ); menuController.close(); - shareService.share(url); - }, + } + }, + ), + ListTile( + dense: true, + title: Text( + context.l10n.sharePdf, + style: TextStyle(color: pdfColor), + ), + leading: ImageIcon( + const AssetImage(sharePdfImage), + color: pdfColor, ), - ]) + onTap: () async { + if (pdfFile != null) { + menuController.close(); + unawaited(shareService.shareFile(pdfFile)); + } else { + await showDialog( + context: context, + builder: (context) { + return const PdfNotAvailableDialog(); + }, + ); + menuController.close(); + } + }, + ), + ListTile( + dense: true, + title: Text(context.l10n.openInBrowser), + leading: const Icon(Icons.open_in_browser), + onTap: () async { + menuController.close(); + unawaited(shareService.launchUrl(Uri.parse(url))); + }, + ), + ListTile( + dense: true, + title: Text(context.l10n.shareLink), + leading: const ImageIcon(AssetImage(shareLinkImage)), + onTap: () { + menuController.close(); + shareService.share(url); + }, + ), ], - )) - ]); + ), + ], + ), + ), + ], + ); } } @@ -128,21 +149,28 @@ class PdfNotAvailableDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text(context.l10n.sorry), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.sentiment_dissatisfied, - size: smileySize, - // For unknown reasons smiley is invisible otherwise - color: Theme.of(context).colorScheme.onSurface), + title: Text(context.l10n.sorry), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sentiment_dissatisfied, + size: smileySize, + // For unknown reasons smiley is invisible otherwise + color: Theme.of(context).colorScheme.onSurface, + ), const SizedBox(height: 10), Text(context.l10n.pdfNotAvailable), - ]), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.okay)) - ]); + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.l10n.okay), + ), + ], + ); } } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 823bfa9..3b5f5db 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -854,6 +854,18 @@ abstract class AppLocalizations { /// **'Searched for updates in the background'** String get foundBgActivity; + /// Prompt shown on the settings page when automatic updates require confirmation and updates were found + /// + /// In en, this message translates to: + /// **'Updates are available for your languages.'** + String get updatesReadyToDownload; + + /// Button to download the available updates after the user confirms + /// + /// In en, this message translates to: + /// **'Download now'** + String get downloadUpdatesNow; + /// No description provided for @sharePdf. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c72acc6..fb5a1db 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -494,6 +494,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get foundBgActivity => 'Im Hintergrund wurde nach Updates gesucht'; + @override + String get updatesReadyToDownload => + 'Für deine Sprachen sind Updates verfügbar.'; + + @override + String get downloadUpdatesNow => 'Jetzt herunterladen'; + @override String get sharePdf => 'PDF teilen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 7362d02..b6e78e5 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -493,6 +493,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get foundBgActivity => 'Searched for updates in the background'; + @override + String get updatesReadyToDownload => + 'Updates are available for your languages.'; + + @override + String get downloadUpdatesNow => 'Download now'; + @override String get sharePdf => 'Share PDF'; diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 5439169..99a48e1 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -50,7 +50,7 @@ extension GetLanguageNameExt on AppLocalizations { 'es': language_es, 'sq': language_sq, 'en': language_en, - 'de': language_de + 'de': language_de, }; if (languageMap.containsKey(languageCode)) { return "${languageMap[languageCode]!} ($languageCode)"; diff --git a/lib/l10n/locales/app_de.arb b/lib/l10n/locales/app_de.arb index 8c1231e..f05a5a1 100644 --- a/lib/l10n/locales/app_de.arb +++ b/lib/l10n/locales/app_de.arb @@ -335,6 +335,8 @@ "letsGo": "Los geht's!", "homeExplanation": "Gott baut sein Reich überall auf der Welt. Er möchte, dass wir dabei mitmachen und andere zu Jüngern machen!\nDiese App will dir diese Aufgabe erleichtern: Wir stellen dir gute Trainingsmaterialien zur Verfügung. Und das Beste ist: Du kannst dasselbe Arbeitsblatt in verschiedenen Sprachen anschauen, so dass du immer weißt, was es bedeutet, selbst wenn du eine Sprache nicht verstehst.\n\nAlle Inhalte sind nun offline verfügbar und jederzeit bereit auf deinem Handy:", "foundBgActivity": "Im Hintergrund wurde nach Updates gesucht", + "updatesReadyToDownload": "Für deine Sprachen sind Updates verfügbar.", + "downloadUpdatesNow": "Jetzt herunterladen", "sharePdf": "PDF teilen", "openPdf": "PDF öffnen", "openInBrowser": "Im Browser öffnen", diff --git a/lib/l10n/locales/app_en.arb b/lib/l10n/locales/app_en.arb index 996b648..2158d70 100644 --- a/lib/l10n/locales/app_en.arb +++ b/lib/l10n/locales/app_en.arb @@ -335,6 +335,14 @@ "letsGo": "Let's go!", "homeExplanation": "God is building His kingdom all around the world. He wants us to join in His work and make disciples!\nThis app wants to serve you and make your job easier: We equip you with good training worksheets. The best thing is: You can access the same worksheet in different languages so that you always know what it means, even if you don't understand the language.\n\nAll this content is now available offline, always ready on your phone:", "foundBgActivity": "Searched for updates in the background", + "updatesReadyToDownload": "Updates are available for your languages.", + "@updatesReadyToDownload": { + "description": "Prompt shown on the settings page when automatic updates require confirmation and updates were found" + }, + "downloadUpdatesNow": "Download now", + "@downloadUpdatesNow": { + "description": "Button to download the available updates after the user confirms" + }, "sharePdf": "Share PDF", "openPdf": "Open PDF", "openInBrowser": "Open in browser", diff --git a/lib/main.dart b/lib/main.dart index e349d39..d54a203 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:app4training/data/connectivity_service.dart'; import 'dart:async'; import 'package:app4training/data/language_downloader.dart'; @@ -13,6 +14,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:workmanager/workmanager.dart'; +import 'background/background_task.dart'; import 'data/app_language.dart'; import 'data/globals.dart'; import 'design/theme.dart'; @@ -124,9 +127,9 @@ bool _isKnownHtmlTableAssertion(FlutterErrorDetails details) { final String exceptionText = details.exception.toString(); final bool exceptionMatchesKnownSignature = exceptionText.contains('RenderBox was not laid out') || - exceptionText.contains('computeDryBaseline') || - exceptionText.contains('renderBoxDoingDryBaseline') || - exceptionText.contains("'child!.hasSize'"); + exceptionText.contains('computeDryBaseline') || + exceptionText.contains('renderBoxDoingDryBaseline') || + exceptionText.contains("'child!.hasSize'"); // Stringifying the stack is by far the expensive half of this check, and // these assertions fire hundreds of times per page load in debug/profile // builds - so only pay for it once the cheap message check has matched. @@ -145,15 +148,18 @@ void main() async { // sequential platform channel round trips - the native splash screen is up // for all of it, without a single Flutter frame rendered yet. final (prefs, packageInfo, appDocsDir) = await PerfLogger.span( - 'main.platformChannels', - () => ( - SharedPreferences.getInstance(), - PackageInfo.fromPlatform(), - getApplicationDocumentsDirectory(), - ).wait); + 'main.platformChannels', + () => + ( + SharedPreferences.getInstance(), + PackageInfo.fromPlatform(), + getApplicationDocumentsDirectory(), + ).wait, + ); PerfLogger.start( - fileSystem: const LocalFileSystem(), - directory: p.join(appDocsDir.path, 'perf_sessions')); + fileSystem: const LocalFileSystem(), + directory: p.join(appDocsDir.path, 'perf_sessions'), + ); unawaited(PerfLogger.logDeviceAndApp(packageInfo)); final languageDownloader = LanguageDownloaderImpl( root: appDocsDir.path, @@ -161,14 +167,24 @@ void main() async { fileSystem: const LocalFileSystem(), ); - // Run initialization for our background task TODO enable in version 0.9 - // await Workmanager().initialize(backgroundTask, isInDebugMode: false); + // Register the isolate entry point for our background task. Periodic + // scheduling itself is owned by BackgroundScheduler.schedule(), triggered by + // startup / onboarding / the check-frequency setting. + await Workmanager().initialize(backgroundTask); - runApp(ProviderScope(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - packageInfoProvider.overrideWithValue(packageInfo), - languageDownloaderProvider.overrideWithValue(languageDownloader), - ], child: const App4Training())); + runApp( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + packageInfoProvider.overrideWithValue(packageInfo), + languageDownloaderProvider.overrideWithValue(languageDownloader), + connectivityServiceProvider.overrideWithValue( + ConnectivityServiceImpl(), + ), + ], + child: const App4Training(), + ), + ); PerfLogger.attachToApp(); } diff --git a/lib/routes/about_page.dart b/lib/routes/about_page.dart index 62f95b2..b4d6a80 100644 --- a/lib/routes/about_page.dart +++ b/lib/routes/about_page.dart @@ -19,81 +19,100 @@ class AboutPage extends ConsumerWidget { child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), - child: Column( - children: [ - const PromoBlock(), - const SizedBox(height: 20), - Linkify( - text: context.l10n.appDescription, - // ignore: deprecated_member_use - textScaleFactor: MediaQuery.of(context).textScaleFactor, - style: Theme.of(context).textTheme.bodyMedium, - onOpen: (link) async { - unawaited(launchUrl(Uri.parse(link.url))); - }), - const SizedBox(height: 10), - Text(context.l10n.matthew10_8, - style: const TextStyle(fontStyle: FontStyle.italic)), - const SizedBox(height: 10), - Text(context.l10n.noCopyright), - const SizedBox(height: 20), - Align( - alignment: Alignment.topLeft, - child: Text(context.l10n.worksOffline, - style: Theme.of(context).textTheme.titleLarge)), - const SizedBox(height: 10), - Text(context.l10n.worksOfflineText), - const SizedBox(height: 20), - Align( - alignment: Alignment.topLeft, - child: Text( - context.l10n.secure, - style: Theme.of(context).textTheme.titleLarge, - )), - const SizedBox(height: 10), - Text(context.l10n.secureText), - const SizedBox(height: 20), - Align( - alignment: Alignment.topLeft, - child: Text(context.l10n.contributing, - style: Theme.of(context).textTheme.titleLarge)), - const SizedBox(height: 10), - Linkify( - text: context.l10n.contributingText, - // ignore: deprecated_member_use - textScaleFactor: MediaQuery.of(context).textScaleFactor, - style: Theme.of(context).textTheme.bodyMedium, - onOpen: (link) async { - unawaited(launchUrl(Uri.parse(link.url))); - }), - const SizedBox(height: 20), - Align( - alignment: Alignment.topLeft, - child: Text(context.l10n.openSource, - style: Theme.of(context).textTheme.titleLarge)), - const SizedBox(height: 10), - Linkify( - text: context.l10n.openSourceText, - // ignore: deprecated_member_use - textScaleFactor: MediaQuery.of(context).textScaleFactor, - style: Theme.of(context).textTheme.bodyMedium, - onOpen: (link) async { - unawaited(launchUrl(Uri.parse(link.url))); - }), - const SizedBox(height: 20), - Align( - alignment: Alignment.topLeft, - child: Text(context.l10n.version, - style: Theme.of(context).textTheme.titleLarge)), - const SizedBox(height: 10), - Align( - alignment: Alignment.topLeft, - child: Text(ref.read(packageInfoProvider).version)) - ], - ), + child: Column( + children: [ + const PromoBlock(), + const SizedBox(height: 20), + Linkify( + text: context.l10n.appDescription, + // ignore: deprecated_member_use + textScaleFactor: MediaQuery.of(context).textScaleFactor, + style: Theme.of(context).textTheme.bodyMedium, + onOpen: (link) async { + unawaited(launchUrl(Uri.parse(link.url))); + }, + ), + const SizedBox(height: 10), + Text( + context.l10n.matthew10_8, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + const SizedBox(height: 10), + Text(context.l10n.noCopyright), + const SizedBox(height: 20), + Align( + alignment: Alignment.topLeft, + child: Text( + context.l10n.worksOffline, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 10), + Text(context.l10n.worksOfflineText), + const SizedBox(height: 20), + Align( + alignment: Alignment.topLeft, + child: Text( + context.l10n.secure, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 10), + Text(context.l10n.secureText), + const SizedBox(height: 20), + Align( + alignment: Alignment.topLeft, + child: Text( + context.l10n.contributing, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 10), + Linkify( + text: context.l10n.contributingText, + // ignore: deprecated_member_use + textScaleFactor: MediaQuery.of(context).textScaleFactor, + style: Theme.of(context).textTheme.bodyMedium, + onOpen: (link) async { + unawaited(launchUrl(Uri.parse(link.url))); + }, + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.topLeft, + child: Text( + context.l10n.openSource, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 10), + Linkify( + text: context.l10n.openSourceText, + // ignore: deprecated_member_use + textScaleFactor: MediaQuery.of(context).textScaleFactor, + style: Theme.of(context).textTheme.bodyMedium, + onOpen: (link) async { + unawaited(launchUrl(Uri.parse(link.url))); + }, + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.topLeft, + child: Text( + context.l10n.version, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.topLeft, + child: Text(ref.read(packageInfoProvider).version), + ), + ], ), ), ), + ), ); } } diff --git a/lib/routes/error_page.dart b/lib/routes/error_page.dart index 08abd48..c8f1fc8 100644 --- a/lib/routes/error_page.dart +++ b/lib/routes/error_page.dart @@ -14,10 +14,14 @@ class ErrorPage extends StatelessWidget { Widget build(BuildContext context) { debugPrint('Internal error: $message'); return Scaffold( - appBar: AppBar(title: const Text(Globals.appTitle)), - drawer: const MainDrawer(null, null), - body: SafeArea( - child: ErrorMessage( - context.l10n.error, context.l10n.internalError(message)))); + appBar: AppBar(title: const Text(Globals.appTitle)), + drawer: const MainDrawer(null, null), + body: SafeArea( + child: ErrorMessage( + context.l10n.error, + context.l10n.internalError(message), + ), + ), + ); } } diff --git a/lib/routes/home_page.dart b/lib/routes/home_page.dart index 9b33073..37cb076 100644 --- a/lib/routes/home_page.dart +++ b/lib/routes/home_page.dart @@ -14,13 +14,18 @@ class HomePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // When we're finished with loading: Go to the recently opened page return Scaffold( - appBar: AppBar( - title: const Text(Globals.appTitle), + appBar: AppBar(title: const Text(Globals.appTitle)), + drawer: const MainDrawer(null, null), + body: SafeArea( + child: TableOfContent( + null, + null, + header: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Text(context.l10n.homeExplanation), + ), ), - drawer: const MainDrawer(null, null), - body: SafeArea(child: TableOfContent(null, null, - header: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: Text(context.l10n.homeExplanation))))); + ), + ); } } diff --git a/lib/routes/onboarding/download_languages_page.dart b/lib/routes/onboarding/download_languages_page.dart index fe8f5d2..742a3fc 100644 --- a/lib/routes/onboarding/download_languages_page.dart +++ b/lib/routes/onboarding/download_languages_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:app4training/data/app_language.dart'; +import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:app4training/widgets/languages_table.dart'; @@ -23,84 +24,89 @@ class DownloadLanguagesPage extends ConsumerWidget { // unfortunately an ElevatedButton only looks greyed out when setting // onPressed to null - so create a greyed-out style by hand so that // we can have a clickable greyed-out button. - ButtonStyle buttonStyle = appLanguageDownloaded - ? ElevatedButton.styleFrom(shape: const StadiumBorder()) - : ElevatedButton.styleFrom( - // https://api.flutter.dev/flutter/material/ElevatedButton/defaultStyleOf.html - backgroundColor: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.12), - foregroundColor: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), - elevation: 0, - shape: const StadiumBorder(), - ); + ButtonStyle buttonStyle = + appLanguageDownloaded + ? ElevatedButton.styleFrom(shape: const StadiumBorder()) + : ElevatedButton.styleFrom( + // https://api.flutter.dev/flutter/material/ElevatedButton/defaultStyleOf.html + backgroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.12), + foregroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.38), + elevation: 0, + shape: const StadiumBorder(), + ); return Scaffold( - appBar: AppBar(title: Text(context.l10n.downloadLanguages)), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - Text(context.l10n.downloadLanguagesExplanation), - const SizedBox(height: 20), - Expanded( - child: LanguagesTable( - highlightLang: appLanguage.languageCode, - )), - const SizedBox(height: 20), - Row(children: [ - const Spacer(flex: 2), - ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const StadiumBorder(), + appBar: AppBar(title: Text(context.l10n.downloadLanguages)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + Text(context.l10n.downloadLanguagesExplanation), + const SizedBox(height: 20), + Expanded( + child: LanguagesTable(highlightLang: appLanguage.languageCode), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(flex: 2), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const StadiumBorder(), + ), + onPressed: () { + Navigator.pushReplacementNamed(context, '/onboarding/1'); + }, + child: Text(context.l10n.back), + ), + const Spacer(), + ElevatedButton( + style: buttonStyle, + onPressed: () async { + // Show warning if user hasn't downloaded his app language + if (!appLanguageDownloaded) { + await showDialog( + context: context, + builder: (context) { + return const MissingAppLanguageDialog(); + }, + ); + return; + } + unawaited( + Navigator.pushReplacementNamed( + context, + getNextRoute(ref), ), - onPressed: () { - Navigator.pushReplacementNamed( - context, '/onboarding/1'); - }, - child: Text(context.l10n.back), - ), - const Spacer(), - ElevatedButton( - style: buttonStyle, - onPressed: () async { - // Show warning if user hasn't downloaded his app language - if (!appLanguageDownloaded) { - await showDialog( - context: context, - builder: (context) { - return const MissingAppLanguageDialog(); - }); - return; - } - unawaited(Navigator.pushReplacementNamed( - context, getNextRoute(ref))); - }, - child: Text(context.l10n.continueText), - ), - const Spacer(flex: 2), - ]) - ], - )))); + ); + }, + child: Text(context.l10n.continueText), + ), + const Spacer(flex: 2), + ], + ), + ], + ), + ), + ), + ); } /// Which route should we continue with after this? - /// Currently (version 0.8) this is the last onboarding step and we proceed - /// to the home screen. - /// TODO for version 0.9: - /// During onboarding (no automatic updates settings saved): go to third step, - /// otherwise (user deleted all languages and ends up here): go to /home + /// During onboarding (no automatic-updates settings saved yet): go to the + /// third step to configure update preferences. + /// Otherwise (user deleted all languages and ended up here again): go to /home String getNextRoute(WidgetRef ref) { - return '/home'; -/* - // TODO for version 0.9 return ref.read(sharedPrefsProvider).getString('checkFrequency') == null ? '/onboarding/3' : '/home'; -*/ } } @@ -112,14 +118,16 @@ class MissingAppLanguageDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text(context.l10n.warning), - content: Text(context.l10n.warnMissingAppLanguage), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.gotit)), - ]); + title: Text(context.l10n.warning), + content: Text(context.l10n.warnMissingAppLanguage), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.gotit), + ), + ], + ); } } diff --git a/lib/routes/onboarding/set_update_prefs_page.dart b/lib/routes/onboarding/set_update_prefs_page.dart index 1444cc8..df76c86 100644 --- a/lib/routes/onboarding/set_update_prefs_page.dart +++ b/lib/routes/onboarding/set_update_prefs_page.dart @@ -15,62 +15,64 @@ class SetUpdatePrefsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: AppBar(title: Text(context.l10n.updates)), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(), - Text(context.l10n.updatesExplanation), - const Spacer(flex: 2), - Text(context.l10n.checkFrequency, - style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 10), - const DropdownButtonCheckFrequency(), - const Spacer(flex: 2), - Text(context.l10n.doAutomaticUpdates, - style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 10), - const DropdownButtonAutomaticUpdates(), - const Spacer(flex: 8), - Row(children: [ - const Spacer(flex: 2), - ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const StadiumBorder(), - ), - onPressed: () { - Navigator.pushReplacementNamed( - context, '/onboarding/2'); - }, - child: Text(context.l10n.back), - ), - const Spacer(), - ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const StadiumBorder(), - ), - onPressed: () { - // Make sure settings are saved: onboarding completed - ref - .read(automaticUpdatesProvider.notifier) - .persistNow(); - ref - .read(checkFrequencyProvider.notifier) - .persistNow(); - // Make sure background task gets scheduled - ref - .read(backgroundSchedulerProvider.notifier) - .schedule(); - Navigator.pushReplacementNamed(context, '/home'); - }, - child: Text(context.l10n.letsGo), - ), - const Spacer(flex: 2), - ]) - ], - )))); + appBar: AppBar(title: Text(context.l10n.updates)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Text(context.l10n.updatesExplanation), + const Spacer(flex: 2), + Text( + context.l10n.checkFrequency, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 10), + const DropdownButtonCheckFrequency(), + const Spacer(flex: 2), + Text( + context.l10n.doAutomaticUpdates, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 10), + const DropdownButtonAutomaticUpdates(), + const Spacer(flex: 8), + Row( + children: [ + const Spacer(flex: 2), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const StadiumBorder(), + ), + onPressed: () { + Navigator.pushReplacementNamed(context, '/onboarding/2'); + }, + child: Text(context.l10n.back), + ), + const Spacer(), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const StadiumBorder(), + ), + onPressed: () { + // Make sure settings are saved: onboarding completed + ref.read(automaticUpdatesProvider.notifier).persistNow(); + ref.read(checkFrequencyProvider.notifier).persistNow(); + // Make sure background task gets scheduled + ref.read(backgroundSchedulerProvider.notifier).schedule(); + Navigator.pushReplacementNamed(context, '/home'); + }, + child: Text(context.l10n.letsGo), + ), + const Spacer(flex: 2), + ], + ), + ], + ), + ), + ), + ); } } diff --git a/lib/routes/onboarding/welcome_page.dart b/lib/routes/onboarding/welcome_page.dart index 9d2c3bb..05d1a6f 100644 --- a/lib/routes/onboarding/welcome_page.dart +++ b/lib/routes/onboarding/welcome_page.dart @@ -14,8 +14,9 @@ class WelcomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text(Globals.appTitle)), - body: const SafeArea(child: WelcomeScreen())); + appBar: AppBar(title: const Text(Globals.appTitle)), + body: const SafeArea(child: WelcomeScreen()), + ); } } @@ -33,48 +34,58 @@ class WelcomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return LayoutBuilder( - builder: (BuildContext context, BoxConstraints viewportConstraints) { - return SingleChildScrollView( + builder: (BuildContext context, BoxConstraints viewportConstraints) { + return SingleChildScrollView( child: ConstrainedBox( - constraints: - BoxConstraints(minHeight: viewportConstraints.maxHeight), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(), - Text(context.l10n.welcome, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 40, fontWeight: FontWeight.bold)), - const Spacer(), - Text(context.l10n.selectAppLanguage), - const SizedBox(height: 20), - const DropdownButtonAppLanguage(), - const Spacer(), - const Divider(), - const PromoBlock(), - const Spacer(flex: 2), - ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const StadiumBorder(), - ), - onPressed: () { - // Make sure appLanguage is now saved - // so that WelcomePage won't be shown again next time - ref - .read(appLanguageProvider.notifier) - .persistNow(); - Navigator.pushReplacementNamed( - context, '/onboarding/2'); - }, - child: Text(context.l10n.continueText), - ), - ], - ))))); - }); + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Text( + context.l10n.welcome, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text(context.l10n.selectAppLanguage), + const SizedBox(height: 20), + const DropdownButtonAppLanguage(), + const Spacer(), + const Divider(), + const PromoBlock(), + const Spacer(flex: 2), + ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const StadiumBorder(), + ), + onPressed: () { + // Make sure appLanguage is now saved + // so that WelcomePage won't be shown again next time + ref.read(appLanguageProvider.notifier).persistNow(); + Navigator.pushReplacementNamed( + context, + '/onboarding/2', + ); + }, + child: Text(context.l10n.continueText), + ), + ], + ), + ), + ), + ), + ); + }, + ); } } @@ -101,8 +112,11 @@ class PromoBlock extends StatelessWidget { const SizedBox(height: 10), Image.asset('assets/for_training.png', width: 100, height: 100), Center( - child: Text(context.l10n.appName, - style: Theme.of(context).textTheme.headlineMedium)), + child: Text( + context.l10n.appName, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), const SizedBox(height: 10), Align( alignment: Alignment.center, @@ -125,10 +139,10 @@ class PromoBlock extends StatelessWidget { Text( '• ${context.l10n.promoFeature3}', style: Theme.of(context).textTheme.bodyLarge, - ) + ), ], ), - ) + ), ], ); } diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index 2ee432c..3a47353 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -18,38 +18,54 @@ Route generateRoutes(RouteSettings settings) { ); } else if (settings.name == '/home') { return MaterialPageRoute( - settings: settings, builder: (_) => const HomePage()); + settings: settings, + builder: (_) => const HomePage(), + ); } else if (settings.name!.startsWith('/view')) { // route should be /view/pageName/langCode - deep linking is possible final List parts = settings.name!.split('/'); if ((parts.length <= 3) || (parts[2] == '') || (parts[3] == '')) { debugPrint('Unexpected route ${settings.name} - redirecting to /home'); return MaterialPageRoute( - settings: settings, builder: (_) => const HomePage()); + settings: settings, + builder: (_) => const HomePage(), + ); } String page = parts[2]; String langCode = parts[3]; return MaterialPageRoute( - settings: settings, builder: (_) => ViewPage(page, langCode)); + settings: settings, + builder: (_) => ViewPage(page, langCode), + ); } else if (settings.name == '/settings') { return MaterialPageRoute( - settings: settings, builder: (_) => const SettingsPage()); + settings: settings, + builder: (_) => const SettingsPage(), + ); } else if (settings.name == '/about') { return MaterialPageRoute( - settings: settings, builder: (_) => const AboutPage()); + settings: settings, + builder: (_) => const AboutPage(), + ); } else if (settings.name!.startsWith('/onboarding')) { final List parts = settings.name!.split('/'); String step = '1'; if ((parts.length > 2) && (parts[2] != '')) step = parts[2]; if (step == '1') { return MaterialPageRoute( - settings: settings, builder: (_) => const WelcomePage()); + settings: settings, + builder: (_) => const WelcomePage(), + ); } else if (step == '2') { return MaterialPageRoute( - settings: settings, builder: (_) => const DownloadLanguagesPage()); + settings: settings, + builder: (_) => const DownloadLanguagesPage(), + ); } else if (step == '3') { return MaterialPageRoute( - settings: settings, builder: (_) => const SetUpdatePrefsPage()); + settings: settings, + builder: (_) => const SetUpdatePrefsPage(), + ); } } diff --git a/lib/routes/settings_page.dart b/lib/routes/settings_page.dart index b7a7627..a153ab3 100644 --- a/lib/routes/settings_page.dart +++ b/lib/routes/settings_page.dart @@ -1,14 +1,15 @@ -//import 'package:app4training/widgets/dropdownbutton_automatic_updates.dart'; +import 'package:app4training/widgets/dropdownbutton_automatic_updates.dart'; import 'package:app4training/features/perf/perf_export_section.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:app4training/data/updates.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:app4training/widgets/check_now_button.dart'; +import 'package:app4training/widgets/confirm_updates_prompt.dart'; import 'package:app4training/widgets/languages_table.dart'; import 'package:intl/intl.dart'; import '../widgets/dropdownbutton_app_language.dart'; -//import '../widgets/dropdownbutton_check_frequency.dart'; +import '../widgets/dropdownbutton_check_frequency.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -20,24 +21,28 @@ class SettingsPage extends StatelessWidget { body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), - child: Column(children: [ - // Set app language - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(context.l10n.appLanguage, - style: Theme.of(context).textTheme.bodyMedium), - const DropdownButtonAppLanguage(), - ], - ), - const SizedBox(height: 10), - const Expanded(child: LanguageSettings()), - const UpdateSettings(), - // Only visible in instrumented tester builds - const PerfExportSection() - // const SizedBox(height: 10), - // const DesignSettings() - ]), + child: Column( + children: [ + // Set app language + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.appLanguage, + style: Theme.of(context).textTheme.bodyMedium, + ), + const DropdownButtonAppLanguage(), + ], + ), + const SizedBox(height: 10), + const Expanded(child: LanguageSettings()), + const UpdateSettings(), + // Only visible in instrumented tester builds + const PerfExportSection(), + // const SizedBox(height: 10), + // const DesignSettings() + ], + ), ), ), ); @@ -51,22 +56,25 @@ class LanguageSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Column(children: [ - Align( + return Column( + children: [ + Align( alignment: Alignment.topLeft, child: Text( context.l10n.languages, style: Theme.of(context).textTheme.titleLarge, - )), - const SizedBox(height: 10), - Text( - context.l10n.languagesText, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 10), - const Expanded(child: LanguagesTable()), - const SizedBox(height: 10), - ]); + ), + ), + const SizedBox(height: 10), + Text( + context.l10n.languagesText, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + const Expanded(child: LanguagesTable()), + const SizedBox(height: 10), + ], + ); } } @@ -81,51 +89,70 @@ class UpdateSettings extends ConsumerWidget { DateTime localTime = lastCheck.add(DateTime.now().timeZoneOffset); String timestamp = DateFormat('yyyy-MM-dd HH:mm').format(localTime); - return Column(children: [ - // Updates (headline) - Align( + return Column( + children: [ + // Updates (headline) + Align( alignment: Alignment.topLeft, - child: Text(context.l10n.updates, - style: Theme.of(context).textTheme.titleLarge)), - // Check for updates TODO for version 0.9 -/* Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text(context.l10n.checkFrequency, - style: Theme.of(context).textTheme.bodyMedium)), - const SizedBox(width: 20), - const DropdownButtonCheckFrequency(), - ], - ), - const SizedBox(height: 10),*/ - // Last check with date - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("${context.l10n.lastCheck} ", - style: Theme.of(context).textTheme.bodyMedium), - Text(timestamp, style: Theme.of(context).textTheme.bodyMedium) - ]), - const SizedBox(height: 10), - // Check now - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [CheckNowButton(buttonText: context.l10n.checkNow)], - ), -/* const SizedBox(height: 10), - // Do automatic updates TODO for version 0.9 + child: Text( + context.l10n.updates, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + // Prompt to confirm downloading updates (requireConfirmation mode). + // Renders nothing in the other AutomaticUpdates modes. + const ConfirmUpdatesPrompt(), + // Check for updates + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + context.l10n.checkFrequency, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: 20), + const DropdownButtonCheckFrequency(), + ], + ), + const SizedBox(height: 10), + // Last check with date + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${context.l10n.lastCheck} ", + style: Theme.of(context).textTheme.bodyMedium, + ), + Text(timestamp, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + const SizedBox(height: 10), + // Check now + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [CheckNowButton(buttonText: context.l10n.checkNow)], + ), + const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text(context.l10n.doAutomaticUpdates, - style: Theme.of(context).textTheme.bodyMedium)), - const SizedBox(width: 20), - const DropdownButtonAutomaticUpdates(), - ], - ),*/ - ]); + // Do automatic updates + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + context.l10n.doAutomaticUpdates, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: 20), + const DropdownButtonAutomaticUpdates(), + ], + ), + ], + ); } } diff --git a/lib/routes/startup_page.dart b/lib/routes/startup_page.dart index 8823e15..cc6fb67 100644 --- a/lib/routes/startup_page.dart +++ b/lib/routes/startup_page.dart @@ -72,15 +72,17 @@ class _StartupPageState extends ConsumerState { // Step 1: Which languages are on the device? stage.report(StartupStage.checkingLanguages); - final List availableLanguages = - ref.read(availableLanguagesProvider); + final List availableLanguages = ref.read( + availableLanguagesProvider, + ); await PerfLogger.span( - 'startup.lazyInitAll', - () => Future.wait([ - for (String languageCode in availableLanguages) - ref.read(languageProvider(languageCode).notifier).lazyInit() - ]), - data: () => {'languages': availableLanguages.length}); + 'startup.lazyInitAll', + () => Future.wait([ + for (String languageCode in availableLanguages) + ref.read(languageProvider(languageCode).notifier).lazyInit(), + ]), + data: () => {'languages': availableLanguages.length}, + ); // Check whether app language is downloaded final String appLangCode = ref.read(appLanguageProvider).languageCode; @@ -88,17 +90,17 @@ class _StartupPageState extends ConsumerState { return '/onboarding/2'; // Go to DownloadLanguagesPage } - /* TODO for version 0.9 // Check whether user completed third onboarding step if (ref.read(sharedPrefsProvider).getString('checkFrequency') == null) { return '/onboarding/3'; - }*/ + } // Go to recently opened page or to /home String navigateTo = '/home'; String page = ref.read(sharedPrefsProvider).getString('recentPage') ?? ''; String lang = ref.read(sharedPrefsProvider).getString('recentLang') ?? ''; - final bool resumeRecentPage = (page != '') && + final bool resumeRecentPage = + (page != '') && (lang != '') && ref.read(languageProvider(lang)).downloaded; if (resumeRecentPage) navigateTo = '/view/$page/$lang'; @@ -115,34 +117,46 @@ class _StartupPageState extends ConsumerState { // 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 + 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}); + 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 // rather than the WidgetRef: this page is disposed as soon as we navigate // away, and a disposed WidgetRef must not be used any more. - unawaited(_loadRemainingLanguages([ - for (String languageCode in availableLanguages) - if (!neededNow.contains(languageCode) && - ref.read(languageProvider(languageCode)).downloaded) - ref.read(languageProvider(languageCode).notifier) - ])); + unawaited( + _loadRemainingLanguages([ + for (String languageCode in availableLanguages) + if (!neededNow.contains(languageCode) && + ref.read(languageProvider(languageCode)).downloaded) + ref.read(languageProvider(languageCode).notifier), + ]), + ); // Start the periodic background task unawaited(ref.read(backgroundSchedulerProvider.notifier).schedule()); // Only the kind of destination - never which page/language (no PII) - PerfLogger.event('startup.navigate', - data: {'destination': resumeRecentPage ? 'view' : 'home'}); + PerfLogger.event( + 'startup.navigate', + data: {'destination': resumeRecentPage ? 'view' : 'home'}, + ); return navigateTo; } @@ -152,7 +166,8 @@ class _StartupPageState extends ConsumerState { /// limit so we don't flood the IO queue of a slow device while it is still /// busy rendering that screen. Future _loadRemainingLanguages( - List controllers) async { + List controllers, + ) async { final pending = Queue.of(controllers); Future worker() async { @@ -162,10 +177,12 @@ class _StartupPageState extends ConsumerState { } await PerfLogger.span( - 'startup.loadRemaining', - () => Future.wait( - [for (var i = 0; i < _maxParallelLanguageLoads; i++) worker()]), - data: () => {'languages': controllers.length}); + 'startup.loadRemaining', + () => Future.wait([ + for (var i = 0; i < _maxParallelLanguageLoads; i++) worker(), + ]), + data: () => {'languages': controllers.length}, + ); } @override @@ -195,6 +212,7 @@ class _StartupCaption extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Text( - StartupStage.getLocalized(context, ref.watch(startupStageProvider))); + StartupStage.getLocalized(context, ref.watch(startupStageProvider)), + ); } } diff --git a/lib/routes/view_page.dart b/lib/routes/view_page.dart index 45ebe2a..924910d 100644 --- a/lib/routes/view_page.dart +++ b/lib/routes/view_page.dart @@ -39,75 +39,84 @@ class ViewPage extends ConsumerWidget { .watch(scaffoldMessengerProvider) .showSnackBar(SnackBar(content: Text(l10n.foundBgActivity))); } - return ref - .watch(pageContentProvider((name: page, langCode: langCode)).future); + return ref.watch( + pageContentProvider((name: page, langCode: langCode)).future, + ); }); } @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: AppBar( - title: const Text(Globals.appTitle), - actions: const [ShareButton(), LanguageSelectionButton()], - ), - drawer: MainDrawer(page, langCode), - body: FutureBuilder( - future: checkAndLoad(context, ref), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (kDebugMode) debugPrint(snapshot.connectionState.toString()); + appBar: AppBar( + title: const Text(Globals.appTitle), + actions: const [ShareButton(), LanguageSelectionButton()], + ), + drawer: MainDrawer(page, langCode), + body: FutureBuilder( + future: checkAndLoad(context, ref), + 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(context.l10n.loadingContent); - case ConnectionState.done: - if (kDebugMode) { - debugPrint('Done, hasData: ${snapshot.hasData},' - ' Error: ${snapshot.hasError}'); - } - if (snapshot.hasError) { - // In Riverpod v3, provider errors are wrapped in - // ProviderException - unwrap to get the original error - var e = snapshot.error; - if (e is ProviderException) { - e = e.exception; - } - if (e is App4TrainingException) { - if ((e is PageNotFoundException) || - (e is LanguageNotDownloadedException)) { - return ErrorMessage( - context.l10n.warning, - '${context.l10n.cantDisplayPage(page, context.l10n.getLanguageName(langCode))}\n' - '${context.l10n.reason} ${e.toLocalizedString(context)}', - icon: Icons.warning_amber, - iconColor: Colors.black); - } else if (e is LanguageCorruptedException) { - return ErrorMessage( - context.l10n.error, e.toLocalizedString(context)); - } - } - // What happened?! - return ErrorMessage(context.l10n.error, - context.l10n.internalError(e.toString())); - } else { - String content = snapshot.data; - // Save the selected page to the SharedPreferences to continue here - // in case the user closes the app - ref.read(sharedPrefsProvider).setString('recentPage', page); - ref - .read(sharedPrefsProvider) - .setString('recentLang', langCode); - return SafeArea( - child: HtmlView( - content, - (Globals.rtlLanguages.contains(langCode)) - ? TextDirection.rtl - : TextDirection.ltr), + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + return loadingAnimation(context.l10n.loadingContent); + case ConnectionState.done: + if (kDebugMode) { + debugPrint( + 'Done, hasData: ${snapshot.hasData},' + ' Error: ${snapshot.hasError}', + ); + } + if (snapshot.hasError) { + // In Riverpod v3, provider errors are wrapped in + // ProviderException - unwrap to get the original error + var e = snapshot.error; + if (e is ProviderException) { + e = e.exception; + } + if (e is App4TrainingException) { + if ((e is PageNotFoundException) || + (e is LanguageNotDownloadedException)) { + return ErrorMessage( + context.l10n.warning, + '${context.l10n.cantDisplayPage(page, context.l10n.getLanguageName(langCode))}\n' + '${context.l10n.reason} ${e.toLocalizedString(context)}', + icon: Icons.warning_amber, + iconColor: Colors.black, + ); + } else if (e is LanguageCorruptedException) { + return ErrorMessage( + context.l10n.error, + e.toLocalizedString(context), ); } + } + // What happened?! + return ErrorMessage( + context.l10n.error, + context.l10n.internalError(e.toString()), + ); + } else { + String content = snapshot.data; + // Save the selected page to the SharedPreferences to continue here + // in case the user closes the app + ref.read(sharedPrefsProvider).setString('recentPage', page); + ref.read(sharedPrefsProvider).setString('recentLang', langCode); + return SafeArea( + child: HtmlView( + content, + (Globals.rtlLanguages.contains(langCode)) + ? TextDirection.rtl + : TextDirection.ltr, + ), + ); } - })); + } + }, + ), + ); } } diff --git a/lib/widgets/check_now_button.dart b/lib/widgets/check_now_button.dart index 0082d9e..47f8db3 100644 --- a/lib/widgets/check_now_button.dart +++ b/lib/widgets/check_now_button.dart @@ -22,54 +22,73 @@ class _CheckNowButtonState extends ConsumerState { @override Widget build(BuildContext context) { return ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const StadiumBorder(), fixedSize: const Size(150, 36)), - onPressed: () async { - setState(() { - _isLoading = true; - }); + style: ElevatedButton.styleFrom( + shape: const StadiumBorder(), + fixedSize: const Size(150, 36), + ), + onPressed: () async { + setState(() { + _isLoading = true; + }); - bool hasError = false; - bool exceededLimit = false; - int countAvailableUpdates = 0; - // Get l10n now as we can't access context after async gap later - AppLocalizations l10n = context.l10n; + bool hasError = false; + bool exceededLimit = false; + int countAvailableUpdates = 0; + // Get l10n now as we can't access context after async gap later + AppLocalizations l10n = context.l10n; - for (String languageCode in ref.read(availableLanguagesProvider)) { - // We don't check languages that are not downloaded - if (!ref.read(languageProvider(languageCode)).downloaded) continue; - int result = await ref - .read(languageStatusProvider(languageCode).notifier) - .check(); - if (result < 0) { - hasError = true; - if (result == apiRateLimitExceeded) { - exceededLimit = true; - break; - } + for (String languageCode in ref.read(availableLanguagesProvider)) { + // We don't check languages that are not downloaded + if (!ref.read(languageProvider(languageCode)).downloaded) continue; + int result = + await ref + .read(languageStatusProvider(languageCode).notifier) + .check(); + if (result < 0) { + hasError = true; + if (result == apiRateLimitExceeded) { + exceededLimit = true; + break; } - if (result > 0) countAvailableUpdates++; } + if (result > 0) countAvailableUpdates++; + } - if (!hasError) { - ref.watch(scaffoldMessengerProvider).showSnackBar(SnackBar( - content: Text(l10n.nUpdatesAvailable(countAvailableUpdates)))); + if (!hasError) { + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar( + content: Text(l10n.nUpdatesAvailable(countAvailableUpdates)), + ), + ); + } else { + if (exceededLimit) { + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar(content: Text(l10n.checkingUpdatesLimit)), + ); } else { - if (exceededLimit) { - ref.watch(scaffoldMessengerProvider).showSnackBar( - SnackBar(content: Text(l10n.checkingUpdatesLimit))); - } else { - ref.watch(scaffoldMessengerProvider).showSnackBar( - SnackBar(content: Text(l10n.checkingUpdatesError))); - } + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar(content: Text(l10n.checkingUpdatesError)), + ); } - setState(() { - _isLoading = false; - }); - }, - child: _isLoading // Show Text or loading animation depending on state - ? const SizedBox( - height: 25, width: 25, child: CircularProgressIndicator()) - : Text(widget.buttonText)); + } + setState(() { + _isLoading = false; + }); + }, + child: + _isLoading // Show Text or loading animation depending on state + ? const SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator(), + ) + : Text(widget.buttonText), + ); } } diff --git a/lib/widgets/confirm_updates_prompt.dart b/lib/widgets/confirm_updates_prompt.dart new file mode 100644 index 0000000..3de02cd --- /dev/null +++ b/lib/widgets/confirm_updates_prompt.dart @@ -0,0 +1,92 @@ +import 'package:app4training/data/bulk_language_download.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/languages.dart'; +import 'package:app4training/data/updates.dart'; +import 'package:app4training/l10n/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Persistent prompt shown on the settings page when +/// [AutomaticUpdates.requireConfirmation] is set and the background task has +/// found updates it deliberately did not auto-download. Lets the user confirm +/// downloading them via the normal foreground download path. +/// +/// Renders nothing unless [updatesNeedConfirmationProvider] is true, so it +/// silently disappears once the user has downloaded the updates (or under any +/// other AutomaticUpdates mode). +class ConfirmUpdatesPrompt extends ConsumerStatefulWidget { + const ConfirmUpdatesPrompt({super.key}); + + @override + ConsumerState createState() => + _ConfirmUpdatesPromptState(); +} + +class _ConfirmUpdatesPromptState extends ConsumerState { + bool _isLoading = false; + + Future _downloadUpdates() async { + setState(() => _isLoading = true); + // Get l10n now as we can't access context after the async gap + final l10n = context.l10n; + final codesToUpdate = [ + for (final languageCode in ref.read(availableLanguagesProvider)) + if (ref.read(languageStatusProvider(languageCode)).updatesAvailable && + ref.read(languageProvider(languageCode)).downloaded) + languageCode, + ]; + final result = await downloadLanguagesInParallel( + codesToUpdate, + download: (code) => ref.read(languageProvider(code).notifier).download(), + ); + if (result.successCount > 0) { + final text = + (result.successCount == 1) + ? l10n.updatedLanguage( + l10n.getLanguageName(result.lastSuccessCode), + ) + : l10n.updatedNLanguages(result.successCount, result.errorCount); + ref + .read(scaffoldMessengerProvider) + .showSnackBar(SnackBar(content: Text(text))); + } else if (result.errorCount > 0) { + ref + .read(scaffoldMessengerProvider) + .showSnackBar(SnackBar(content: Text(l10n.updateError))); + } + if (mounted) setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + // Only surface anything when updates await the user's confirmation + if (!ref.watch(updatesNeedConfirmationProvider)) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + context.l10n.updatesReadyToDownload, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: 10), + _isLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ) + : ElevatedButton( + style: ElevatedButton.styleFrom(shape: const StadiumBorder()), + onPressed: _downloadUpdates, + child: Text(context.l10n.downloadUpdatesNow), + ), + ], + ); + } +} diff --git a/lib/widgets/delete_language_button.dart b/lib/widgets/delete_language_button.dart index e18fca1..36763ac 100644 --- a/lib/widgets/delete_language_button.dart +++ b/lib/widgets/delete_language_button.dart @@ -23,29 +23,35 @@ class DeleteLanguageButton extends ConsumerWidget { LanguageController lang = ref.read(languageProvider(languageCode).notifier); return IconButton( - onPressed: () async { - // snackbar to be shown after the resources are deleted - final snackBar = SnackBar( - content: Text(context.l10n - .deletedLanguage(context.l10n.getLanguageName(languageCode))), - duration: snackBarQuickSuccessDuration, + onPressed: () async { + // snackbar to be shown after the resources are deleted + final snackBar = SnackBar( + content: Text( + context.l10n.deletedLanguage( + context.l10n.getLanguageName(languageCode), + ), + ), + duration: snackBarQuickSuccessDuration, + ); + if (isDiscouraged) { + bool result = await showDialog( + context: context, + builder: (context) { + return const ConfirmDeletionDialog(); + }, ); - if (isDiscouraged) { - bool result = await showDialog( - context: context, - builder: (context) { - return const ConfirmDeletionDialog(); - }); - if (!result) return; - } - await lang.deleteResources(); - ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); - }, - icon: const Icon(Icons.delete), - color: isDiscouraged - ? Theme.of(context).colorScheme.inversePrimary - : Theme.of(context).colorScheme.primary, - padding: EdgeInsets.zero); + if (!result) return; + } + await lang.deleteResources(); + ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); + }, + icon: const Icon(Icons.delete), + color: + isDiscouraged + ? Theme.of(context).colorScheme.inversePrimary + : Theme.of(context).colorScheme.primary, + padding: EdgeInsets.zero, + ); } } @@ -57,38 +63,40 @@ class DeleteAllLanguagesButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return IconButton( - onPressed: () async { - // snackbar to be shown after the resources are deleted - // Get l10n now as we can't access context after async gap later - AppLocalizations l10n = context.l10n; - int countDeleted = 0; - String lastLanguage = ''; - for (var languageCode in ref.read(availableLanguagesProvider)) { - // Delete all languages except the current app language - if (languageCode == ref.read(appLanguageProvider).languageCode) { - continue; - } - if (ref.watch(languageProvider(languageCode)).downloaded) { - await ref - .read(languageProvider(languageCode).notifier) - .deleteResources(); - countDeleted++; - lastLanguage = languageCode; - } + onPressed: () async { + // snackbar to be shown after the resources are deleted + // Get l10n now as we can't access context after async gap later + AppLocalizations l10n = context.l10n; + int countDeleted = 0; + String lastLanguage = ''; + for (var languageCode in ref.read(availableLanguagesProvider)) { + // Delete all languages except the current app language + if (languageCode == ref.read(appLanguageProvider).languageCode) { + continue; } - - if (countDeleted > 0) { - // Show info message in snackbar - String text = (countDeleted == 1) - ? l10n.deletedLanguage(l10n.getLanguageName(lastLanguage)) - : l10n.deletedNLanguages(countDeleted); - final snackBar = SnackBar(content: Text(text)); - ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); + if (ref.watch(languageProvider(languageCode)).downloaded) { + await ref + .read(languageProvider(languageCode).notifier) + .deleteResources(); + countDeleted++; + lastLanguage = languageCode; } - }, - icon: const Icon(Icons.delete), - color: Theme.of(context).colorScheme.primary, - padding: EdgeInsets.zero); + } + + if (countDeleted > 0) { + // Show info message in snackbar + String text = + (countDeleted == 1) + ? l10n.deletedLanguage(l10n.getLanguageName(lastLanguage)) + : l10n.deletedNLanguages(countDeleted); + final snackBar = SnackBar(content: Text(text)); + ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); + } + }, + icon: const Icon(Icons.delete), + color: Theme.of(context).colorScheme.primary, + padding: EdgeInsets.zero, + ); } } @@ -100,19 +108,22 @@ class ConfirmDeletionDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text(context.l10n.warning), - content: Text(context.l10n.warnBeforeDelete), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.cancel)), - TextButton( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.delete)) - ]); + title: Text(context.l10n.warning), + content: Text(context.l10n.warnBeforeDelete), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.delete), + ), + ], + ); } } diff --git a/lib/widgets/download_language_button.dart b/lib/widgets/download_language_button.dart index 7af050a..501e914 100644 --- a/lib/widgets/download_language_button.dart +++ b/lib/widgets/download_language_button.dart @@ -14,8 +14,11 @@ class DownloadLanguageButton extends ConsumerStatefulWidget { /// Should the button be highlighted? final bool highlight; - const DownloadLanguageButton(this.languageCode, - {this.highlight = false, super.key}); + const DownloadLanguageButton( + this.languageCode, { + this.highlight = false, + super.key, + }); @override ConsumerState createState() => @@ -28,44 +31,62 @@ class _DownloadLanguageButtonState @override Widget build(BuildContext context) { - LanguageController lang = - ref.watch(languageProvider(widget.languageCode).notifier); + LanguageController lang = ref.watch( + languageProvider(widget.languageCode).notifier, + ); - Widget ourWidget = _isLoading - ? const Center( - child: SizedBox( - height: 24, width: 24, child: CircularProgressIndicator())) - : IconButton( - onPressed: () async { - setState(() { - _isLoading = true; - }); - // Get l10n now as we can't access context after async gap later - AppLocalizations l10n = context.l10n; + Widget ourWidget = + _isLoading + ? const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ), + ) + : IconButton( + onPressed: () async { + setState(() { + _isLoading = true; + }); + // Get l10n now as we can't access context after async gap later + AppLocalizations l10n = context.l10n; - bool success = await lang.download(); + bool success = await lang.download(); - ref.watch(scaffoldMessengerProvider).showSnackBar(SnackBar( - duration: success - ? snackBarQuickSuccessDuration - : snackBarErrorDuration, - content: Text(success - ? l10n.downloadedLanguage( - l10n.getLanguageName(widget.languageCode)) - : l10n.downloadError))); - setState(() { - _isLoading = false; - }); - }, - icon: const Icon(Icons.download), - padding: EdgeInsets.zero); + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar( + duration: + success + ? snackBarQuickSuccessDuration + : snackBarErrorDuration, + content: Text( + success + ? l10n.downloadedLanguage( + l10n.getLanguageName(widget.languageCode), + ) + : l10n.downloadError, + ), + ), + ); + setState(() { + _isLoading = false; + }); + }, + icon: const Icon(Icons.download), + padding: EdgeInsets.zero, + ); return widget.highlight ? Container( - decoration: BoxDecoration( - color: Theme.of(context).highlightColor, - borderRadius: BorderRadius.circular(8.0)), - child: ourWidget) + decoration: BoxDecoration( + color: Theme.of(context).highlightColor, + borderRadius: BorderRadius.circular(8.0), + ), + child: ourWidget, + ) : ourWidget; } } @@ -93,65 +114,82 @@ class _DownloadAllLanguagesButtonState @override Widget build(BuildContext context) { if (_isLoading) { - return Row(mainAxisSize: MainAxisSize.min, children: [ - SizedBox( + 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), - ]); + 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 { - // Get l10n now as we can't access context after async gap later - final l10n = context.l10n; - final codesToDownload = [ - for (final languageCode in ref.read(availableLanguagesProvider)) - 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 - String text = (result.successCount == 1) + width: 32, + child: IconButton( + onPressed: () async { + // Get l10n now as we can't access context after async gap later + final l10n = context.l10n; + final codesToDownload = [ + for (final languageCode in ref.read(availableLanguagesProvider)) + 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 + String text = + (result.successCount == 1) ? l10n.downloadedLanguage( - l10n.getLanguageName(result.lastSuccessCode)) + l10n.getLanguageName(result.lastSuccessCode), + ) : l10n.downloadedNLanguages(result.successCount); - final snackBar = SnackBar( - content: Text(text), - duration: snackBarQuickSuccessDuration); - ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); - } - if (result.errorCount > 0) { - ref.watch(scaffoldMessengerProvider).showSnackBar(SnackBar( + final snackBar = SnackBar( + content: Text(text), + duration: snackBarQuickSuccessDuration, + ); + ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); + } + if (result.errorCount > 0) { + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar( content: Text(l10n.downloadError), - duration: snackBarErrorDuration)); - } - setState(() { - _isLoading = false; - }); - }, - icon: const Icon(Icons.download), - padding: EdgeInsets.zero)); + duration: snackBarErrorDuration, + ), + ); + } + setState(() { + _isLoading = false; + }); + }, + icon: const Icon(Icons.download), + padding: EdgeInsets.zero, + ), + ); } } diff --git a/lib/widgets/dropdownbutton_app_language.dart b/lib/widgets/dropdownbutton_app_language.dart index 9337995..ad5604b 100644 --- a/lib/widgets/dropdownbutton_app_language.dart +++ b/lib/widgets/dropdownbutton_app_language.dart @@ -10,13 +10,14 @@ class DropdownButtonAppLanguage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final AppLanguage appLanguage = ref.watch(appLanguageProvider); return DropdownButton( - value: appLanguage.toString(), - items: [ - for (MapEntry item in AppLanguage.availableAppLanguages.entries) - DropdownMenuItem(value: item.key, child: Text(item.value)) - ], - onChanged: (String? value) { - ref.read(appLanguageProvider.notifier).setLocale(value!); - }); + value: appLanguage.toString(), + items: [ + for (MapEntry item in AppLanguage.availableAppLanguages.entries) + DropdownMenuItem(value: item.key, child: Text(item.value)), + ], + onChanged: (String? value) { + ref.read(appLanguageProvider.notifier).setLocale(value!); + }, + ); } } diff --git a/lib/widgets/dropdownbutton_automatic_updates.dart b/lib/widgets/dropdownbutton_automatic_updates.dart index 8775fc0..8c6bb5d 100644 --- a/lib/widgets/dropdownbutton_automatic_updates.dart +++ b/lib/widgets/dropdownbutton_automatic_updates.dart @@ -10,18 +10,17 @@ class DropdownButtonAutomaticUpdates extends ConsumerWidget { final AutomaticUpdates setting = ref.watch(automaticUpdatesProvider); return DropdownButton( - value: setting.name, - items: [ - for (var option in AutomaticUpdates.values) - DropdownMenuItem( - value: option.name, - child: Text(AutomaticUpdates.getLocalized(context, option)), - ) - ], - onChanged: (String? value) { - ref - .read(automaticUpdatesProvider.notifier) - .setAutomaticUpdates(value); - }); + value: setting.name, + items: [ + for (var option in AutomaticUpdates.values) + DropdownMenuItem( + value: option.name, + child: Text(AutomaticUpdates.getLocalized(context, option)), + ), + ], + onChanged: (String? value) { + ref.read(automaticUpdatesProvider.notifier).setAutomaticUpdates(value); + }, + ); } } diff --git a/lib/widgets/dropdownbutton_check_frequency.dart b/lib/widgets/dropdownbutton_check_frequency.dart index 0c7d568..a7070bf 100644 --- a/lib/widgets/dropdownbutton_check_frequency.dart +++ b/lib/widgets/dropdownbutton_check_frequency.dart @@ -10,16 +10,17 @@ class DropdownButtonCheckFrequency extends ConsumerWidget { final CheckFrequency checkFrequency = ref.watch(checkFrequencyProvider); return DropdownButton( - value: checkFrequency.name, - items: [ - for (var frequency in CheckFrequency.values) - DropdownMenuItem( - value: frequency.name, - child: Text(CheckFrequency.getLocalized(context, frequency)), - ) - ], - onChanged: (String? value) { - ref.read(checkFrequencyProvider.notifier).setCheckFrequency(value); - }); + value: checkFrequency.name, + items: [ + for (var frequency in CheckFrequency.values) + DropdownMenuItem( + value: frequency.name, + child: Text(CheckFrequency.getLocalized(context, frequency)), + ), + ], + onChanged: (String? value) { + ref.read(checkFrequencyProvider.notifier).setCheckFrequency(value); + }, + ); } } diff --git a/lib/widgets/error_message.dart b/lib/widgets/error_message.dart index 52df8e3..d523628 100644 --- a/lib/widgets/error_message.dart +++ b/lib/widgets/error_message.dart @@ -5,33 +5,42 @@ class ErrorMessage extends StatelessWidget { final String message; final IconData icon; final Color iconColor; - const ErrorMessage(this.title, this.message, - {this.icon = Icons.error, this.iconColor = Colors.red, super.key}); + const ErrorMessage( + this.title, + this.message, { + this.icon = Icons.error, + this.iconColor = Colors.red, + super.key, + }); @override Widget build(BuildContext context) { return Container( - color: Colors.grey, - alignment: Alignment.center, - child: Column(children: [ + color: Colors.grey, + alignment: Alignment.center, + child: Column( + children: [ const Spacer(), Container( - decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10)), - margin: const EdgeInsets.all(20), - padding: const EdgeInsets.all(20), - child: Column(children: [ - Icon( - icon, - color: iconColor, - size: 48, - ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.all(20), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Icon(icon, color: iconColor, size: 48), const SizedBox(height: 10), Text(title, style: Theme.of(context).textTheme.headlineLarge), const SizedBox(height: 20), - Text(message) - ])), - const Spacer() - ])); + Text(message), + ], + ), + ), + const Spacer(), + ], + ), + ); } } diff --git a/lib/widgets/html_view.dart b/lib/widgets/html_view.dart index 8fe6ed4..d97eb65 100644 --- a/lib/widgets/html_view.dart +++ b/lib/widgets/html_view.dart @@ -45,8 +45,10 @@ class _HtmlViewState extends State { // Synchronous CPU work on the UI isolate right before a page appears - // one of the render-time suspects, so worth a span of its own _document = PerfLogger.spanSync( - 'page.sanitize', () => sanitize(widget.content, isDarkMode), - data: () => {'htmlBytes': widget.content.length}); + 'page.sanitize', + () => sanitize(widget.content, isDarkMode), + data: () => {'htmlBytes': widget.content.length}, + ); _documentIsDarkMode = isDarkMode; } return _document!; diff --git a/lib/widgets/language_selection_button.dart b/lib/widgets/language_selection_button.dart index 4daebba..f67ba04 100644 --- a/lib/widgets/language_selection_button.dart +++ b/lib/widgets/language_selection_button.dart @@ -16,11 +16,14 @@ class LanguageSelectionButton extends ConsumerWidget { final List menuItems = []; // Sort languages alphabetically - List sortedLanguages = - List.from(ref.read(availableLanguagesProvider)); - sortedLanguages.sort((a, b) => context.l10n - .getLanguageName(a) - .compareTo(context.l10n.getLanguageName(b))); + List sortedLanguages = List.from( + ref.read(availableLanguagesProvider), + ); + sortedLanguages.sort( + (a, b) => context.l10n + .getLanguageName(a) + .compareTo(context.l10n.getLanguageName(b)), + ); // Construct items for all available translations of the current page for (var langCode in sortedLanguages) { @@ -30,24 +33,25 @@ class LanguageSelectionButton extends ConsumerWidget { context.findAncestorWidgetOfExactType()!.page; if (!language.pages.containsKey(currentPage)) continue; - menuItems.add(MenuItemButton( - onPressed: () { - Navigator.pushNamed(context, "/view/$currentPage/$langCode"); - }, - child: Text(context.l10n.getLanguageName(langCode)), - )); + menuItems.add( + MenuItemButton( + onPressed: () { + Navigator.pushNamed(context, "/view/$currentPage/$langCode"); + }, + child: Text(context.l10n.getLanguageName(langCode)), + ), + ); } final menuController = MenuController(); return MenuAnchor( - style: MenuStyle( - maximumSize: WidgetStatePropertyAll( - Size(400, 500), - ), - ), + style: MenuStyle(maximumSize: WidgetStatePropertyAll(Size(400, 500))), controller: menuController, - builder: - (BuildContext context, MenuController controller, Widget? child) { + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { return IconButton( onPressed: () { if (controller.isOpen) { @@ -71,22 +75,21 @@ class LanguageSelectionButton extends ConsumerWidget { // TODO better 2-column design for odd numbers of languages menuItems.length > 10 ? Row( - children: [ - Flexible( - fit: FlexFit.loose, - child: Column( - children: - menuItems.sublist(0, menuItems.length ~/ 2), - ), + children: [ + Flexible( + fit: FlexFit.loose, + child: Column( + children: menuItems.sublist(0, menuItems.length ~/ 2), + ), + ), + Flexible( + fit: FlexFit.loose, + child: Column( + children: menuItems.sublist(menuItems.length ~/ 2), ), - Flexible( - fit: FlexFit.loose, - child: Column( - children: - menuItems.sublist(menuItems.length ~/ 2)), - ) - ], - ) + ), + ], + ) : Column(children: menuItems), const Divider(), ListTile( diff --git a/lib/widgets/languages_table.dart b/lib/widgets/languages_table.dart index a0fc4b3..4c3643b 100644 --- a/lib/widgets/languages_table.dart +++ b/lib/widgets/languages_table.dart @@ -67,65 +67,92 @@ class LanguagesTable extends ConsumerWidget { ])); } - return Column( + // Header row with the all-languages buttons (pinned at the top) + final Widget header = Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + 2: IntrinsicColumnWidth(), + 3: IntrinsicColumnWidth(), + 4: IntrinsicColumnWidth(), + }, children: [ - Table( - columnWidths: const { - 0: IntrinsicColumnWidth(), - 1: FlexColumnWidth(), - 2: IntrinsicColumnWidth(), - 3: IntrinsicColumnWidth(), - 4: IntrinsicColumnWidth(), - }, + // header with all-languages-buttons + TableRow( + decoration: const BoxDecoration( + border: + Border(bottom: BorderSide(width: 3, color: Colors.grey))), + children: [ + SizedBox( + height: 32, width: 32, child: IsDownloaded(allDownloaded)), + Container( + height: 32, + alignment: Alignment.centerLeft, + child: Text( + '${context.l10n.allLanguages} ($countAvailableLanguages)', + 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, child: DownloadAllLanguagesButton()), + const SizedBox( + height: 32, width: 32, child: DeleteAllLanguagesButton()), + ]) + ], + ); + + // The scrollable list of languages + final Widget languageList = Table( + //border: TableBorder.all(color: Colors.black26), + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + 2: IntrinsicColumnWidth(), + 3: IntrinsicColumnWidth(), + }, + children: rows, + ); + + // Disk usage summary (pinned at the bottom) + final Widget diskUsageText = Text( + '${context.l10n.diskUsage}: ${diskUsage.value ?? '…'} kB' + ' $countLanguages', + style: Theme.of(context).textTheme.bodyMedium, + ); + + return LayoutBuilder(builder: (context, constraints) { + // Normally there is plenty of vertical room, so we pin the header and the + // disk-usage summary and let only the language list scroll. + // When the available height is too small for that fixed chrome - e.g. + // transient frames while a route transition tears this page down, or a + // very short viewport - fall back to scrolling the whole table so the + // fixed header/footer can never overflow. + const double minHeightForPinnedLayout = 120; + if (constraints.maxHeight.isFinite && + constraints.maxHeight >= minHeightForPinnedLayout) { + return Column( children: [ - // header with all-languages-buttons - TableRow( - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(width: 3, color: Colors.grey))), - children: [ - SizedBox( - height: 32, - width: 32, - child: IsDownloaded(allDownloaded)), - Container( - height: 32, - alignment: Alignment.centerLeft, - child: Text( - '${context.l10n.allLanguages} ($countAvailableLanguages)', - 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, child: DownloadAllLanguagesButton()), - const SizedBox( - height: 32, width: 32, child: DeleteAllLanguagesButton()), - ]) + header, + const SizedBox(height: 5), + Expanded(child: SingleChildScrollView(child: languageList)), + const SizedBox(height: 5), + diskUsageText, + ], + ); + } + return SingleChildScrollView( + child: Column( + children: [ + header, + const SizedBox(height: 5), + languageList, + const SizedBox(height: 5), + diskUsageText, ], ), - const SizedBox(height: 5), - Expanded( - child: SingleChildScrollView( - child: Table( - //border: TableBorder.all(color: Colors.black26), - columnWidths: const { - 0: IntrinsicColumnWidth(), - 1: FlexColumnWidth(), - 2: IntrinsicColumnWidth(), - 3: IntrinsicColumnWidth(), - }, - children: rows, - ))), - const SizedBox(height: 5), - Text( - '${context.l10n.diskUsage}: ${diskUsage.value ?? '…'} kB' - ' $countLanguages', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ); + ); + }); } } diff --git a/lib/widgets/loading_animation.dart b/lib/widgets/loading_animation.dart index ad5efc3..b91152f 100644 --- a/lib/widgets/loading_animation.dart +++ b/lib/widgets/loading_animation.dart @@ -11,18 +11,19 @@ class LoadingAnimation extends StatelessWidget { @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) - ], + 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), + ], + ), ), - )); + ); } } diff --git a/lib/widgets/main_drawer.dart b/lib/widgets/main_drawer.dart index 535d6c1..44ed25a 100644 --- a/lib/widgets/main_drawer.dart +++ b/lib/widgets/main_drawer.dart @@ -5,6 +5,7 @@ import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/categories.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; +import 'package:app4training/data/updates.dart'; import 'package:app4training/design/theme.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:flutter/material.dart'; @@ -31,9 +32,12 @@ class MainDrawer extends StatelessWidget { title: Padding( padding: const EdgeInsets.fromLTRB(10, 40, 10, 10), child: Align( - alignment: Alignment.center, - child: Text(context.l10n.content, - style: Theme.of(context).textTheme.titleLarge)), + alignment: Alignment.center, + child: Text( + context.l10n.content, + style: Theme.of(context).textTheme.titleLarge, + ), + ), ), onTap: () { // Drawer should be closed when user leaves the settings page @@ -63,49 +67,68 @@ class TableOfContent extends ConsumerWidget { if ((_langCode != null) && (_langCode != appLangCode)) { otherLanguage = ref.watch(languageProvider(_langCode)); } - categories = Category.values.map((Category category) { - return CategoryTile(_page, appLanguage, category, - otherLanguage: otherLanguage); - }).toList(); + categories = + Category.values.map((Category category) { + return CategoryTile( + _page, + appLanguage, + category, + otherLanguage: otherLanguage, + ); + }).toList(); } else { // show error message because menu is empty categories = [ Padding( - padding: const EdgeInsets.all(10), - child: Text( - context.l10n.languageNotDownloaded( - context.l10n.getLanguageName(appLangCode)), - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ))) + padding: const EdgeInsets.all(10), + child: Text( + context.l10n.languageNotDownloaded( + context.l10n.getLanguageName(appLangCode), + ), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), ]; } return SingleChildScrollView( - child: Column(children: [ - // Header - header ?? const SizedBox(), - ...categories, - const Divider(), - ListTile( - title: Text(context.l10n.settings), - leading: const Icon(Icons.settings), - onTap: () { - // Drawer should be closed when user leaves the settings page - context.findAncestorStateOfType()?.closeDrawer(); - Navigator.pushNamed(context, '/settings'); - }, + child: Column( + children: [ + // Header + header ?? const SizedBox(), + ...categories, + const Divider(), + ListTile( + title: Text(context.l10n.settings), + leading: const Icon(Icons.settings), + // Persistent indicator: a dot when updates found in the background + // are waiting for the user to confirm the download (requireConfirmation) + trailing: + ref.watch(updatesNeedConfirmationProvider) + ? Icon( + Icons.circle, + size: 12, + color: Theme.of(context).colorScheme.error, + ) + : null, + onTap: () { + // Drawer should be closed when user leaves the settings page + context.findAncestorStateOfType()?.closeDrawer(); + Navigator.pushNamed(context, '/settings'); + }, + ), + ListTile( + title: Text(context.l10n.about), + leading: const Icon(Icons.info), + onTap: () { + // Drawer should be closed when user leaves the settings page + context.findAncestorStateOfType()?.closeDrawer(); + Navigator.pushNamed(context, '/about'); + }, + ), + ], ), - ListTile( - title: Text(context.l10n.about), - leading: const Icon(Icons.info), - onTap: () { - // Drawer should be closed when user leaves the settings page - context.findAncestorStateOfType()?.closeDrawer(); - Navigator.pushNamed(context, '/about'); - }, - ) - ])); + ); } } @@ -117,9 +140,13 @@ class CategoryTile extends ConsumerWidget { final Language _appLanguage; final Language? _otherLanguage; final Category _category; - const CategoryTile(this._page, this._appLanguage, this._category, - {Language? otherLanguage, super.key}) - : _otherLanguage = otherLanguage; + const CategoryTile( + this._page, + this._appLanguage, + this._category, { + Language? otherLanguage, + super.key, + }) : _otherLanguage = otherLanguage; @override Widget build(BuildContext context, WidgetRef ref) { @@ -129,7 +156,8 @@ class CategoryTile extends ConsumerWidget { List categoryContent = []; allTitles.forEach((englishName, translatedName) { if (worksheetCategories[englishName] == _category) { - bool isTranslated = (_otherLanguage != null) && + bool isTranslated = + (_otherLanguage != null) && (_otherLanguage.pages.containsKey(englishName)); // default: no icon (this is a dummy) Widget translationIcon = const SizedBox(); @@ -137,80 +165,115 @@ class CategoryTile extends ConsumerWidget { if (isTranslated) { // Show normal translate icon translationIcon = IconButton( - onPressed: () async { - bool result = await showDialog( - context: context, - builder: (context) { - return AvailableInDialog( - translatedName, _otherLanguage.languageCode); - }); - if (result) { - if (!context.mounted) return; - unawaited(Navigator.popAndPushNamed(context, - '/view/$englishName/${_otherLanguage.languageCode}')); - } - }, - icon: const Icon(Icons.translate)); + onPressed: () async { + bool result = await showDialog( + context: context, + builder: (context) { + return AvailableInDialog( + translatedName, + _otherLanguage.languageCode, + ); + }, + ); + if (result) { + if (!context.mounted) return; + unawaited( + Navigator.popAndPushNamed( + context, + '/view/$englishName/${_otherLanguage.languageCode}', + ), + ); + } + }, + icon: const Icon(Icons.translate), + ); } else { // Show a greyed-out icon translationIcon = IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) { - return NotTranslatedDialog(_otherLanguage.languageCode); - }); - }, - color: Theme.of(context).colorScheme.inversePrimary, - icon: const Icon(Icons.translate)); + onPressed: () { + showDialog( + context: context, + builder: (context) { + return NotTranslatedDialog(_otherLanguage.languageCode); + }, + ); + }, + color: Theme.of(context).colorScheme.inversePrimary, + icon: const Icon(Icons.translate), + ); } } - categoryContent.add(Row(children: [ - const SizedBox(width: 10), - Expanded( - child: TextButton( + categoryContent.add( + Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: TextButton( style: ButtonStyle( - alignment: Alignment.centerLeft, - shape: const WidgetStatePropertyAll( - RoundedRectangleBorder()), - backgroundColor: WidgetStatePropertyAll( - (englishName == _page) - ? Theme.of(context).focusColor - : null)), + alignment: Alignment.centerLeft, + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(), + ), + backgroundColor: WidgetStatePropertyAll( + (englishName == _page) + ? Theme.of(context).focusColor + : null, + ), + ), onPressed: () { - final destLangCode = isTranslated - ? _otherLanguage.languageCode - : _appLanguage.languageCode; + final destLangCode = + isTranslated + ? _otherLanguage.languageCode + : _appLanguage.languageCode; if ((_otherLanguage != null) && !isTranslated) { - ref.watch(scaffoldMessengerProvider).showSnackBar( - SnackBar( - content: Text(context.l10n.languageChangedBack( + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar( + content: Text( + context.l10n.languageChangedBack( translatedName, context.l10n.getLanguageName( - _otherLanguage.languageCode), + _otherLanguage.languageCode, + ), context.l10n.getLanguageName( - _appLanguage.languageCode))))); + _appLanguage.languageCode, + ), + ), + ), + ), + ); } Navigator.pop(context); Navigator.pushNamed( - context, '/view/$englishName/$destLangCode'); + context, + '/view/$englishName/$destLangCode', + ); }, - child: Text(translatedName, - style: Theme.of(context).textTheme.titleMedium))), - translationIcon - ])); + child: Text( + translatedName, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + translationIcon, + ], + ), + ); } }); return ExpansionTile( - title: Text(Category.getLocalized(context, _category)), - collapsedBackgroundColor: (worksheetCategories[_page] == _category) - ? Theme.of(context).highlightColor - : null, - controlAffinity: ListTileControlAffinity.leading, - shape: const Border(), // remove border when tile is expanded - initiallyExpanded: worksheetCategories[_page] == _category, - children: categoryContent); + title: Text(Category.getLocalized(context, _category)), + collapsedBackgroundColor: + (worksheetCategories[_page] == _category) + ? Theme.of(context).highlightColor + : null, + controlAffinity: ListTileControlAffinity.leading, + shape: const Border(), // remove border when tile is expanded + initiallyExpanded: worksheetCategories[_page] == _category, + children: categoryContent, + ); } } @@ -224,28 +287,35 @@ class AvailableInDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text(context.l10n.translationAvailable), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.sentiment_satisfied, - size: smileySize, - ), + title: Text(context.l10n.translationAvailable), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sentiment_satisfied, size: smileySize), const SizedBox(height: 10), - Text(context.l10n.translationAvailableText( - page, context.l10n.getLanguageName(languageCode))) - ]), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.close)), - TextButton( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.showPage)) - ]); + Text( + context.l10n.translationAvailableText( + page, + context.l10n.getLanguageName(languageCode), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.close), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.showPage), + ), + ], + ); } } @@ -258,22 +328,27 @@ class NotTranslatedDialog extends StatelessWidget { @override Widget build(BuildContext context) { return AlertDialog( - title: Text(context.l10n.sorry), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.sentiment_dissatisfied, - size: smileySize, - ), + title: Text(context.l10n.sorry), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sentiment_dissatisfied, size: smileySize), const SizedBox(height: 10), - Text(context.l10n - .notTranslated(context.l10n.getLanguageName(languageCode))), - ]), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(context.l10n.okay)) - ]); + Text( + context.l10n.notTranslated( + context.l10n.getLanguageName(languageCode), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.l10n.okay), + ), + ], + ); } } diff --git a/lib/widgets/update_language_button.dart b/lib/widgets/update_language_button.dart index cf25d29..2c879dc 100644 --- a/lib/widgets/update_language_button.dart +++ b/lib/widgets/update_language_button.dart @@ -23,34 +23,48 @@ class _UpdateLanguageButtonState extends ConsumerState { @override Widget build(BuildContext context) { - LanguageController lang = - ref.read(languageProvider(widget.languageCode).notifier); + LanguageController lang = ref.read( + languageProvider(widget.languageCode).notifier, + ); return _isUpdating ? const Center( - child: SizedBox( - height: 24, width: 24, child: CircularProgressIndicator())) + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ), + ) : IconButton( - onPressed: () async { - setState(() { - _isUpdating = true; - }); - // Get l10n now as we can't access context after async gap later - AppLocalizations l10n = context.l10n; + onPressed: () async { + setState(() { + _isUpdating = true; + }); + // Get l10n now as we can't access context after async gap later + AppLocalizations l10n = context.l10n; - bool success = await lang.download(); + bool success = await lang.download(); - ref.watch(scaffoldMessengerProvider).showSnackBar(SnackBar( - content: Text(success - ? l10n.updatedLanguage( - l10n.getLanguageName(widget.languageCode)) - : l10n.updateError))); + ref + .watch(scaffoldMessengerProvider) + .showSnackBar( + SnackBar( + content: Text( + success + ? l10n.updatedLanguage( + l10n.getLanguageName(widget.languageCode), + ) + : l10n.updateError, + ), + ), + ); - setState(() { - _isUpdating = false; - }); - }, - icon: const Icon(Icons.refresh), - padding: EdgeInsets.zero); + setState(() { + _isUpdating = false; + }); + }, + icon: const Icon(Icons.refresh), + padding: EdgeInsets.zero, + ); } } @@ -75,45 +89,57 @@ class _UpdateAllLanguagesButtonState return _isLoading ? const Center( - child: SizedBox( - height: 24, width: 24, child: CircularProgressIndicator())) + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ), + ) : IconButton( - onPressed: () async { - setState(() { - _isLoading = true; - }); - // Get l10n now as we can't access context after async gap later - final l10n = context.l10n; - final codesToUpdate = [ - for (final languageCode in ref.read(availableLanguagesProvider)) - if (ref.read(languageStatusProvider(languageCode)).updatesAvailable && - ref.read(languageProvider(languageCode)).downloaded) - languageCode, - ]; - final result = await downloadLanguagesInParallel( - codesToUpdate, - download: (code) => - ref.read(languageProvider(code).notifier).download(), - ); - if (result.successCount > 0) { - // Show info message in snackbar - String text = (result.successCount == 1) - ? l10n.updatedLanguage( - l10n.getLanguageName(result.lastSuccessCode)) - : l10n.updatedNLanguages( - result.successCount, result.errorCount); - final snackBar = SnackBar(content: Text(text)); - ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); - } else if (result.errorCount > 0) { - ref - .watch(scaffoldMessengerProvider) - .showSnackBar(SnackBar(content: Text(l10n.updateError))); - } - setState(() { - _isLoading = false; - }); - }, - icon: const Icon(Icons.refresh), - padding: EdgeInsets.zero); + onPressed: () async { + setState(() { + _isLoading = true; + }); + // Get l10n now as we can't access context after async gap later + final l10n = context.l10n; + final codesToUpdate = [ + for (final languageCode in ref.read(availableLanguagesProvider)) + if (ref + .read(languageStatusProvider(languageCode)) + .updatesAvailable && + ref.read(languageProvider(languageCode)).downloaded) + languageCode, + ]; + final result = await downloadLanguagesInParallel( + codesToUpdate, + download: + (code) => + ref.read(languageProvider(code).notifier).download(), + ); + if (result.successCount > 0) { + // Show info message in snackbar + String text = + (result.successCount == 1) + ? l10n.updatedLanguage( + l10n.getLanguageName(result.lastSuccessCode), + ) + : l10n.updatedNLanguages( + result.successCount, + result.errorCount, + ); + final snackBar = SnackBar(content: Text(text)); + ref.watch(scaffoldMessengerProvider).showSnackBar(snackBar); + } else if (result.errorCount > 0) { + ref + .watch(scaffoldMessengerProvider) + .showSnackBar(SnackBar(content: Text(l10n.updateError))); + } + setState(() { + _isLoading = false; + }); + }, + icon: const Icon(Icons.refresh), + padding: EdgeInsets.zero, + ); } } diff --git a/pubspec.lock b/pubspec.lock index e14d4b2..f447f4d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -169,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -416,10 +440,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -496,10 +520,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -512,10 +536,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mime: dependency: transitive description: @@ -540,6 +564,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: @@ -636,6 +668,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -909,26 +949,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.18" typed_data: dependency: transitive description: @@ -1013,10 +1053,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -1129,6 +1169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 330bc0e..e7ae146 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: flutter_linkify: ^6.0.0 package_info_plus: ^10.0.0 workmanager: ^0.9.0+3 + connectivity_plus: ^6.0.0 share_plus: ^13.0.0 open_filex: ^4.4.0 device_info_plus: ^13.2.0 diff --git a/test/about_page_test.dart b/test/about_page_test.dart index 5b17e8b..613df55 100644 --- a/test/about_page_test.dart +++ b/test/about_page_test.dart @@ -11,20 +11,26 @@ import 'package:package_info_plus/package_info_plus.dart'; void main() { testWidgets('Basic test', (WidgetTester tester) async { const testVersion = '0.8.1'; - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [ - packageInfoProvider.overrideWithValue(PackageInfo( - version: testVersion, - buildNumber: '1', - appName: 'app4training', - packageName: 'net.app4training', - )) + packageInfoProvider.overrideWithValue( + PackageInfo( + version: testVersion, + buildNumber: '1', + appName: 'app4training', + packageName: 'net.app4training', + ), + ), ], child: const MaterialApp( - locale: Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: AboutPage()))); + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AboutPage(), + ), + ), + ); expect(find.byType(PromoBlock), findsOneWidget); // are all headlines there? diff --git a/test/app_language_test.dart b/test/app_language_test.dart index 23fa437..d9198b5 100644 --- a/test/app_language_test.dart +++ b/test/app_language_test.dart @@ -71,7 +71,8 @@ void main() { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); final prefs = await SharedPreferences.getInstance(); final ref = ProviderContainer( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)]); + overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + ); expect(prefs.getString('appLanguage'), equals('de')); expect(ref.read(appLanguageProvider).languageCode, equals('de')); @@ -89,7 +90,8 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final ref = ProviderContainer( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)]); + overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + ); expect(prefs.getString('appLanguage'), isNull); expect(ref.read(appLanguageProvider).isSystemDefault, true); @@ -104,9 +106,11 @@ void main() { }); test('Test TestAppLanguage class', () { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')) - ]); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + ], + ); expect(ref.read(appLanguageProvider).languageCode, 'de'); ref.read(appLanguageProvider.notifier).setLocale('en'); expect(ref.read(appLanguageProvider).languageCode, 'en'); diff --git a/test/background_check_frequency_test.dart b/test/background_check_frequency_test.dart new file mode 100644 index 0000000..9a5ca10 --- /dev/null +++ b/test/background_check_frequency_test.dart @@ -0,0 +1,128 @@ +import 'package:app4training/background/background_task.dart'; +import 'package:app4training/background/background_test.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/languages.dart'; +import 'package:app4training/data/updates.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'languages_test.dart'; +import 'updates_test.dart'; + +/// German is downloaded (and therefore last checked) right now; +/// [httpCalls] counts every request to the GitHub API +Future<({ProviderContainer ref, List httpCalls})> setup( + CheckFrequency checkFrequency, +) async { + SharedPreferences.setMockInitialValues({ + 'checkFrequency': checkFrequency.name, + }); + final prefs = await SharedPreferences.getInstance(); + final fileSystem = await createBasicFileSystem(['de']); + final httpCalls = []; + + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + connectivityServiceProvider.overrideWithValue( + FakeConnectivityService(unmetered: true), + ), + httpClientProvider.overrideWithValue( + MockClient((request) async { + httpCalls.add(request.url); + return fakeResponseNUpdates(0); + }), + ), + ], + ); + return (ref: ref, httpCalls: httpCalls); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'weekly: a run 2 days after the last check makes no network calls', + () async { + final s = await setup(CheckFrequency.weekly); + await backgroundRun( + s.ref, + now: DateTime.now().toUtc().add(const Duration(days: 2)), + ); + expect(s.httpCalls, isEmpty); + }, + ); + + test( + 'weekly: a run a week after the last check checks for updates', + () async { + final s = await setup(CheckFrequency.weekly); + await backgroundRun( + s.ref, + now: DateTime.now().toUtc().add(const Duration(days: 8)), + ); + expect(s.httpCalls, hasLength(1)); + }, + ); + + test('Android: a run scheduled one interval after the previous run started ' + 'is not skipped, although lastChecked was written a bit later', () async { + final s = await setup(CheckFrequency.weekly); + await backgroundRun( + s.ref, + now: DateTime.now().toUtc().add( + const Duration(days: 7) - const Duration(minutes: 5), + ), + ); + expect(s.httpCalls, hasLength(1)); + }); + + test( + 'never: a stray run (e.g. a leftover iOS registration) does nothing', + () async { + final s = await setup(CheckFrequency.never); + await backgroundRun( + s.ref, + now: DateTime.now().toUtc().add(const Duration(days: 365)), + ); + expect(s.httpCalls, isEmpty); + }, + ); + + group('Skip / run decision for each CheckFrequency', () { + for (final frequency in CheckFrequency.values) { + final interval = frequency.getDuration(); + if (interval == null) continue; // never: tested above + test('${frequency.name}: skip after half the interval', () async { + final s = await setup(frequency); + await backgroundRun( + s.ref, + now: DateTime.now().toUtc().add(interval ~/ 2), + ); + expect(s.httpCalls, isEmpty); + }); + test('${frequency.name}: run after the full interval', () async { + final s = await setup(frequency); + await backgroundRun(s.ref, now: DateTime.now().toUtc().add(interval)); + expect(s.httpCalls, hasLength(1)); + }); + } + }); + + test('iOS: with weekly, daily wake-ups only check on the 7th day', () async { + final s = await setup(CheckFrequency.weekly); + final start = DateTime.now().toUtc(); + for (int day = 1; day <= 6; day++) { + await backgroundRun(s.ref, now: start.add(Duration(days: day))); + } + expect(s.httpCalls, isEmpty); + await backgroundRun(s.ref, now: start.add(const Duration(days: 7))); + expect(s.httpCalls, hasLength(1)); + }); +} diff --git a/test/background_download_test.dart b/test/background_download_test.dart new file mode 100644 index 0000000..3145645 --- /dev/null +++ b/test/background_download_test.dart @@ -0,0 +1,147 @@ +import 'package:app4training/background/background_task.dart'; +import 'package:app4training/background/background_test.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/updates.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'updates_test.dart'; + +/// Drive [backgroundDownload] with a fully faked container so we can assert +/// the AutomaticUpdates x connectivity decision matrix without any platform +/// channels or network access. +Future<({ProviderContainer ref, FakeLanguageDownloader downloader})> setup({ + required AutomaticUpdates automaticUpdates, + required bool unmetered, + List langsWithUpdates = const ['de'], +}) async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': automaticUpdates.name, + }); + final prefs = await SharedPreferences.getInstance(); + final fileSystem = MemoryFileSystem(); + final downloader = FakeLanguageDownloader(fileSystem: fileSystem); + + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + languageDownloaderProvider.overrideWithValue(downloader), + connectivityServiceProvider.overrideWithValue( + FakeConnectivityService(unmetered: unmetered), + ), + languageStatusProvider.overrideWith2( + (langCode) => TestLanguageStatus(langWithUpdates: langsWithUpdates), + ), + ], + ); + return (ref: ref, downloader: downloader); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AutomaticUpdates.never: never downloads', () { + for (final unmetered in [true, false]) { + test('unmetered=$unmetered', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.never, + unmetered: unmetered, + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadCalls, 0); + }); + } + }); + + group('AutomaticUpdates.requireConfirmation: never downloads', () { + for (final unmetered in [true, false]) { + test('unmetered=$unmetered, updatesAvailable stays true', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.requireConfirmation, + unmetered: unmetered, + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadCalls, 0); + // The foreground still needs to see that de has updates + expect(s.ref.read(languageStatusProvider('de')).updatesAvailable, true); + }); + } + }); + + group('AutomaticUpdates.onlyOnWifi: downloads only when unmetered', () { + test('not unmetered -> no download', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.onlyOnWifi, + unmetered: false, + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadCalls, 0); + }); + + test('unmetered -> downloads de', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.onlyOnWifi, + unmetered: true, + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadedLangs, ['de']); + }); + }); + + group('AutomaticUpdates.yesAlways: downloads on any connection', () { + for (final unmetered in [true, false]) { + test('unmetered=$unmetered -> downloads de', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.yesAlways, + unmetered: unmetered, + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadedLangs, ['de']); + }); + } + }); + + test('Only languages with updatesAvailable are downloaded', () async { + final s = await setup( + automaticUpdates: AutomaticUpdates.yesAlways, + unmetered: true, + langsWithUpdates: ['de', 'fr'], + ); + await backgroundDownload(s.ref); + expect(s.downloader.downloadedLangs.toSet(), {'de', 'fr'}); + expect(s.downloader.downloadedLangs, isNot(contains('en'))); + }); + + test('One failing download does not abort the whole run', () async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': AutomaticUpdates.yesAlways.name, + }); + final prefs = await SharedPreferences.getInstance(); + final fileSystem = MemoryFileSystem(); + // This downloader always throws + final downloader = FakeLanguageDownloader( + fileSystem: fileSystem, + throwOnDownload: true, + ); + + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + languageDownloaderProvider.overrideWithValue(downloader), + connectivityServiceProvider.overrideWithValue( + FakeConnectivityService(unmetered: true), + ), + languageStatusProvider.overrideWith2( + (langCode) => TestLanguageStatus(langWithUpdates: ['de', 'fr']), + ), + ], + ); + + // Should not throw even though every download() throws + await backgroundDownload(ref); + // It attempted both languages rather than bailing after the first failure + expect(downloader.downloadCalls, 2); + }); +} diff --git a/test/background_scheduler_test.dart b/test/background_scheduler_test.dart index 76e868d..d9c2a70 100644 --- a/test/background_scheduler_test.dart +++ b/test/background_scheduler_test.dart @@ -10,34 +10,109 @@ import 'package:shared_preferences/shared_preferences.dart'; /// /// Use this to test all places where BackgroundScheduler.schedule() gets called class TestBackgroundScheduler extends BackgroundScheduler { + /// How often did we (would we have) registered a periodic task? + int registerCalls = 0; + + /// How often did we cancel the previously scheduled task? + int cancelCalls = 0; + /// Same implementation as the real BackgroundScheduler.schedule() function, /// just without the Workmanager calls @override Future schedule() async { + cancelCalls++; Duration? interval = ref.read(checkFrequencyProvider).getDuration(); if (interval == null) { state = false; return; } + registerCalls++; state = true; } } void main() { - test('Testing TestBackgroundScheduler', () async { - SharedPreferences.setMockInitialValues({}); + Future createContainer(String? checkFrequency) async { + SharedPreferences.setMockInitialValues( + checkFrequency == null ? {} : {'checkFrequency': checkFrequency}, + ); final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + } + + test('Provider state is false before scheduling', () async { + final ref = await createContainer(null); + expect(ref.read(backgroundSchedulerProvider), false); + }); - final ref = ProviderContainer(overrides: [ - backgroundSchedulerProvider.overrideWith(() => TestBackgroundScheduler()), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + test('CheckFrequency.never: nothing scheduled, state stays false', () async { + final ref = await createContainer('never'); + final scheduler = + ref.read(backgroundSchedulerProvider.notifier) + as TestBackgroundScheduler; + + await scheduler.schedule(); expect(ref.read(backgroundSchedulerProvider), false); - await ref.read(backgroundSchedulerProvider.notifier).schedule(); + expect(scheduler.registerCalls, 0); + }); + + test('Each real frequency schedules the task and sets state true', () async { + for (final frequency in ['daily', 'weekly', 'monthly', 'testinterval']) { + final ref = await createContainer(frequency); + final scheduler = + ref.read(backgroundSchedulerProvider.notifier) + as TestBackgroundScheduler; + + await scheduler.schedule(); + + expect( + ref.read(backgroundSchedulerProvider), + true, + reason: 'state should be true for $frequency', + ); + expect( + scheduler.registerCalls, + 1, + reason: 'one periodic task should be registered for $frequency', + ); + } + }); + + test('Re-scheduling cancels the previous registration first', () async { + final ref = await createContainer('weekly'); + final scheduler = + ref.read(backgroundSchedulerProvider.notifier) + as TestBackgroundScheduler; + + await scheduler.schedule(); + await scheduler.schedule(); + + // Every schedule() call cancels the prior registration before registering + expect(scheduler.cancelCalls, 2); + expect(scheduler.registerCalls, 2); expect(ref.read(backgroundSchedulerProvider), true); - ref.read(checkFrequencyProvider.notifier).setCheckFrequency("never"); - await ref.read(backgroundSchedulerProvider.notifier).schedule(); + }); + + test('Switching to never after a real frequency cancels the task', () async { + final ref = await createContainer('weekly'); + final scheduler = + ref.read(backgroundSchedulerProvider.notifier) + as TestBackgroundScheduler; + + await scheduler.schedule(); + expect(ref.read(backgroundSchedulerProvider), true); + + ref.read(checkFrequencyProvider.notifier).setCheckFrequency('never'); + await scheduler.schedule(); + expect(ref.read(backgroundSchedulerProvider), false); }); } diff --git a/test/background_task_test.dart b/test/background_task_test.dart index 06b40d0..54ca6a2 100644 --- a/test/background_task_test.dart +++ b/test/background_task_test.dart @@ -19,13 +19,18 @@ void main() { final prefs = await SharedPreferences.getInstance(); final fileSystem = MemoryFileSystem(); - final ref = ProviderContainer(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - languageStatusProvider.overrideWith2((langCode) => TestLanguageStatus()) - ]); + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + languageStatusProvider.overrideWith2( + (langCode) => TestLanguageStatus(), + ), + ], + ); await backgroundCheck(ref); expect(ref.read(updatesAvailableProvider), false); }); @@ -35,13 +40,16 @@ void main() { final prefs = await SharedPreferences.getInstance(); var fileSystem = await createBasicFileSystem(['de', 'en']); - final ref = ProviderContainer(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - httpClientProvider.overrideWith((ref) => mockCheckResponse({'de': 2})), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + httpClientProvider.overrideWith((ref) => mockCheckResponse({'de': 2})), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); expect(ref.read(sharedPrefsProvider).getBool('updatesAvailable-de'), null); await backgroundCheck(ref); diff --git a/test/bulk_language_download_test.dart b/test/bulk_language_download_test.dart index a83ab4e..e2f18b2 100644 --- a/test/bulk_language_download_test.dart +++ b/test/bulk_language_download_test.dart @@ -34,20 +34,22 @@ void main() { expect(['de', 'fr'], contains(result.lastSuccessCode)); }); - test('counts a throwing download as an error without aborting the rest', - () async { - final result = await downloadLanguagesInParallel( - ['de', 'en', 'fr'], - maxConcurrent: 4, - download: (code) async { - if (code == 'en') throw Exception('boom'); - return true; - }, - ); - - expect(result.successCount, 2); - expect(result.errorCount, 1); - }); + test( + 'counts a throwing download as an error without aborting the rest', + () async { + final result = await downloadLanguagesInParallel( + ['de', 'en', 'fr'], + maxConcurrent: 4, + download: (code) async { + if (code == 'en') throw Exception('boom'); + return true; + }, + ); + + expect(result.successCount, 2); + expect(result.errorCount, 1); + }, + ); test('never exceeds maxConcurrent in flight', () async { var inFlight = 0; @@ -115,29 +117,34 @@ void main() { ); expect( - [for (final r in reports) (r.completed, r.total, r.languageCode, r.success)], + [ + 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( + '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/check_now_button_test.dart b/test/check_now_button_test.dart index d5f366b..fb3f9fa 100644 --- a/test/check_now_button_test.dart +++ b/test/check_now_button_test.dart @@ -19,26 +19,37 @@ class TestCheckNowButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: const Scaffold(body: CheckNowButton(buttonText: 'Check'))); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: CheckNowButton(buttonText: 'Check')), + ); } } void main() { - testWidgets('Test when no updates are available', - (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()), - languageStatusProvider - .overrideWith2((languageCode) => TestLanguageStatus(checkReturnValue: 0)) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestCheckNowButton())); + testWidgets('Test when no updates are available', ( + WidgetTester tester, + ) async { + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(checkReturnValue: 0), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestCheckNowButton(), + ), + ); expect(find.byType(ElevatedButton), findsOneWidget); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); @@ -50,87 +61,134 @@ void main() { expect(find.text('Alle Sprachen sind bereits aktuell'), findsOneWidget); }); - testWidgets('Test with 2 updates available in each language', - (WidgetTester tester) async { + testWidgets('Test with 2 updates available in each language', ( + WidgetTester tester, + ) async { countCheckCalled = 0; - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()), - languageStatusProvider - .overrideWith2((languageCode) => TestLanguageStatus(checkReturnValue: 2)) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestCheckNowButton())); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(checkReturnValue: 2), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestCheckNowButton(), + ), + ); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - equals(DateTime.utc(2023))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + equals(DateTime.utc(2023)), + ); await tester.tap(find.byType(CheckNowButton)); await tester.pump(); expect(ref.read(languageStatusProvider('en')).updatesAvailable, true); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - isNot(equals(DateTime.utc(2023)))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + isNot(equals(DateTime.utc(2023))), + ); // snackbar correct? expect(find.text('Updates für 34 Sprachen verfügbar'), findsOneWidget); expect(countCheckCalled, countAvailableLanguages); }); - testWidgets('Test that we ran into Github API rate limit', - (WidgetTester tester) async { + testWidgets('Test that we ran into Github API rate limit', ( + WidgetTester tester, + ) async { countCheckCalled = 0; - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()), - languageStatusProvider.overrideWith2( - (languageCode) => TestLanguageStatus(checkReturnValue: apiRateLimitExceeded)) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestCheckNowButton())); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + languageStatusProvider.overrideWith2( + (languageCode) => + TestLanguageStatus(checkReturnValue: apiRateLimitExceeded), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestCheckNowButton(), + ), + ); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - equals(DateTime.utc(2023))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + equals(DateTime.utc(2023)), + ); await tester.tap(find.byType(CheckNowButton)); await tester.pump(); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - equals(DateTime.utc(2023))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + equals(DateTime.utc(2023)), + ); // snackbar correct? expect( - find.text(AppLocalizationsDe().checkingUpdatesLimit), findsOneWidget); + find.text(AppLocalizationsDe().checkingUpdatesLimit), + findsOneWidget, + ); // Verify that we skipped the rest after the first error of this kind expect(countCheckCalled, 1); }); - testWidgets('Test unknown error while checking for updates', - (WidgetTester tester) async { + testWidgets('Test unknown error while checking for updates', ( + WidgetTester tester, + ) async { countCheckCalled = 0; - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()), - languageStatusProvider - .overrideWith2((languageCode) => TestLanguageStatus(checkReturnValue: -1)) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestCheckNowButton())); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(checkReturnValue: -1), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestCheckNowButton(), + ), + ); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - equals(DateTime.utc(2023))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + equals(DateTime.utc(2023)), + ); await tester.tap(find.byType(CheckNowButton)); await tester.pump(); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - expect(ref.read(languageStatusProvider('en')).lastCheckedTimestamp, - equals(DateTime.utc(2023))); + expect( + ref.read(languageStatusProvider('en')).lastCheckedTimestamp, + equals(DateTime.utc(2023)), + ); // snackbar correct? expect( - find.text(AppLocalizationsDe().checkingUpdatesError), findsOneWidget); + find.text(AppLocalizationsDe().checkingUpdatesError), + findsOneWidget, + ); expect(countCheckCalled, countAvailableLanguages); }); } diff --git a/test/confirm_updates_prompt_test.dart b/test/confirm_updates_prompt_test.dart new file mode 100644 index 0000000..79d9f05 --- /dev/null +++ b/test/confirm_updates_prompt_test.dart @@ -0,0 +1,124 @@ +import 'package:app4training/data/app_language.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/languages.dart'; +import 'package:app4training/data/updates.dart'; +import 'package:app4training/l10n/generated/app_localizations.dart'; +import 'package:app4training/l10n/generated/app_localizations_en.dart'; +import 'package:app4training/widgets/confirm_updates_prompt.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app_language_test.dart'; +import 'languages_test.dart'; +import 'updates_test.dart'; + +class TestConfirmUpdatesPrompt extends ConsumerWidget { + const TestConfirmUpdatesPrompt({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp( + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: ConfirmUpdatesPrompt()), + ); + } +} + +Future makeRef(String automaticUpdates) async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': automaticUpdates, + }); + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + ], + ); +} + +void main() { + testWidgets('Prompt hidden when no updates available', (tester) async { + final ref = await makeRef('requireConfirmation'); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestConfirmUpdatesPrompt(), + ), + ); + + expect(find.text(AppLocalizationsEn().downloadUpdatesNow), findsNothing); + }); + + testWidgets('Prompt hidden under yesAlways even with updates', ( + tester, + ) async { + final ref = await makeRef('yesAlways'); + await ref.read(languageStatusProvider('de').notifier).check(); + expect(ref.read(updatesAvailableProvider), true); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestConfirmUpdatesPrompt(), + ), + ); + + expect(find.text(AppLocalizationsEn().downloadUpdatesNow), findsNothing); + }); + + testWidgets('Prompt shown under requireConfirmation with updates', ( + tester, + ) async { + final ref = await makeRef('requireConfirmation'); + await ref.read(languageStatusProvider('de').notifier).check(); + expect(ref.read(updatesNeedConfirmationProvider), true); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestConfirmUpdatesPrompt(), + ), + ); + + expect(find.text(AppLocalizationsEn().downloadUpdatesNow), findsOneWidget); + }); + + testWidgets('Confirming downloads updates and dismisses the prompt', ( + tester, + ) async { + final ref = await makeRef('requireConfirmation'); + await ref.read(languageStatusProvider('de').notifier).check(); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestConfirmUpdatesPrompt(), + ), + ); + + await tester.tap(find.text(AppLocalizationsEn().downloadUpdatesNow)); + await tester.pump(); + + // de got re-downloaded -> its updatesAvailable resets + expect( + ref + .read(languageProvider('de')) + .downloadTimestamp + .compareTo(DateTime.utc(2023)), + greaterThan(0), + ); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, false); + // The prompt should now be gone (no more confirmation needed) + expect(ref.read(updatesNeedConfirmationProvider), false); + expect(find.text(AppLocalizationsEn().downloadUpdatesNow), findsNothing); + }); +} diff --git a/test/connectivity_service_test.dart b/test/connectivity_service_test.dart new file mode 100644 index 0000000..497c778 --- /dev/null +++ b/test/connectivity_service_test.dart @@ -0,0 +1,52 @@ +import 'package:app4training/background/background_test.dart'; +import 'package:app4training/data/connectivity_service.dart'; +import 'package:app4training/data/globals.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:riverpod/misc.dart' show ProviderException; + +void main() { + test('connectivityServiceProvider throws if not overridden', () { + final ref = ProviderContainer(); + expect( + () => ref.read(connectivityServiceProvider), + throwsA(isA()), + ); + }); + + test('FakeConnectivityService returns the controllable result', () async { + final fake = FakeConnectivityService(unmetered: true); + expect(await fake.isUnmetered(), true); + + fake.unmetered = false; + expect(await fake.isUnmetered(), false); + }); + + group('isUnmeteredConnection: WiFi/ethernet count, mobile does not', () { + test('WiFi is unmetered', () { + expect(isUnmeteredConnection([ConnectivityResult.wifi]), true); + }); + test('Ethernet is unmetered', () { + expect(isUnmeteredConnection([ConnectivityResult.ethernet]), true); + }); + test('Mobile is metered', () { + expect(isUnmeteredConnection([ConnectivityResult.mobile]), false); + }); + test('No connection is metered', () { + expect(isUnmeteredConnection([ConnectivityResult.none]), false); + }); + test('Mixed mobile + wifi is unmetered', () { + expect( + isUnmeteredConnection([ + ConnectivityResult.mobile, + ConnectivityResult.wifi, + ]), + true, + ); + }); + test('Empty list is metered', () { + expect(isUnmeteredConnection([]), false); + }); + }); +} diff --git a/test/delete_language_button_test.dart b/test/delete_language_button_test.dart index 10014a9..f56c3d3 100644 --- a/test/delete_language_button_test.dart +++ b/test/delete_language_button_test.dart @@ -17,24 +17,33 @@ class TestDeleteLanguageButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: Scaffold(body: DeleteLanguageButton(languageCode))); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: Scaffold(body: DeleteLanguageButton(languageCode)), + ); } } void main() { testWidgets('Test DeleteLanguageButton', (WidgetTester tester) async { final testLanguageController = TestLanguageController(); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => testLanguageController) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestDeleteLanguageButton('en'))); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => testLanguageController, + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestDeleteLanguageButton('en'), + ), + ); expect(find.byIcon(Icons.delete), findsOneWidget); expect(testLanguageController.state.downloaded, true); @@ -52,15 +61,24 @@ void main() { }); // Trying to delete the currently selected app language is discouraged - testWidgets('Test DeleteLanguageButton for app language', - (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestDeleteLanguageButton('de'))); + testWidgets('Test DeleteLanguageButton for app language', ( + WidgetTester tester, + ) async { + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestDeleteLanguageButton('de'), + ), + ); expect(find.byIcon(Icons.delete), findsOneWidget); // TODO test that the color of the icon is greyed out @@ -88,19 +106,27 @@ void main() { }); testWidgets('Test DeleteAllLanguagesButton', (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController()) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(), + ), + ], + ); + + 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: DeleteAllLanguagesButton())))); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: DeleteAllLanguagesButton()), + ), + ), + ); expect(ref.read(languageProvider('ar')).downloaded, true); diff --git a/test/download_language_button_test.dart b/test/download_language_button_test.dart index d048848..d04c155 100644 --- a/test/download_language_button_test.dart +++ b/test/download_language_button_test.dart @@ -30,31 +30,43 @@ class GatedDownloadLanguageController extends TestLanguageController { class TestDownloadLanguageButton extends ConsumerWidget { final String languageCode; final bool highlight; - const TestDownloadLanguageButton(this.languageCode, - {this.highlight = false, super.key}); + const TestDownloadLanguageButton( + this.languageCode, { + this.highlight = false, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: Scaffold( - body: DownloadLanguageButton(languageCode, highlight: highlight))); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: Scaffold( + body: DownloadLanguageButton(languageCode, highlight: highlight), + ), + ); } } void main() { testWidgets('Test DownloadLanguageButton', (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider - .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])), - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestDownloadLanguageButton('en'))); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: []), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestDownloadLanguageButton('en'), + ), + ); expect(find.byIcon(Icons.download), findsOneWidget); expect(find.byType(Container), findsNothing); // should not be highlighted @@ -68,20 +80,27 @@ void main() { }); testWidgets('Test DownloadAllLanguagesButton', (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider - .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: []), + ), + ], + ); + + 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())))); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: DownloadAllLanguagesButton()), + ), + ), + ); expect(ref.read(languageProvider('ar')).downloaded, false); @@ -96,31 +115,40 @@ void main() { expect(find.text('34 Sprachen heruntergeladen'), findsOneWidget); }); - testWidgets('DownloadAllLanguagesButton shows how far the batch is', - (WidgetTester tester) async { + testWidgets('DownloadAllLanguagesButton shows how far the batch is', ( + WidgetTester tester, + ) async { final gates = { - for (final code in ['de', 'en', 'fr']) code: Completer() + 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 ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + availableLanguagesProvider.overrideWithValue(['de', 'en', 'fr']), + languageProvider.overrideWith2( + (langCode) => GatedDownloadLanguageController(gates), + ), + ], + ); final l10n = AppLocalizationsDe(); - await tester.pumpWidget(UncontrolledProviderScope( + 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)); + 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) @@ -150,17 +178,24 @@ void main() { expect(find.text('3 Sprachen heruntergeladen'), findsOneWidget); }); - testWidgets('Test highlighted DownloadLanguageButton', - (WidgetTester tester) async { - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider - .overrideWith2((langCode) => TestLanguageController(downloadedLanguages: [])), - ]); - - await tester.pumpWidget(UncontrolledProviderScope( + testWidgets('Test highlighted DownloadLanguageButton', ( + WidgetTester tester, + ) async { + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: []), + ), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( container: ref, - child: const TestDownloadLanguageButton('en', highlight: true))); + child: const TestDownloadLanguageButton('en', highlight: true), + ), + ); expect(find.byIcon(Icons.download), findsOneWidget); // Test the highlighting @@ -177,15 +212,23 @@ void main() { testWidgets('Test failing download', (WidgetTester tester) async { final fileSystem = MemoryFileSystem(); final fakeDownloader = FakeLanguageDownloader( - fileSystem: fileSystem, throwOnDownload: true); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestDownloadLanguageButton('en'))); + fileSystem: fileSystem, + throwOnDownload: true, + ); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestDownloadLanguageButton('en'), + ), + ); await tester.tap(find.byType(DownloadLanguageButton)); await tester.pump(); diff --git a/test/download_languages_page_test.dart b/test/download_languages_page_test.dart index 3d821ae..43fed68 100644 --- a/test/download_languages_page_test.dart +++ b/test/download_languages_page_test.dart @@ -96,10 +96,8 @@ void main() { find.widgetWithText(ElevatedButton, AppLocalizationsEn().continueText), ); await tester.pump(); - expect(listEquals(testObserver.replacedRoutes, ['/home']), isTrue); - /* TODO for version 0.9 + // checkFrequency is unset (fresh onboarding) -> continue to third step expect(listEquals(testObserver.replacedRoutes, ['/onboarding/3']), isTrue); -*/ }); testWidgets('Test DownloadLanguagesPage back button in German', ( @@ -110,7 +108,9 @@ void main() { ProviderScope( overrides: [ appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageStatusProvider.overrideWith2((languageCode) => TestLanguageStatus()), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(), + ), ], child: TestDownloadLanguagesPage(testObserver), ), @@ -138,7 +138,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final gates = { - for (final code in ['de', 'en', 'fr']) code: Completer() + for (final code in ['de', 'en', 'fr']) code: Completer(), }; final ref = ProviderContainer( overrides: [ diff --git a/test/dropdownbutton_app_language_test.dart b/test/dropdownbutton_app_language_test.dart index 01c8761..af88392 100644 --- a/test/dropdownbutton_app_language_test.dart +++ b/test/dropdownbutton_app_language_test.dart @@ -6,42 +6,53 @@ import 'package:app4training/widgets/dropdownbutton_app_language.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Test DropdownButtonAppLanguage with appLanguage: system default', - (WidgetTester tester) async { - SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); - final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: const MaterialApp( - home: Scaffold(body: DropdownButtonAppLanguage())))); + testWidgets( + 'Test DropdownButtonAppLanguage with appLanguage: system default', + (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + child: const MaterialApp( + home: Scaffold(body: DropdownButtonAppLanguage()), + ), + ), + ); - // Verify initial value - expect(prefs.getString('appLanguage'), 'system'); - expect(find.text('System default'), findsOneWidget); - expect(find.text('Deutsch (de)'), findsNothing); + // Verify initial value + expect(prefs.getString('appLanguage'), 'system'); + expect(find.text('System default'), findsOneWidget); + expect(find.text('Deutsch (de)'), findsNothing); - // Click on the button to expand it: Find the other options as well - await tester.tap(find.byType(DropdownButtonAppLanguage)); - await tester.pump(); - expect(find.text('Deutsch (de)'), findsOneWidget); - expect(find.text('English (en)'), findsOneWidget); + // Click on the button to expand it: Find the other options as well + await tester.tap(find.byType(DropdownButtonAppLanguage)); + await tester.pump(); + expect(find.text('Deutsch (de)'), findsOneWidget); + expect(find.text('English (en)'), findsOneWidget); - // Select German and verify correct UI and saving in SharedPreferences - await tester.tap(find.text('Deutsch (de)')); - await tester.pump(); - expect(find.text('Deutsch (de)'), findsOneWidget); - expect(find.text('System default'), findsNothing); - expect(prefs.getString('appLanguage'), 'de'); - }); + // Select German and verify correct UI and saving in SharedPreferences + await tester.tap(find.text('Deutsch (de)')); + await tester.pump(); + expect(find.text('Deutsch (de)'), findsOneWidget); + expect(find.text('System default'), findsNothing); + expect(prefs.getString('appLanguage'), 'de'); + }, + ); - testWidgets('Test DropdownButtonAppLanguage with appLanguage: German', - (WidgetTester tester) async { + testWidgets('Test DropdownButtonAppLanguage with appLanguage: German', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], child: const MaterialApp( - home: Scaffold(body: DropdownButtonAppLanguage())))); + home: Scaffold(body: DropdownButtonAppLanguage()), + ), + ), + ); // Verify initial value expect(prefs.getString('appLanguage'), 'de'); diff --git a/test/dropdownbutton_automatic_updates_test.dart b/test/dropdownbutton_automatic_updates_test.dart index a3e0129..1509e9a 100644 --- a/test/dropdownbutton_automatic_updates_test.dart +++ b/test/dropdownbutton_automatic_updates_test.dart @@ -7,16 +7,21 @@ import 'package:app4training/data/globals.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Test DropdownButtonAutomaticUpdates with yesAlways', - (WidgetTester tester) async { + testWidgets('Test DropdownButtonAutomaticUpdates with yesAlways', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'automaticUpdates': 'yesAlways'}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], child: const MaterialApp( - // We need the following to access l10n; Locale is default en_US - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: Scaffold(body: DropdownButtonAutomaticUpdates())))); + // We need the following to access l10n; Locale is default en_US + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold(body: DropdownButtonAutomaticUpdates()), + ), + ), + ); // Verify initial value expect(prefs.getString('automaticUpdates'), 'yesAlways'); @@ -38,17 +43,23 @@ void main() { expect(prefs.getString('automaticUpdates'), 'never'); }); - testWidgets('Test DropdownButtonAutomaticUpdates with requireConfirmation', - (WidgetTester tester) async { - SharedPreferences.setMockInitialValues( - {'automaticUpdates': 'requireConfirmation'}); + testWidgets('Test DropdownButtonAutomaticUpdates with requireConfirmation', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': 'requireConfirmation', + }); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], child: const MaterialApp( - // We need the following to access l10n; Locale is default en_US - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: Scaffold(body: DropdownButtonAutomaticUpdates())))); + // We need the following to access l10n; Locale is default en_US + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold(body: DropdownButtonAutomaticUpdates()), + ), + ), + ); // Verify initial value expect(prefs.getString('automaticUpdates'), 'requireConfirmation'); diff --git a/test/dropdownbutton_check_frequency_test.dart b/test/dropdownbutton_check_frequency_test.dart index f15310a..d8ad95e 100644 --- a/test/dropdownbutton_check_frequency_test.dart +++ b/test/dropdownbutton_check_frequency_test.dart @@ -10,21 +10,30 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'background_scheduler_test.dart'; void main() { - testWidgets('Test DropdownButtonCheckFrequency with frequency: daily', - (WidgetTester tester) async { + testWidgets('Test DropdownButtonCheckFrequency with frequency: daily', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'checkFrequency': 'daily'}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - backgroundSchedulerProvider.overrideWith(() => TestBackgroundScheduler()) - ]); + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + ], + ); expect(ref.read(backgroundSchedulerProvider), false); - await tester.pumpWidget(UncontrolledProviderScope( + await tester.pumpWidget( + UncontrolledProviderScope( container: ref, child: const MaterialApp( - // We need the following to access l10n; Locale is default en_US - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: Scaffold(body: DropdownButtonCheckFrequency())))); + // We need the following to access l10n; Locale is default en_US + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold(body: DropdownButtonCheckFrequency()), + ), + ), + ); // Verify initial value expect(prefs.getString('checkFrequency'), 'daily'); @@ -46,21 +55,30 @@ void main() { expect(ref.read(backgroundSchedulerProvider), false); }); - testWidgets('Test DropdownButtonCheckFrequency with frequency: monthly', - (WidgetTester tester) async { + testWidgets('Test DropdownButtonCheckFrequency with frequency: monthly', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'checkFrequency': 'monthly'}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - backgroundSchedulerProvider.overrideWith(() => TestBackgroundScheduler()) - ]); + final ref = ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + ], + ); expect(ref.read(backgroundSchedulerProvider), false); - await tester.pumpWidget(UncontrolledProviderScope( + await tester.pumpWidget( + UncontrolledProviderScope( container: ref, child: const MaterialApp( - // We need the following to access l10n; Locale is default en_US - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: Scaffold(body: DropdownButtonCheckFrequency())))); + // We need the following to access l10n; Locale is default en_US + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold(body: DropdownButtonCheckFrequency()), + ), + ), + ); // Verify initial value expect(prefs.getString('checkFrequency'), 'monthly'); diff --git a/test/exceptions_test.dart b/test/exceptions_test.dart index a5be1d6..ccfa4a6 100644 --- a/test/exceptions_test.dart +++ b/test/exceptions_test.dart @@ -14,10 +14,15 @@ void main() { try { throw LanguageCorruptedException( - 'de', 'Test message', const FileSystemException()); + 'de', + 'Test message', + const FileSystemException(), + ); } catch (e) { - expect(e.toString(), - startsWith("Language data for 'de' seems to be corrupted")); + expect( + e.toString(), + startsWith("Language data for 'de' seems to be corrupted"), + ); expect(e.toString(), contains('Test message')); expect(e.toString(), contains('FileSystemException')); } @@ -30,28 +35,37 @@ void main() { }); testWidgets('Test translated error messages', (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - locale: const Locale('de'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Container(), - )); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Container(), + ), + ); BuildContext context = tester.element(find.byType(Container)); try { throw LanguageNotDownloadedException('de'); } on App4TrainingException catch (e) { - expect(e.toLocalizedString(context), - startsWith("Sprache ist nicht verfügbar")); + expect( + e.toLocalizedString(context), + startsWith("Sprache ist nicht verfügbar"), + ); } try { throw LanguageCorruptedException( - 'de', 'Test message', const FileSystemException()); + 'de', + 'Test message', + const FileSystemException(), + ); } on App4TrainingException catch (e) { expect( - e.toLocalizedString(context), - startsWith( - "Die Sprachdaten von 'Deutsch (de)' scheinen beschädigt zu sein")); + e.toLocalizedString(context), + startsWith( + "Die Sprachdaten von 'Deutsch (de)' scheinen beschädigt zu sein", + ), + ); expect(e.toLocalizedString(context), contains('Test message')); expect(e.toLocalizedString(context), contains('FileSystemException')); } @@ -59,8 +73,10 @@ void main() { try { throw PageNotFoundException('Prayer', 'fr'); } on App4TrainingException catch (e) { - expect(e.toLocalizedString(context), - startsWith('Seite Prayer/fr konnte nicht gefunden werden.')); + expect( + e.toLocalizedString(context), + startsWith('Seite Prayer/fr konnte nicht gefunden werden.'), + ); } }); } diff --git a/test/globals_test.dart b/test/globals_test.dart index e93be0a..270ec58 100644 --- a/test/globals_test.dart +++ b/test/globals_test.dart @@ -4,19 +4,23 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('Test remote URLs and local path', () { expect( - Globals.getRemoteUrlHtml('de'), - equals( - 'https://github.com/4training/html-de/archive/refs/heads/main.zip')); + Globals.getRemoteUrlHtml('de'), + equals( + 'https://github.com/4training/html-de/archive/refs/heads/main.zip', + ), + ); expect( - Globals.getRemoteUrlPdf('de'), - equals( - 'https://github.com/4training/pdf-de/archive/refs/heads/main.zip')); + Globals.getRemoteUrlPdf('de'), + equals('https://github.com/4training/pdf-de/archive/refs/heads/main.zip'), + ); expect(Globals.getAssetsDir('de'), equals('assets-de')); expect(Globals.getResourcesDir('de'), equals('html-de-main')); expect(Globals.getPdfDir('de'), equals('pdf-de-main')); expect( - Globals.getCommitsSince('de', DateTime.utc(2023)), - equals( - 'https://api.github.com/repos/4training/html-de/commits?since=2023-01-01T00:00:00.000Z')); + Globals.getCommitsSince('de', DateTime.utc(2023)), + equals( + 'https://api.github.com/repos/4training/html-de/commits?since=2023-01-01T00:00:00.000Z', + ), + ); }); } diff --git a/test/html_view_table_test.dart b/test/html_view_table_test.dart index bb00a9c..06652ec 100644 --- a/test/html_view_table_test.dart +++ b/test/html_view_table_test.dart @@ -9,14 +9,13 @@ import 'package:flutter_test/flutter_test.dart'; /// make sure HtmlView can render common table shapes without throwing. void main() { Future pump(WidgetTester tester, String body) async { - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: HtmlView( - '$body', - TextDirection.ltr, + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: HtmlView('$body', TextDirection.ltr), ), ), - )); + ); await tester.pumpAndSettle(); } @@ -30,8 +29,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('wraps each in a visible horizontal Scrollbar', - (tester) async { + testWidgets('wraps each
in a visible horizontal Scrollbar', ( + tester, + ) async { // Guards the UX affordance: users need a visible scroll thumb to // discover that wide tables are horizontally scrollable. await pump(tester, ''' @@ -50,14 +50,19 @@ void main() { // here belongs to a _HorizontalTableScroll wrapper. expect(find.byType(Scrollbar), findsNWidgets(2)); // And the scroll direction inside each wrapper must be horizontal. - final horizontalScrolls = tester - .widgetList(find.byType(SingleChildScrollView)) - .where((w) => w.scrollDirection == Axis.horizontal) - .toList(); + final horizontalScrolls = + tester + .widgetList( + find.byType(SingleChildScrollView), + ) + .where((w) => w.scrollDirection == Axis.horizontal) + .toList(); expect(horizontalScrolls.length, 2); }); - testWidgets('renders a table with long non-breaking cell content', (tester) async { + testWidgets('renders a table with long non-breaking cell content', ( + tester, + ) async { // Long single token that cannot wrap — this is the kind of content that // causes layout problems on some translations of Time With God. await pump(tester, ''' @@ -94,8 +99,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('renders a table with th[style] (stripped by sanitize)', - (tester) async { + testWidgets('renders a table with th[style] (stripped by sanitize)', ( + tester, + ) async { await pump(tester, '''
@@ -113,8 +119,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('renders a table with empty
cells (fill-in-the-blank)', - (tester) async { + testWidgets('renders a table with empty cells (fill-in-the-blank)', ( + tester, + ) async { // Real Time_with_God table shape: lots of empty cells for users to fill in. await pump(tester, ''' @@ -144,8 +151,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('renders a
nested inside a
  • (Time_with_God shape)', - (tester) async { + testWidgets('renders a
  • nested inside a
  • (Time_with_God shape)', ( + tester, + ) async { // This is the exact shape from Time_with_God line 70: a table inside a // list item inside a ul. It uses a
    before the nested table. await pump(tester, ''' @@ -175,8 +183,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('renders the real Time_with_God fixture end-to-end', - (tester) async { + testWidgets('renders the real Time_with_God fixture end-to-end', ( + tester, + ) async { // Pumping the exact page content that was crashing in production. // Critically: // 1. enable semantics — on a real device the semantics pass walks the @@ -190,8 +199,10 @@ void main() { final semantics = tester.ensureSemantics(); try { await tester.binding.setSurfaceSize(const Size(390, 844)); - final html = File('test/assets-en/html-en-main/Time_with_God.html') - .readAsStringSync(); + final html = + File( + 'test/assets-en/html-en-main/Time_with_God.html', + ).readAsStringSync(); await pump(tester, html); expect(tester.takeException(), isNull); } finally { @@ -200,8 +211,9 @@ void main() { } }); - testWidgets('renders a table wrapped in the standard page chrome', - (tester) async { + testWidgets('renders a table wrapped in the standard page chrome', ( + tester, + ) async { // Mimics the structure of a real Time With God page: headings, // paragraphs, and a table together. await pump(tester, ''' diff --git a/test/language_downloader_test.dart b/test/language_downloader_test.dart index 331cb32..bbf9515 100644 --- a/test/language_downloader_test.dart +++ b/test/language_downloader_test.dart @@ -24,14 +24,15 @@ Uint8List createTestZip(Map files) { /// Helper to set up a mock Dio response for a URL returning zip bytes. void mockDioGet(MockDio dio, String url, Uint8List zipBytes) { - when(() => dio.get>( - url, - options: any(named: 'options'), - )).thenAnswer((_) async => Response( - data: zipBytes.toList(), - statusCode: 200, - requestOptions: RequestOptions(path: url), - )); + when( + () => dio.get>(url, options: any(named: 'options')), + ).thenAnswer( + (_) async => Response( + data: zipBytes.toList(), + statusCode: 200, + requestOptions: RequestOptions(path: url), + ), + ); } void main() { @@ -68,41 +69,53 @@ void main() { expect(await downloader.isDownloaded('de'), true); final contentsJson = fs.file( - '/app-docs/assets-de/${Globals.getResourcesDir('de')}/structure/contents.json'); + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/structure/contents.json', + ); expect(await contentsJson.exists(), true); expect(await contentsJson.readAsString(), '{"worksheets":[]}'); final indexHtml = fs.file( - '/app-docs/assets-de/${Globals.getResourcesDir('de')}/index.html'); + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/index.html', + ); expect(await indexHtml.exists(), true); final pdfFile = fs.file( - '/app-docs/assets-de/${Globals.getPdfDir('de')}/test.pdf'); + '/app-docs/assets-de/${Globals.getPdfDir('de')}/test.pdf', + ); expect(await pdfFile.exists(), true); // No staging dir left behind expect(await fs.directory('/app-docs/assets-de.staging').exists(), false); }); - test('Network failure: nothing remains at pathFor and no staging leftover', - () async { - // HTML download succeeds but PDF download throws - final htmlZip = createTestZip({ - '${Globals.getResourcesDir('fr')}/index.html': '

    Bonjour

    ', - }); - mockDioGet(dio, Globals.getRemoteUrlHtml('fr'), htmlZip); - when(() => dio.get>( + test( + 'Network failure: nothing remains at pathFor and no staging leftover', + () async { + // HTML download succeeds but PDF download throws + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('fr')}/index.html': '

    Bonjour

    ', + }); + mockDioGet(dio, Globals.getRemoteUrlHtml('fr'), htmlZip); + when( + () => dio.get>( Globals.getRemoteUrlPdf('fr'), options: any(named: 'options'), - )).thenThrow(DioException( - requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('fr')), - )); + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('fr')), + ), + ); - await expectLater(downloader.download('fr'), throwsA(isA())); + await expectLater( + downloader.download('fr'), + throwsA(isA()), + ); - expect(await downloader.isDownloaded('fr'), false); - expect(await fs.directory('/app-docs/assets-fr.staging').exists(), false); - }); + expect(await downloader.isDownloaded('fr'), false); + expect(await fs.directory('/app-docs/assets-fr.staging').exists(), false); + }, + ); test('Corrupted zip: cleanup same as network failure', () async { // A zip header (PK\x03\x04) followed by garbage triggers a real decode error @@ -132,20 +145,25 @@ void main() { '${Globals.getResourcesDir('it')}/file.html': '

    ciao

    ', }); mockDioGet(dio, Globals.getRemoteUrlHtml('it'), htmlZip); - when(() => dio.get>( - Globals.getRemoteUrlPdf('it'), - options: any(named: 'options'), - )).thenThrow(DioException( - requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('it')), - )); + when( + () => dio.get>( + Globals.getRemoteUrlPdf('it'), + options: any(named: 'options'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: Globals.getRemoteUrlPdf('it')), + ), + ); await expectLater(downloader.download('it'), throwsA(isA())); // Prior data is intact expect(await downloader.isDownloaded('it'), true); expect( - await fs.file('/app-docs/assets-it/existing.txt').readAsString(), - 'precious'); + await fs.file('/app-docs/assets-it/existing.txt').readAsString(), + 'precious', + ); }); test('Different languages download in parallel', () async { @@ -167,10 +185,12 @@ void main() { '${Globals.getPdfDir('fr')}/file.pdf': 'fr-pdf', }); - when(() => dio.get>( - Globals.getRemoteUrlHtml('de'), - options: any(named: 'options'), - )).thenAnswer((_) async { + when( + () => dio.get>( + Globals.getRemoteUrlHtml('de'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async { deHtmlInFlight = true; await deHtmlGate.future; return Response( @@ -179,18 +199,24 @@ void main() { requestOptions: RequestOptions(), ); }); - when(() => dio.get>( - Globals.getRemoteUrlPdf('de'), - options: any(named: 'options'), - )).thenAnswer((_) async => Response( - data: pdfZip1.toList(), - statusCode: 200, - requestOptions: RequestOptions(), - )); - when(() => dio.get>( - Globals.getRemoteUrlHtml('fr'), - options: any(named: 'options'), - )).thenAnswer((_) async { + when( + () => dio.get>( + Globals.getRemoteUrlPdf('de'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Response( + data: pdfZip1.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ), + ); + when( + () => dio.get>( + Globals.getRemoteUrlHtml('fr'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async { frHtmlInFlight = true; await frHtmlGate.future; return Response( @@ -199,14 +225,18 @@ void main() { requestOptions: RequestOptions(), ); }); - when(() => dio.get>( - Globals.getRemoteUrlPdf('fr'), - options: any(named: 'options'), - )).thenAnswer((_) async => Response( - data: pdfZip2.toList(), - statusCode: 200, - requestOptions: RequestOptions(), - )); + when( + () => dio.get>( + Globals.getRemoteUrlPdf('fr'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Response( + data: pdfZip2.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ), + ); final f1 = downloader.download('de'); await Future.delayed(Duration.zero); @@ -232,10 +262,12 @@ void main() { '${Globals.getPdfDir('de')}/file.pdf': 'de-pdf', }); - when(() => dio.get>( - Globals.getRemoteUrlHtml('de'), - options: any(named: 'options'), - )).thenAnswer((_) async { + when( + () => dio.get>( + Globals.getRemoteUrlHtml('de'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async { htmlCallCount++; await gate.future; return Response( @@ -244,14 +276,18 @@ void main() { requestOptions: RequestOptions(), ); }); - when(() => dio.get>( - Globals.getRemoteUrlPdf('de'), - options: any(named: 'options'), - )).thenAnswer((_) async => Response( - data: pdfZip.toList(), - statusCode: 200, - requestOptions: RequestOptions(), - )); + when( + () => dio.get>( + Globals.getRemoteUrlPdf('de'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Response( + data: pdfZip.toList(), + statusCode: 200, + requestOptions: RequestOptions(), + ), + ); final f1 = downloader.download('de'); await Future.delayed(Duration.zero); @@ -264,88 +300,108 @@ void main() { expect(htmlCallCount, 2); }); - test('Only kMaxParallelZipDecodes archives are decoded at the same time', - () async { - // Every decode blocks on a gate so we can watch how many run at once - var inFlight = 0; - var maxInFlight = 0; - final gates = >[]; - Future> gatedDecoder(Uint8List zipBytes) async { - inFlight++; - maxInFlight = max(maxInFlight, inFlight); - final gate = Completer(); - gates.add(gate); - await gate.future; - inFlight--; - return decodeZipEntries(zipBytes); - } - - downloader = LanguageDownloaderImpl( - root: root, dio: dio, fileSystem: fs, zipDecoder: gatedDecoder); - - const langCodes = ['de', 'fr', 'es']; - for (final langCode in langCodes) { - mockDioGet(dio, Globals.getRemoteUrlHtml(langCode), - createTestZip({'${Globals.getResourcesDir('de')}/f.html': 'x'})); - mockDioGet(dio, Globals.getRemoteUrlPdf(langCode), - createTestZip({'${Globals.getPdfDir('de')}/f.pdf': 'x'})); - } - - final downloads = [for (final code in langCodes) downloader.download(code)]; - var finished = false; - unawaited(Future.wait(downloads).then((_) => finished = true)); - - // Let all three downloads reach their first decode - for (var i = 0; i < 5; i++) { - await Future.delayed(Duration.zero); - } - expect(inFlight, kMaxParallelZipDecodes); - expect(gates.length, kMaxParallelZipDecodes); - - // Now let them through one by one - a queued decode may only start - // once a running one has finished - for (var i = 0; !finished && i < 100; i++) { - if (gates.isNotEmpty) gates.removeAt(0).complete(); - await Future.delayed(Duration.zero); - } - expect(finished, isTrue); - expect(maxInFlight, kMaxParallelZipDecodes); - for (final langCode in langCodes) { - expect(await downloader.isDownloaded(langCode), true); - } - }); - - test('Crash recovery: pre-seeded staging dir is wiped by next download', - () async { - // Simulate crashed prior run leaving a staging dir - await fs - .directory('/app-docs/assets-de.staging/leftover') - .create(recursive: true); - await fs - .file('/app-docs/assets-de.staging/leftover/junk.txt') - .writeAsString('crash-leftover'); - - final htmlZip = createTestZip({ - '${Globals.getResourcesDir('de')}/fresh.html': '

    Fresh

    ', - }); - final pdfZip = createTestZip({ - '${Globals.getPdfDir('de')}/fresh.pdf': 'fresh-pdf', - }); - - mockDioGet(dio, Globals.getRemoteUrlHtml('de'), htmlZip); - mockDioGet(dio, Globals.getRemoteUrlPdf('de'), pdfZip); - - await downloader.download('de'); + test( + 'Only kMaxParallelZipDecodes archives are decoded at the same time', + () async { + // Every decode blocks on a gate so we can watch how many run at once + var inFlight = 0; + var maxInFlight = 0; + final gates = >[]; + Future> gatedDecoder(Uint8List zipBytes) async { + inFlight++; + maxInFlight = max(maxInFlight, inFlight); + final gate = Completer(); + gates.add(gate); + await gate.future; + inFlight--; + return decodeZipEntries(zipBytes); + } + + downloader = LanguageDownloaderImpl( + root: root, + dio: dio, + fileSystem: fs, + zipDecoder: gatedDecoder, + ); - // Old staging leftover is gone - expect( - await fs.file('/app-docs/assets-de/leftover/junk.txt').exists(), false); - // Fresh content is there - expect( + const langCodes = ['de', 'fr', 'es']; + for (final langCode in langCodes) { + mockDioGet( + dio, + Globals.getRemoteUrlHtml(langCode), + createTestZip({'${Globals.getResourcesDir('de')}/f.html': 'x'}), + ); + mockDioGet( + dio, + Globals.getRemoteUrlPdf(langCode), + createTestZip({'${Globals.getPdfDir('de')}/f.pdf': 'x'}), + ); + } + + final downloads = [ + for (final code in langCodes) downloader.download(code), + ]; + var finished = false; + unawaited(Future.wait(downloads).then((_) => finished = true)); + + // Let all three downloads reach their first decode + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + expect(inFlight, kMaxParallelZipDecodes); + expect(gates.length, kMaxParallelZipDecodes); + + // Now let them through one by one - a queued decode may only start + // once a running one has finished + for (var i = 0; !finished && i < 100; i++) { + if (gates.isNotEmpty) gates.removeAt(0).complete(); + await Future.delayed(Duration.zero); + } + expect(finished, isTrue); + expect(maxInFlight, kMaxParallelZipDecodes); + for (final langCode in langCodes) { + expect(await downloader.isDownloaded(langCode), true); + } + }, + ); + + test( + 'Crash recovery: pre-seeded staging dir is wiped by next download', + () async { + // Simulate crashed prior run leaving a staging dir + await fs + .directory('/app-docs/assets-de.staging/leftover') + .create(recursive: true); + await fs + .file('/app-docs/assets-de.staging/leftover/junk.txt') + .writeAsString('crash-leftover'); + + final htmlZip = createTestZip({ + '${Globals.getResourcesDir('de')}/fresh.html': '

    Fresh

    ', + }); + final pdfZip = createTestZip({ + '${Globals.getPdfDir('de')}/fresh.pdf': 'fresh-pdf', + }); + + mockDioGet(dio, Globals.getRemoteUrlHtml('de'), htmlZip); + mockDioGet(dio, Globals.getRemoteUrlPdf('de'), pdfZip); + + await downloader.download('de'); + + // Old staging leftover is gone + expect( + await fs.file('/app-docs/assets-de/leftover/junk.txt').exists(), + false, + ); + // Fresh content is there + expect( await fs .file( - '/app-docs/assets-de/${Globals.getResourcesDir('de')}/fresh.html') + '/app-docs/assets-de/${Globals.getResourcesDir('de')}/fresh.html', + ) .exists(), - true); - }); + true, + ); + }, + ); } diff --git a/test/language_selection_button_test.dart b/test/language_selection_button_test.dart index cbc45d8..13701a7 100644 --- a/test/language_selection_button_test.dart +++ b/test/language_selection_button_test.dart @@ -20,24 +20,36 @@ class TestLanguagesButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - // we need ViewPage here because LanguagesButton uses - // context.findAncestorWidgetOfExactType() to get current page - home: const ViewPage('Healing', 'de')); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + // we need ViewPage here because LanguagesButton uses + // context.findAncestorWidgetOfExactType() to get current page + home: const ViewPage('Healing', 'de'), + ); } } void main() { - testWidgets('Test with only German downloaded, English as appLanguage', - (WidgetTester tester) async { - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('en')), - languageProvider.overrideWith2((languageCode) => TestLanguageController( - downloadedLanguages: ['de'], - pages: {'Healing': const Page('test', 'test', 'test', '1.0', null)})) - ], child: const TestLanguagesButton())); + testWidgets('Test with only German downloaded, English as appLanguage', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController( + downloadedLanguages: ['de'], + pages: { + 'Healing': const Page('test', 'test', 'test', '1.0', null), + }, + ), + ), + ], + child: const TestLanguagesButton(), + ), + ); expect(find.byIcon(Icons.translate), findsOneWidget); expect(find.text('German (de)'), findsNothing); @@ -53,14 +65,25 @@ void main() { expect(find.text('Manage languages'), findsOneWidget); }); - testWidgets('Test with 5 languages downloaded, German as appLanguage', - (WidgetTester tester) async { - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController( - downloadedLanguages: ['de', 'en', 'fr', 'es', 'ar'], - pages: {'Healing': const Page('test', 'test', 'test', '1.0', null)})) - ], child: const TestLanguagesButton())); + testWidgets('Test with 5 languages downloaded, German as appLanguage', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController( + downloadedLanguages: ['de', 'en', 'fr', 'es', 'ar'], + pages: { + 'Healing': const Page('test', 'test', 'test', '1.0', null), + }, + ), + ), + ], + child: const TestLanguagesButton(), + ), + ); expect(find.byIcon(Icons.translate), findsOneWidget); expect(find.text('Deutsch (de)'), findsNothing); @@ -79,7 +102,7 @@ void main() { 'Deutsch (de)', 'Englisch (en)', 'Französisch (fr)', - 'Spanisch (es)' + 'Spanisch (es)', ]; List offsets = []; @@ -96,16 +119,25 @@ void main() { final container = ProviderContainer(); final availableLanguages = container.read(availableLanguagesProvider); - await tester.pumpWidget(UncontrolledProviderScope( + await tester.pumpWidget( + UncontrolledProviderScope( container: container, - child: ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((languageCode) => TestLanguageController( - downloadedLanguages: availableLanguages, - pages: { - 'Healing': const Page('test', 'test', 'test', '1.0', null) - })) - ], child: const TestLanguagesButton()))); + child: ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (languageCode) => TestLanguageController( + downloadedLanguages: availableLanguages, + pages: { + 'Healing': const Page('test', 'test', 'test', '1.0', null), + }, + ), + ), + ], + child: const TestLanguagesButton(), + ), + ), + ); expect(find.byIcon(Icons.translate), findsOneWidget); expect(find.text('Deutsch (de)'), findsNothing); diff --git a/test/languages_test.dart b/test/languages_test.dart index 100a6d9..6f1ebd4 100644 --- a/test/languages_test.dart +++ b/test/languages_test.dart @@ -23,13 +23,13 @@ class TestLanguageController extends LanguageController { final List? _downloadedLanguages; final Map _pages; // map of pages that are available final bool _initReturns; - TestLanguageController( - {List? downloadedLanguages, - Map pages = const {}, - initReturns = false}) - : _downloadedLanguages = downloadedLanguages, - _pages = pages, - _initReturns = initReturns; + TestLanguageController({ + List? downloadedLanguages, + Map pages = const {}, + initReturns = false, + }) : _downloadedLanguages = downloadedLanguages, + _pages = pages, + _initReturns = initReturns; @override Language build() { @@ -41,14 +41,26 @@ class TestLanguageController extends LanguageController { if (_downloadedLanguages != null) { downloaded = _downloadedLanguages.contains(languageCode); } - return Language(downloaded ? languageCode : '', _pages, const [], const {}, - '', DateTime.utc(2023)); + return Language( + downloaded ? languageCode : '', + _pages, + const [], + const {}, + '', + DateTime.utc(2023), + ); } @override Future download() async { - state = Language(languageCode, _pages, const [], const {}, '', - DateTime.now().toUtc()); + state = Language( + languageCode, + _pages, + const [], + const {}, + '', + DateTime.now().toUtc(), + ); return true; } @@ -75,7 +87,8 @@ class TestLanguageController extends LanguageController { /// Only structure/contents.json is existing (with dummy contents) /// But that's enough for Languages.lazyInit() Future createBasicFileSystem( - List downloadedLangs) async { + List downloadedLangs, +) async { var fileSystem = MemoryFileSystem(); for (final lang in downloadedLangs) { await fileSystem @@ -92,10 +105,12 @@ void main() { test('Test init() when no files are there', () async { final fileSystem = MemoryFileSystem(); final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ], + ); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.init(), false); expect(frTest.state.downloaded, false); @@ -106,10 +121,12 @@ void main() { test('Test lazyInit() when no files are there', () async { final fileSystem = MemoryFileSystem(); final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ], + ); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.lazyInit(), false); expect(frTest.state.downloaded, false); @@ -118,10 +135,12 @@ void main() { test('Test that download() starts the download', () async { final fileSystem = MemoryFileSystem(); final fakeDownloader = FakeLanguageDownloader(fileSystem: fileSystem); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ], + ); final frTest = ref.read(languageProvider('fr').notifier); // as we're faking, the language won't be available after download @@ -133,12 +152,16 @@ void main() { test('Test failing download', () async { final fileSystem = MemoryFileSystem(); - final fakeDownloader = - FakeLanguageDownloader(fileSystem: fileSystem, throwOnDownload: true); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - ]); + final fakeDownloader = FakeLanguageDownloader( + fileSystem: fileSystem, + throwOnDownload: true, + ); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + ], + ); final frTest = ref.read(languageProvider('fr').notifier); expect(await frTest.download(), false); @@ -150,11 +173,14 @@ void main() { group('Test error handling of incorrect files / structure', () { test('Test error handling when no files can be found at all', () async { final fileSystem = MemoryFileSystem(); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue( - FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), false); @@ -166,15 +192,19 @@ void main() { await fileSystem .directory('assets-de/html-de-main/structure') .create(recursive: true); - var contentsJson = - fileSystem.file('assets-de/html-de-main/structure/contents.json'); + var contentsJson = fileSystem.file( + 'assets-de/html-de-main/structure/contents.json', + ); await contentsJson.writeAsString('invalid'); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue( - FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), false); expect(deTest.state.downloaded, false); @@ -188,59 +218,81 @@ void main() { .directory('assets-de/html-de-main/structure') .create(recursive: true); var readFileSystem = ChrootFileSystem( - const LocalFileSystem(), path.canonicalize('test/')); + const LocalFileSystem(), + path.canonicalize('test/'), + ); String jsonPath = 'assets-de/html-de-main/structure/contents.json'; var contentsJson = fileSystem.file(jsonPath); - await contentsJson - .writeAsString(await readFileSystem.file(jsonPath).readAsString()); - - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue( - FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + await contentsJson.writeAsString( + await readFileSystem.file(jsonPath).readAsString(), + ); + + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); // init() should work (even if expected HTML files are missing) final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), true); expect(deTest.state.downloaded, true); - expect(deTest.state.downloadTimestamp.compareTo(DateTime(2023)), - greaterThan(0)); + expect( + deTest.state.downloadTimestamp.compareTo(DateTime(2023)), + greaterThan(0), + ); }); }); test('Test lazyInit() when language is available', () async { // We construct a file system in memory with structure/contents.json final fileSystem = await createBasicFileSystem(['de']); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue( - FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); expect(await ref.read(languageProvider('de').notifier).lazyInit(), true); final deStatus = ref.read(languageProvider('de')); expect(deStatus.downloaded, true); expect(deStatus.path, equals('assets-de/html-de-main')); expect( - deStatus.downloadTimestamp.compareTo(DateTime(2023)), greaterThan(0)); + deStatus.downloadTimestamp.compareTo(DateTime(2023)), + greaterThan(0), + ); }); test('Test everything with real content from test/assets-de/', () async { final fileSystem = ChrootFileSystem( - const LocalFileSystem(), path.canonicalize('test/')); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + const LocalFileSystem(), + path.canonicalize('test/'), + ); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); final deTest = ref.read(languageProvider('de').notifier); expect(await deTest.init(), true); // Loads Gottes_Geschichte_(fünf_Finger).html - String content = await ref.read(pageContentProvider( - (name: "God's_Story_(five_fingers)", langCode: 'de')).future); + String content = await ref.read( + pageContentProvider(( + name: "God's_Story_(five_fingers)", + langCode: 'de', + )).future, + ); expect(content, startsWith('

    Gottes Geschichte')); // The link of this image should have been replaced with image content @@ -249,19 +301,22 @@ void main() { // This should still be there as the image file is missing expect(content, contains('src="files/Hand_5.png"')); // PDF should be available - expect(deTest.state.pages['Forgiving_Step_by_Step']?.pdfPath, - equals('assets-de/pdf-de-main/Schritte_der_Vergebung.pdf')); + expect( + deTest.state.pages['Forgiving_Step_by_Step']?.pdfPath, + equals('assets-de/pdf-de-main/Schritte_der_Vergebung.pdf'), + ); // This PDF is missing expect(deTest.state.pages['MissingTest']?.pdfPath, isNull); // Test Languages.getPageTitles() expect( - deTest.state.getPageTitles().values, - orderedEquals(const [ - 'Gottes Geschichte (fünf Finger)', - 'Schritte der Vergebung', - 'MissingTest' - ])); + deTest.state.getPageTitles().values, + orderedEquals(const [ + 'Gottes Geschichte (fünf Finger)', + 'Schritte der Vergebung', + 'MissingTest', + ]), + ); expect(deTest.state.path, equals('assets-de/html-de-main')); // Test some error handling @@ -270,18 +325,22 @@ void main() { ref.read(pageContentProvider((name: 'Invalid', langCode: 'de'))); await Future.delayed(const Duration(milliseconds: 500)); - final missingResult = - ref.read(pageContentProvider((name: 'MissingTest', langCode: 'de'))); + final missingResult = ref.read( + pageContentProvider((name: 'MissingTest', langCode: 'de')), + ); expect(missingResult.hasError, true); // In Riverpod v3, errors are wrapped in ProviderException var error = missingResult.error; if (error is ProviderException) error = error.exception; expect(error, isA()); - expect((error as LanguageCorruptedException).exception, - isA()); + expect( + (error as LanguageCorruptedException).exception, + isA(), + ); - final invalidResult = - ref.read(pageContentProvider((name: 'Invalid', langCode: 'de'))); + final invalidResult = ref.read( + pageContentProvider((name: 'Invalid', langCode: 'de')), + ); expect(invalidResult.hasError, true); error = invalidResult.error; if (error is ProviderException) error = error.exception; @@ -290,13 +349,18 @@ void main() { }); test('Test languageSizeProvider and diskUsageProvider', () async { - final fileSystem = - ChrootFileSystem(const LocalFileSystem(), path.canonicalize('test/')); - final ref = ProviderContainer(overrides: [ - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ]); + final fileSystem = ChrootFileSystem( + const LocalFileSystem(), + path.canonicalize('test/'), + ); + final ref = ProviderContainer( + overrides: [ + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + ); // Sizes are only calculated for languages that are actually loaded expect(await ref.read(languageSizeProvider('de').future), 0); @@ -313,10 +377,14 @@ void main() { }); test('Test countDownloadedLanguagesProvider', () { - final ref = ProviderContainer(overrides: [ - languageProvider.overrideWith2((langCode) => - TestLanguageController(downloadedLanguages: ['de', 'fr', 'en'])), - ]); + final ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2( + (langCode) => + TestLanguageController(downloadedLanguages: ['de', 'fr', 'en']), + ), + ], + ); expect(ref.read(countDownloadedLanguagesProvider), 3); }); } diff --git a/test/main_drawer_test.dart b/test/main_drawer_test.dart index f9ce8f9..f32bfc8 100644 --- a/test/main_drawer_test.dart +++ b/test/main_drawer_test.dart @@ -12,9 +12,12 @@ import 'package:shared_preferences/shared_preferences.dart'; // ignore: implementation_imports, invalid_use_of_internal_member import 'package:riverpod/src/framework.dart' show $RefArg; +import 'package:app4training/data/updates.dart'; + import 'app_language_test.dart'; import 'languages_test.dart'; import 'routes_test.dart'; +import 'updates_test.dart'; // Simulate that five pages are downloaded in most languages. // French only has "Prayer" available. @@ -288,6 +291,8 @@ void main() { testWidgets('Test error message when appLanguage is not downloaded', ( WidgetTester tester, ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -295,6 +300,7 @@ void main() { languageProvider.overrideWith2( (languageCode) => TestLanguageController(downloadedLanguages: []), ), + sharedPrefsProvider.overrideWithValue(prefs), ], child: const TestApp(), ), @@ -311,6 +317,63 @@ void main() { expect(find.textContaining('lade Deutsch (de) herunter'), findsOneWidget); }); + testWidgets('Settings entry shows an indicator dot under requireConfirmation ' + 'when updates are available', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': 'requireConfirmation', + }); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (lang) => CustomTestLanguageController(), + ), + sharedPrefsProvider.overrideWithValue(prefs), + languageStatusProvider.overrideWith2( + (lang) => TestLanguageStatus(langWithUpdates: ['de']), + ), + ], + child: const TestApp(), + ), + ); + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + await tester.pumpAndSettle(); + + expect(find.text('Einstellungen'), findsOneWidget); + expect(find.byIcon(Icons.circle), findsOneWidget); + }); + + testWidgets('No indicator dot under yesAlways even with updates', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({'automaticUpdates': 'yesAlways'}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2( + (lang) => CustomTestLanguageController(), + ), + sharedPrefsProvider.overrideWithValue(prefs), + languageStatusProvider.overrideWith2( + (lang) => TestLanguageStatus(langWithUpdates: ['de']), + ), + ], + child: const TestApp(), + ), + ); + final ScaffoldState state = tester.firstState(find.byType(Scaffold)); + state.openDrawer(); + await tester.pumpAndSettle(); + + expect(find.text('Einstellungen'), findsOneWidget); + expect(find.byIcon(Icons.circle), findsNothing); + }); + // TODO: test that currently opened page is highlighted in menu // TODO: test the two dialogs when clicking on an icon / greyed-out icon } diff --git a/test/routes_test.dart b/test/routes_test.dart index 5ae0a73..218218c 100644 --- a/test/routes_test.dart +++ b/test/routes_test.dart @@ -6,6 +6,7 @@ import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:app4training/routes/error_page.dart'; import 'package:app4training/routes/home_page.dart'; import 'package:app4training/routes/onboarding/download_languages_page.dart'; +import 'package:app4training/routes/onboarding/set_update_prefs_page.dart'; import 'package:app4training/routes/onboarding/welcome_page.dart'; import 'package:file/memory.dart'; import 'package:flutter/material.dart'; @@ -57,9 +58,12 @@ void main() { final prefs = await SharedPreferences.getInstance(); final TestObserver observer = TestObserver(); - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestApp(observer))); + child: TestApp(observer), + ), + ); // Test that onboarding process get's started on first app usage expect(find.byType(TestApp), findsOneWidget); @@ -68,34 +72,42 @@ void main() { expect(find.byType(WelcomePage), findsOneWidget); // Test second onboarding step - unawaited(Navigator.of(tester.element(find.byType(WelcomePage))) - .pushReplacementNamed('/onboarding/2')); + unawaited( + Navigator.of( + tester.element(find.byType(WelcomePage)), + ).pushReplacementNamed('/onboarding/2'), + ); await tester.pumpAndSettle(); expect(find.byType(DownloadLanguagesPage), findsOneWidget); // Go back again - unawaited(Navigator.of(tester.element(find.byType(DownloadLanguagesPage))) - .pushReplacementNamed('/onboarding/1')); + unawaited( + Navigator.of( + tester.element(find.byType(DownloadLanguagesPage)), + ).pushReplacementNamed('/onboarding/1'), + ); await tester.pumpAndSettle(); expect(find.byType(WelcomePage), findsOneWidget); -/* TODO for version 0.9 // Test third onboarding step - unawaited(Navigator.of(tester.element(find.byType(WelcomePage))) - .pushReplacementNamed('/onboarding/3')); + unawaited( + Navigator.of( + tester.element(find.byType(WelcomePage)), + ).pushReplacementNamed('/onboarding/3'), + ); await tester.pumpAndSettle(); expect(find.byType(SetUpdatePrefsPage), findsOneWidget); -*/ // Test that routes are handled expect( - observer.replacedRoutes, - orderedEquals([ - '/onboarding/1', - '/onboarding/2', - '/onboarding/1', -// '/onboarding/3' - ])); + observer.replacedRoutes, + orderedEquals([ + '/onboarding/1', + '/onboarding/2', + '/onboarding/1', + '/onboarding/3', + ]), + ); }); testWidgets('Test normal startup', (WidgetTester tester) async { @@ -103,32 +115,45 @@ void main() { final prefs = await SharedPreferences.getInstance(); final TestObserver observer = TestObserver(); - await tester.pumpWidget(ProviderScope(overrides: [ - languageProvider - .overrideWith2((langCode) => TestLanguageController(initReturns: true)), - sharedPrefsProvider.overrideWithValue(prefs) - ], child: TestApp(observer))); + await tester.pumpWidget( + ProviderScope( + overrides: [ + languageProvider.overrideWith2( + (langCode) => TestLanguageController(initReturns: true), + ), + sharedPrefsProvider.overrideWithValue(prefs), + ], + child: TestApp(observer), + ), + ); // Test initial route / expect(find.byType(TestApp), findsOneWidget); expect(find.byType(StartupPage), findsOneWidget); // Test home page - unawaited(Navigator.of(tester.element(find.byType(StartupPage))) - .pushNamed('/home')); + unawaited( + Navigator.of(tester.element(find.byType(StartupPage))).pushNamed('/home'), + ); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); // Test settings page - unawaited(Navigator.of(tester.element(find.byType(HomePage))) - .pushNamed('/settings')); + unawaited( + Navigator.of( + tester.element(find.byType(HomePage)), + ).pushNamed('/settings'), + ); await tester.pumpAndSettle(); expect(find.byType(SettingsPage), findsOneWidget); // Test viewing the forgiveness page in English const String viewRoute = '/view/Forgiving_Step_by_Step/en'; - unawaited(Navigator.of(tester.element(find.byType(SettingsPage))) - .pushNamed(viewRoute)); + unawaited( + Navigator.of( + tester.element(find.byType(SettingsPage)), + ).pushNamed(viewRoute), + ); await tester.pumpAndSettle(); expect(find.byType(ViewPage), findsOneWidget); ViewPage viewPage = @@ -138,7 +163,9 @@ void main() { // Test that routes are handled expect( - observer.routes, orderedEquals(['/', '/home', '/settings', viewRoute])); + observer.routes, + orderedEquals(['/', '/home', '/settings', viewRoute]), + ); }); testWidgets('Test some edge cases', (WidgetTester tester) async { @@ -146,26 +173,37 @@ void main() { final prefs = await SharedPreferences.getInstance(); final fileSystem = MemoryFileSystem(); - await tester.pumpWidget(ProviderScope(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ], child: TestApp(TestObserver()))); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + child: TestApp(TestObserver()), + ), + ); // All of the following errorneous routes should result in showing /home - unawaited(Navigator.of(tester.element(find.byType(StartupPage))) - .pushNamed('/view')); + unawaited( + Navigator.of(tester.element(find.byType(StartupPage))).pushNamed('/view'), + ); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); - unawaited(Navigator.of(tester.element(find.byType(HomePage))) - .pushNamed('/view//')); + unawaited( + Navigator.of(tester.element(find.byType(HomePage))).pushNamed('/view//'), + ); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); - unawaited(Navigator.of(tester.element(find.byType(HomePage))) - .pushNamed('/view/Forgiving_Step_by_Step/')); + unawaited( + Navigator.of( + tester.element(find.byType(HomePage)), + ).pushNamed('/view/Forgiving_Step_by_Step/'), + ); await tester.pumpAndSettle(); expect(find.byType(HomePage), findsOneWidget); }); @@ -174,15 +212,24 @@ void main() { SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); final prefs = await SharedPreferences.getInstance(); final fileSystem = MemoryFileSystem(); - await tester.pumpWidget(ProviderScope(overrides: [ - sharedPrefsProvider.overrideWithValue(prefs), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider - .overrideWithValue(FakeLanguageDownloader(fileSystem: fileSystem)), - ], child: TestApp(TestObserver()))); - - unawaited(Navigator.of(tester.element(find.byType(StartupPage))) - .pushNamed('/unknown')); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue( + FakeLanguageDownloader(fileSystem: fileSystem), + ), + ], + child: TestApp(TestObserver()), + ), + ); + + unawaited( + Navigator.of( + tester.element(find.byType(StartupPage)), + ).pushNamed('/unknown'), + ); await tester.pumpAndSettle(); expect(find.byType(ErrorPage), findsOneWidget); expect(find.textContaining('Unknown route /unknown'), findsOneWidget); diff --git a/test/set_update_prefs_page_test.dart b/test/set_update_prefs_page_test.dart index ae9cad0..23b8590 100644 --- a/test/set_update_prefs_page_test.dart +++ b/test/set_update_prefs_page_test.dart @@ -25,12 +25,13 @@ class TestSetUpdatePrefsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - onGenerateRoute: (settings) => generateRoutes(settings), - navigatorObservers: [navigatorObserver], - home: const SetUpdatePrefsPage()); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + onGenerateRoute: (settings) => generateRoutes(settings), + navigatorObservers: [navigatorObserver], + home: const SetUpdatePrefsPage(), + ); } } @@ -40,13 +41,21 @@ void main() { final prefs = await SharedPreferences.getInstance(); final testObserver = TestObserver(); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('en')), - backgroundSchedulerProvider.overrideWith(() => TestBackgroundScheduler()), - sharedPrefsProvider.overrideWithValue(prefs) - ]); - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: TestSetUpdatePrefsPage(testObserver))); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + backgroundSchedulerProvider.overrideWith( + () => TestBackgroundScheduler(), + ), + sharedPrefsProvider.overrideWithValue(prefs), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: TestSetUpdatePrefsPage(testObserver), + ), + ); expect(ref.read(backgroundSchedulerProvider), false); expect(find.text(AppLocalizationsEn().updatesExplanation), findsOneWidget); @@ -57,8 +66,9 @@ void main() { // Press the "Let's go!" button expect(testObserver.replacedRoutes, isEmpty); - await tester - .tap(find.widgetWithText(ElevatedButton, AppLocalizationsEn().letsGo)); + await tester.tap( + find.widgetWithText(ElevatedButton, AppLocalizationsEn().letsGo), + ); await tester.pump(); expect(listEquals(testObserver.replacedRoutes, ['/home']), isTrue); // Even if user didn't change any setting, after clicking "Continue" @@ -68,16 +78,22 @@ void main() { expect(ref.read(backgroundSchedulerProvider), true); }); - testWidgets('Test SetUpdatePrefsPage back button in German', - (WidgetTester tester) async { + testWidgets('Test SetUpdatePrefsPage back button in German', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final testObserver = TestObserver(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - sharedPrefsProvider.overrideWithValue(prefs) - ], child: TestSetUpdatePrefsPage(testObserver))); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + sharedPrefsProvider.overrideWithValue(prefs), + ], + child: TestSetUpdatePrefsPage(testObserver), + ), + ); // Check that the page is in German expect(find.text(AppLocalizationsDe().updatesExplanation), findsOneWidget); @@ -85,8 +101,9 @@ void main() { // Click the back button expect(testObserver.replacedRoutes, isEmpty); - await tester - .tap(find.widgetWithText(ElevatedButton, AppLocalizationsDe().back)); + await tester.tap( + find.widgetWithText(ElevatedButton, AppLocalizationsDe().back), + ); await tester.pump(); expect(listEquals(testObserver.replacedRoutes, ['/onboarding/2']), isTrue); }); diff --git a/test/startup_page_test.dart b/test/startup_page_test.dart index e0a8be0..d38b713 100644 --- a/test/startup_page_test.dart +++ b/test/startup_page_test.dart @@ -18,9 +18,12 @@ import 'languages_test.dart'; /// Records which languages a full init() was requested for and lets the /// test decide when each of them finishes loading class GatedLanguageController extends TestLanguageController { - GatedLanguageController(this.initCalls, this.gates, - {super.downloadedLanguages, this.lazyInitGate}) - : super(initReturns: true); + GatedLanguageController( + this.initCalls, + this.gates, { + super.downloadedLanguages, + this.lazyInitGate, + }) : super(initReturns: true); final List initCalls; final Map> gates; @@ -128,27 +131,36 @@ void main() { expect(ref.read(backgroundSchedulerProvider), false); }); - /* TODO for version 0.9 - testWidgets('Test continuing to third onboarding step', - (WidgetTester tester) async { + testWidgets('Test continuing to third onboarding step', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); final prefs = await SharedPreferences.getInstance(); route = null; - final ref = ProviderContainer(overrides: [ - languageProvider.overrideWith2( - (languageCode) => TestLanguageController(initReturns: true), - ), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); - await tester.pumpWidget(UncontrolledProviderScope( + final ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2( + (languageCode) => TestLanguageController(initReturns: true), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( container: ref, child: MaterialApp( - home: const StartupPage(), onGenerateRoute: generateRoutes))); + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const StartupPage(), + onGenerateRoute: generateRoutes, + ), + ), + ); await tester.pump(); expect(route, equals('/onboarding/3')); expect(ref.read(backgroundSchedulerProvider), false); }); -*/ testWidgets('Only the languages of the first screen delay the navigation', ( WidgetTester tester, @@ -164,7 +176,7 @@ void main() { final initCalls = []; final gates = { for (final languageCode in ['en', 'de', 'fr']) - languageCode: Completer() + languageCode: Completer(), }; final ref = ProviderContainer( overrides: [ @@ -229,8 +241,7 @@ void main() { final initCalls = []; final lazyInitGate = Completer(); final gates = { - for (final languageCode in ['en', 'de']) - languageCode: Completer() + for (final languageCode in ['en', 'de']) languageCode: Completer(), }; final ref = ProviderContainer( overrides: [ @@ -297,8 +308,7 @@ void main() { route = null; final l10n = AppLocalizationsEn(); final gates = { - for (final languageCode in ['en', 'de']) - languageCode: Completer() + for (final languageCode in ['en', 'de']) languageCode: Completer(), }; final ref = ProviderContainer( overrides: [ @@ -343,53 +353,55 @@ void main() { 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; - } + 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, + 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); + ); + 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); + 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); - }); + gate.complete('/home'); + await tester.pump(); + expect(route, equals('/home')); + expect(initCalls, 1); + }, + ); testWidgets('Test failing initFunction', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); diff --git a/test/update_language_button_test.dart b/test/update_language_button_test.dart index 1d6c4db..247402d 100644 --- a/test/update_language_button_test.dart +++ b/test/update_language_button_test.dart @@ -23,11 +23,12 @@ class TestUpdateLanguageButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: Scaffold(body: UpdateLanguageButton(languageCode))); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: Scaffold(body: UpdateLanguageButton(languageCode)), + ); } } @@ -37,11 +38,12 @@ class TestUpdateAllLanguagesButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.read(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), - home: const Scaffold(body: UpdateAllLanguagesButton())); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + scaffoldMessengerKey: ref.read(scaffoldMessengerKeyProvider), + home: const Scaffold(body: UpdateAllLanguagesButton()), + ); } } @@ -49,15 +51,21 @@ void main() { testWidgets('Test UpdateLanguageButton', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - languageProvider.overrideWith2((langCode) => TestLanguageController()), - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestUpdateLanguageButton('en'))); + final ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2((langCode) => TestLanguageController()), + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestUpdateLanguageButton('en'), + ), + ); expect(find.byIcon(Icons.refresh), findsOneWidget); expect(ref.read(languageProvider('en')).downloaded, true); @@ -78,8 +86,10 @@ void main() { expect(secondTimestamp.compareTo(firstTimestamp), greaterThan(0)); expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); // Snackbar visible? - expect(find.text('Englisch (en) ist nun auf dem aktuellsten Stand'), - findsOneWidget); + expect( + find.text('Englisch (en) ist nun auf dem aktuellsten Stand'), + findsOneWidget, + ); }); testWidgets('Test when download fails', (WidgetTester tester) async { @@ -87,17 +97,25 @@ void main() { final prefs = await SharedPreferences.getInstance(); final fileSystem = MemoryFileSystem(); final fakeDownloader = FakeLanguageDownloader( - fileSystem: fileSystem, throwOnDownload: true); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - fileSystemProvider.overrideWith((ref) => fileSystem), - languageDownloaderProvider.overrideWithValue(fakeDownloader), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) - ]); - - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestUpdateLanguageButton('en'))); + fileSystem: fileSystem, + throwOnDownload: true, + ); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + fileSystemProvider.overrideWith((ref) => fileSystem), + languageDownloaderProvider.overrideWithValue(fakeDownloader), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + ], + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestUpdateLanguageButton('en'), + ), + ); await ref.read(languageStatusProvider('en').notifier).check(); expect(ref.read(languageStatusProvider('en')).updatesAvailable, true); @@ -107,34 +125,44 @@ void main() { expect(fakeDownloader.downloadCalls, 1); // Snackbar visible? expect( - find.textContaining('Aktualisierung fehlgeschlagen.'), findsOneWidget); + find.textContaining('Aktualisierung fehlgeschlagen.'), + findsOneWidget, + ); expect(ref.read(languageProvider('en')).downloaded, false); // A failed download must not clear updates flag: existing data is intact // (atomic staging-and-swap inside LanguageDownloader) expect(ref.read(languageStatusProvider('en')).updatesAvailable, true); }); - testWidgets('Test UpdateAllLanguagesButton: Updating 3 languages', - (WidgetTester tester) async { + testWidgets('Test UpdateAllLanguagesButton: Updating 3 languages', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((langCode) => TestLanguageController()), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) - ]); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + ], + ); expect(ref.read(updatesAvailableProvider), false); // Simulate that there are updates for these three languages available for (String languageCode in ['de', 'en', 'fr']) { expect( - await ref.read(languageStatusProvider(languageCode).notifier).check(), - 2); + await ref.read(languageStatusProvider(languageCode).notifier).check(), + 2, + ); } expect(ref.read(updatesAvailableProvider), true); - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestUpdateAllLanguagesButton())); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestUpdateAllLanguagesButton(), + ), + ); expect(find.byIcon(Icons.refresh), findsOneWidget); await tester.tap(find.byType(UpdateAllLanguagesButton)); @@ -142,14 +170,17 @@ void main() { expect(ref.read(updatesAvailableProvider), false); expect(find.byIcon(Icons.refresh), findsNothing); - expect(ref.read(languageProvider('ar')).downloadTimestamp, - equals(DateTime.utc(2023))); expect( - ref - .read(languageProvider('de')) - .downloadTimestamp - .compareTo(DateTime.utc(2023)), - greaterThan(0)); + ref.read(languageProvider('ar')).downloadTimestamp, + equals(DateTime.utc(2023)), + ); + expect( + ref + .read(languageProvider('de')) + .downloadTimestamp + .compareTo(DateTime.utc(2023)), + greaterThan(0), + ); expect(ref.read(languageStatusProvider('fr')).updatesAvailable, false); await tester.pumpAndSettle(); @@ -157,36 +188,48 @@ void main() { expect(find.text('3 Sprachen aktualisiert'), findsOneWidget); }); - testWidgets('Test UpdateAllLanguagesButton: Updating one language', - (WidgetTester tester) async { + testWidgets('Test UpdateAllLanguagesButton: Updating one language', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageProvider.overrideWith2((langCode) => TestLanguageController()), - sharedPrefsProvider.overrideWith((ref) => prefs), - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()) - ]); - expect(ref.read(languageProvider('de')).downloadTimestamp, - equals(DateTime.utc(2023))); + final ref = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + ], + ); + expect( + ref.read(languageProvider('de')).downloadTimestamp, + equals(DateTime.utc(2023)), + ); await ref.read(languageStatusProvider('de').notifier).check(); - await tester.pumpWidget(UncontrolledProviderScope( - container: ref, child: const TestUpdateAllLanguagesButton())); + await tester.pumpWidget( + UncontrolledProviderScope( + container: ref, + child: const TestUpdateAllLanguagesButton(), + ), + ); expect(find.byIcon(Icons.refresh), findsOneWidget); await tester.tap(find.byType(UpdateAllLanguagesButton)); await tester.pump(); expect( - ref - .read(languageProvider('de')) - .downloadTimestamp - .compareTo(DateTime.utc(2023)), - greaterThan(0)); + ref + .read(languageProvider('de')) + .downloadTimestamp + .compareTo(DateTime.utc(2023)), + greaterThan(0), + ); // Snackbar visible? - expect(find.text('Deutsch (de) ist nun auf dem aktuellsten Stand'), - findsOneWidget); + expect( + find.text('Deutsch (de) ist nun auf dem aktuellsten Stand'), + findsOneWidget, + ); }); // TODO Test correct handling / snackbar message when updates fail diff --git a/test/updates_need_confirmation_test.dart b/test/updates_need_confirmation_test.dart new file mode 100644 index 0000000..e3340c3 --- /dev/null +++ b/test/updates_need_confirmation_test.dart @@ -0,0 +1,54 @@ +import 'package:app4training/data/globals.dart'; +import 'package:app4training/data/updates.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'updates_test.dart'; + +/// Build a container where 'de' has updates available (or not) and the +/// AutomaticUpdates setting is [automaticUpdates]. +Future makeRef({ + required String automaticUpdates, + bool hasUpdates = true, +}) async { + SharedPreferences.setMockInitialValues({ + 'automaticUpdates': automaticUpdates, + }); + final prefs = await SharedPreferences.getInstance(); + return ProviderContainer( + overrides: [ + sharedPrefsProvider.overrideWithValue(prefs), + languageStatusProvider.overrideWith2( + (langCode) => + TestLanguageStatus(langWithUpdates: hasUpdates ? ['de'] : []), + ), + ], + ); +} + +void main() { + test('True only for requireConfirmation with updates available', () async { + final ref = await makeRef(automaticUpdates: 'requireConfirmation'); + expect(ref.read(updatesNeedConfirmationProvider), true); + }); + + test('False for requireConfirmation when no updates available', () async { + final ref = await makeRef( + automaticUpdates: 'requireConfirmation', + hasUpdates: false, + ); + expect(ref.read(updatesNeedConfirmationProvider), false); + }); + + test('False for other AutomaticUpdates modes even with updates', () async { + for (final mode in ['never', 'onlyOnWifi', 'yesAlways']) { + final ref = await makeRef(automaticUpdates: mode); + expect( + ref.read(updatesNeedConfirmationProvider), + false, + reason: 'should be false for $mode', + ); + } + }); +} diff --git a/test/updates_test.dart b/test/updates_test.dart index 4def356..1ce77bb 100644 --- a/test/updates_test.dart +++ b/test/updates_test.dart @@ -31,24 +31,31 @@ int countCheckCalled = 0; class TestLanguageStatus extends LanguageStatusNotifier { int _checkReturnValue; final List _langWithUpdates; - TestLanguageStatus( - {int checkReturnValue = 0, List langWithUpdates = const []}) - : _checkReturnValue = checkReturnValue, - _langWithUpdates = langWithUpdates; + TestLanguageStatus({ + int checkReturnValue = 0, + List langWithUpdates = const [], + }) : _checkReturnValue = checkReturnValue, + _langWithUpdates = langWithUpdates; @override LanguageStatus build() { final arg = ref.$arg as String; return LanguageStatus( - _langWithUpdates.contains(arg), DateTime.utc(2023), DateTime.utc(2023)); + _langWithUpdates.contains(arg), + DateTime.utc(2023), + DateTime.utc(2023), + ); } @override Future check() async { countCheckCalled++; if (_checkReturnValue >= 0) { - state = LanguageStatus(_checkReturnValue > 0, state.downloadTimestamp, - DateTime.now().toUtc()); + state = LanguageStatus( + _checkReturnValue > 0, + state.downloadTimestamp, + DateTime.now().toUtc(), + ); } return _checkReturnValue; } @@ -95,8 +102,10 @@ Client mockCheckResponse(Map languageUpdateMap) { // Get our language code from the URL that looks something like this: // https://api.github.com/repos/4training/html-de/commits?since=... final String languageCode = request.url.pathSegments - .firstWhere((element) => element.startsWith('html-'), - orElse: () => 'html-xyz') + .firstWhere( + (element) => element.startsWith('html-'), + orElse: () => 'html-xyz', + ) .substring(5); int countUpdates = languageUpdateMap[languageCode] ?? 0; return fakeResponseNUpdates(countUpdates); @@ -114,30 +123,40 @@ void main() { }); testWidgets('Test getting localized messages', (WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope( + await tester.pumpWidget( + const ProviderScope( child: MaterialApp( - locale: Locale('de'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold()))); + locale: Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(), + ), + ), + ); BuildContext context = tester.element(find.byType(Scaffold)); expect( - CheckFrequency.getLocalized(context, CheckFrequency.never), "niemals"); + CheckFrequency.getLocalized(context, CheckFrequency.never), + "niemals", + ); }); test('Test TestLanguageStatusNotifier class', () async { var testLanguageStatus = TestLanguageStatus(); - var ref = ProviderContainer(overrides: [ - languageStatusProvider.overrideWith2((langCode) => testLanguageStatus) - ]); + var ref = ProviderContainer( + overrides: [ + languageStatusProvider.overrideWith2((langCode) => testLanguageStatus), + ], + ); var deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.updatesAvailable, false); expect(deStatus.lastCheckedTimestamp, equals(deStatus.downloadTimestamp)); expect(await ref.read(languageStatusProvider('de').notifier).check(), 0); deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.updatesAvailable, false); - expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0)); + expect( + deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0), + ); testLanguageStatus.setCheckReturnValue(2); await Future.delayed(const Duration(milliseconds: 1)); @@ -147,8 +166,9 @@ void main() { expect(deStatus2.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be even newer expect( - deStatus2.lastCheckedTimestamp.compareTo(deStatus.lastCheckedTimestamp), - greaterThan(0)); + deStatus2.lastCheckedTimestamp.compareTo(deStatus.lastCheckedTimestamp), + greaterThan(0), + ); testLanguageStatus.setCheckReturnValue(-1); await Future.delayed(const Duration(milliseconds: 1)); @@ -158,7 +178,9 @@ void main() { expect(deStatus3.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be the same expect( - deStatus3.lastCheckedTimestamp, equals(deStatus2.lastCheckedTimestamp)); + deStatus3.lastCheckedTimestamp, + equals(deStatus2.lastCheckedTimestamp), + ); testLanguageStatus.setCheckReturnValue(0); await Future.delayed(const Duration(milliseconds: 1)); @@ -168,30 +190,40 @@ void main() { expect(deStatus4.downloadTimestamp, equals(deStatus.downloadTimestamp)); // last checked timestamp should be newer expect( - deStatus4.lastCheckedTimestamp - .compareTo(deStatus3.lastCheckedTimestamp), - greaterThan(0)); - }); - test('Test TestLanguageStatusNotifier constructor parameter checkReturnValue', - () async { - final ref = ProviderContainer(overrides: [ - languageStatusProvider - .overrideWith2((langCode) => TestLanguageStatus(checkReturnValue: 2)) - ]); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, false); - expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); - expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); - }); - test('Test TestLanguageStatusNotifier constructor parameter langsWithUpdates', - () async { - var ref = ProviderContainer(overrides: [ - languageStatusProvider - .overrideWith2((langCode) => TestLanguageStatus(langWithUpdates: ['de'])) - ]); - expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); - expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); + deStatus4.lastCheckedTimestamp.compareTo(deStatus3.lastCheckedTimestamp), + greaterThan(0), + ); }); + test( + 'Test TestLanguageStatusNotifier constructor parameter checkReturnValue', + () async { + final ref = ProviderContainer( + overrides: [ + languageStatusProvider.overrideWith2( + (langCode) => TestLanguageStatus(checkReturnValue: 2), + ), + ], + ); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, false); + expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); + expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); + }, + ); + test( + 'Test TestLanguageStatusNotifier constructor parameter langsWithUpdates', + () async { + var ref = ProviderContainer( + overrides: [ + languageStatusProvider.overrideWith2( + (langCode) => TestLanguageStatus(langWithUpdates: ['de']), + ), + ], + ); + expect(ref.read(languageStatusProvider('de')).updatesAvailable, true); + expect(ref.read(languageStatusProvider('en')).updatesAvailable, false); + }, + ); group('Test reading LanguageStatus from SharedPreferences', () { late ProviderContainer ref; @@ -200,11 +232,14 @@ void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); - ref = ProviderContainer(overrides: [ - languageProvider.overrideWith2( - (langCode) => TestLanguageController(downloadedLanguages: ['de'])), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + ref = ProviderContainer( + overrides: [ + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de']), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); }); test('Nothing saved in SharedPreferences', () async { @@ -267,16 +302,23 @@ void main() { test('Test checking for updates: no updates', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => MockClient((request) async { - expect(request.url.toString(), - equals(Globals.getCommitsSince('de', DateTime.utc(2023)))); + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith( + (ref) => MockClient((request) async { + expect( + request.url.toString(), + equals(Globals.getCommitsSince('de', DateTime.utc(2023))), + ); return Response(json.encode([]), 200); - })), - languageProvider.overrideWith2( - (langCode) => TestLanguageController(downloadedLanguages: ['de'])), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + }), + ), + languageProvider.overrideWith2( + (langCode) => TestLanguageController(downloadedLanguages: ['de']), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); expect(deStatus.lastCheckedTimestamp, equals(DateTime.utc(2023))); @@ -287,28 +329,38 @@ void main() { // The lastCheckedTimestamp should be updated to the current time now() deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0)); expect( - ref.read(lastCheckedProvider), equals(deStatus.lastCheckedTimestamp)); + deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0), + ); + expect( + ref.read(lastCheckedProvider), + equals(deStatus.lastCheckedTimestamp), + ); expect(deStatus.updatesAvailable, false); expect(prefs.getBool('updatesAvailable-de'), false); - expect(prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String())); + expect( + prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String()), + ); }); test('Test checking for updates: 2 updates', () async { SharedPreferences.setMockInitialValues({ 'lastChecked-de': '2023-02-02 00:00:00.000Z', - 'updatesAvailable-de': false + 'updatesAvailable-de': false, }); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2( - (langCode) => TestLanguageController(downloadedLanguages: ['de', 'en'])), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2( + (langCode) => + TestLanguageController(downloadedLanguages: ['de', 'en']), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); final deStatusNotifier = ref.read(languageStatusProvider('de').notifier); expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); expect(await deStatusNotifier.check(), 2); @@ -316,23 +368,31 @@ void main() { // lastCheckedTimestamp should be updated to the current time now() LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0)); + expect( + deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0), + ); expect(deStatus.updatesAvailable, true); expect(prefs.getBool('updatesAvailable-de'), true); - expect(prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String())); + expect( + prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String()), + ); // If we check for updates a second time, we should get the same results expect(await deStatusNotifier.check(), 2); deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0)); + expect( + deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0), + ); expect(deStatus.updatesAvailable, true); expect(prefs.getBool('updatesAvailable-de'), true); - expect(prefs.getString('lastChecked-de'), - equals(deStatus.lastCheckedTimestamp.toIso8601String())); + expect( + prefs.getString('lastChecked-de'), + equals(deStatus.lastCheckedTimestamp.toIso8601String()), + ); // As we didn't check for updates for English: expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); @@ -341,20 +401,26 @@ void main() { test('Test checking and updating', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2( - (langCode) => TestLanguageController(downloadedLanguages: ['de', 'en'])), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2( + (langCode) => + TestLanguageController(downloadedLanguages: ['de', 'en']), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); expect(await ref.read(languageStatusProvider('de').notifier).check(), 2); // The lastCheckedTimestamp should be updated to the current time now() LanguageStatus deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); - expect(deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), - greaterThan(0)); + expect( + deStatus.lastCheckedTimestamp.compareTo(deStatus.downloadTimestamp), + greaterThan(0), + ); expect(deStatus.updatesAvailable, true); expect(ref.read(sharedPrefsProvider).getBool('updatesAvailable-de'), true); expect(ref.read(lastCheckedProvider), equals(DateTime.utc(2023))); @@ -367,7 +433,9 @@ void main() { final englishTimestamp = ref.read(languageStatusProvider('en')).lastCheckedTimestamp; expect( - ref.read(lastCheckedProvider), equals(deStatus.lastCheckedTimestamp)); + ref.read(lastCheckedProvider), + equals(deStatus.lastCheckedTimestamp), + ); // After downloading the resources there shouldn't be updates available await Future.delayed(const Duration(milliseconds: 1)); @@ -380,20 +448,25 @@ void main() { // lastCheckedProvider should have advanced a little bit again expect(ref.read(lastCheckedProvider), equals(englishTimestamp)); expect( - ref.read(lastCheckedProvider).compareTo(deStatus.lastCheckedTimestamp), - lessThan(0)); + ref.read(lastCheckedProvider).compareTo(deStatus.lastCheckedTimestamp), + lessThan(0), + ); }); test('Test correct behavior when checking for updates fails', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => MockClient((request) async { + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith( + (ref) => MockClient((request) async { throw ClientException; - })), - languageProvider.overrideWith2((langCode) => TestLanguageController()), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + }), + ), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); expect(await ref.read(languageStatusProvider('de').notifier).check(), -1); @@ -408,22 +481,29 @@ void main() { test('Test error handling when API query limit is exceeded', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => MockClient((request) async { + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith( + (ref) => MockClient((request) async { return Response( - json.encode({ - "message": - "API rate limit exceeded for 1.1.1.1. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", - "documentation_url": - "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" - }), - 403); - })), - languageProvider.overrideWith2((langCode) => TestLanguageController()), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); - expect(await ref.read(languageStatusProvider('de').notifier).check(), - apiRateLimitExceeded); + json.encode({ + "message": + "API rate limit exceeded for 1.1.1.1. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", + "documentation_url": + "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting", + }), + 403, + ); + }), + ), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + expect( + await ref.read(languageStatusProvider('de').notifier).check(), + apiRateLimitExceeded, + ); final deStatus = ref.read(languageStatusProvider('de')); expect(deStatus.downloadTimestamp, equals(DateTime.utc(2023))); @@ -435,11 +515,13 @@ void main() { test('Test updatesAvailableProvider', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - final ref = ProviderContainer(overrides: [ - httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), - languageProvider.overrideWith2((langCode) => TestLanguageController()), - sharedPrefsProvider.overrideWith((ref) => prefs) - ]); + final ref = ProviderContainer( + overrides: [ + httpClientProvider.overrideWith((ref) => mockReturnTwoUpdates()), + languageProvider.overrideWith2((langCode) => TestLanguageController()), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); // No updates available LanguageStatus deStatus = ref.read(languageStatusProvider('de')); @@ -456,9 +538,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 1)); // Updating the German resources - expect( - await ref.read(languageProvider('de').notifier).download(), - true); + expect(await ref.read(languageProvider('de').notifier).download(), true); // Now there should again be no updates available deStatus = ref.read(languageStatusProvider('de')); diff --git a/test/view_page_test.dart b/test/view_page_test.dart index c4a7a48..202b0b9 100644 --- a/test/view_page_test.dart +++ b/test/view_page_test.dart @@ -22,10 +22,11 @@ class TestViewPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.read(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const ViewPage('Healing', 'de')); + locale: ref.read(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const ViewPage('Healing', 'de'), + ); } } @@ -33,11 +34,16 @@ void main() { testWidgets('Test normal behaviour', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - pageContentProvider.overrideWith((ref, page) async => 'TestContent'), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: const TestViewPage())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith((ref, page) async => 'TestContent'), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: const TestViewPage(), + ), + ); expect(prefs.getString('recentPage'), isNull); expect(prefs.getString('recentLang'), isNull); @@ -58,81 +64,119 @@ void main() { expect(find.byType(ShareButton), findsOneWidget); }); - testWidgets('Test LanguageNotDownloadedException handling', - (WidgetTester tester) async { + testWidgets('Test LanguageNotDownloadedException handling', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - pageContentProvider.overrideWith( - (ref, arg) async => throw LanguageNotDownloadedException('de')), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: const TestViewPage())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith( + (ref, arg) async => throw LanguageNotDownloadedException('de'), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: const TestViewPage(), + ), + ); await tester.pumpAndSettle(); // Now we should see a warning (in German) expect(find.byIcon(Icons.warning_amber), findsOneWidget); expect(find.text('Warnung'), findsOneWidget); expect( - find.textContaining( - 'Kann Seite "Healing" nicht auf Deutsch (de) anzeigen'), - findsOneWidget); + find.textContaining( + 'Kann Seite "Healing" nicht auf Deutsch (de) anzeigen', + ), + findsOneWidget, + ); expect(find.textContaining('Sprache ist nicht verfügbar'), findsOneWidget); }); - testWidgets('Test PageNotFoundException handling', - (WidgetTester tester) async { + testWidgets('Test PageNotFoundException handling', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - pageContentProvider.overrideWith( - (ref, page) async => throw PageNotFoundException('Healing', 'de')), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: const TestViewPage())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith( + (ref, page) async => throw PageNotFoundException('Healing', 'de'), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: const TestViewPage(), + ), + ); await tester.pumpAndSettle(); // Now we should see a warning (in German) expect(find.byIcon(Icons.warning_amber), findsOneWidget); expect(find.text('Warnung'), findsOneWidget); expect( - find.textContaining( - 'Kann Seite "Healing" nicht auf Deutsch (de) anzeigen'), - findsOneWidget); - expect(find.textContaining('Seite Healing/de konnte nicht gefunden werden'), - findsOneWidget); + find.textContaining( + 'Kann Seite "Healing" nicht auf Deutsch (de) anzeigen', + ), + findsOneWidget, + ); + expect( + find.textContaining('Seite Healing/de konnte nicht gefunden werden'), + findsOneWidget, + ); }); - testWidgets('Test LanguageCorruptedException handling', - (WidgetTester tester) async { + testWidgets('Test LanguageCorruptedException handling', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('en')), - pageContentProvider.overrideWith((ref, page) async => - throw LanguageCorruptedException('de', 'BadLuck')), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: const TestViewPage())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + pageContentProvider.overrideWith( + (ref, page) async => + throw LanguageCorruptedException('de', 'BadLuck'), + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: const TestViewPage(), + ), + ); await tester.pumpAndSettle(); // Now we should see an error message in German expect(find.byIcon(Icons.error), findsOneWidget); expect(find.text('Error'), findsOneWidget); expect( - find.textContaining( - "Language data for 'German (de)' seems to be corrupted"), - findsOneWidget); + find.textContaining( + "Language data for 'German (de)' seems to be corrupted", + ), + findsOneWidget, + ); }); - testWidgets('Test unexpected exception handling', - (WidgetTester tester) async { + testWidgets('Test unexpected exception handling', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - pageContentProvider.overrideWith((ref, arg) async => throw TestFailure), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: const TestViewPage())); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith( + (ref, arg) async => throw TestFailure, + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: const TestViewPage(), + ), + ); await tester.pumpAndSettle(); // Now we should see an error (internalError in German) diff --git a/test/welcome_page_test.dart b/test/welcome_page_test.dart index 8d15a8a..afd0dc3 100644 --- a/test/welcome_page_test.dart +++ b/test/welcome_page_test.dart @@ -22,12 +22,13 @@ class TestWelcomePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - onGenerateRoute: (settings) => generateRoutes(settings), - navigatorObservers: [navigatorObserver], - home: const WelcomePage()); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + onGenerateRoute: (settings) => generateRoutes(settings), + navigatorObservers: [navigatorObserver], + home: const WelcomePage(), + ); } } @@ -38,25 +39,36 @@ class TestSmallWelcomePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( - locale: ref.watch(appLanguageProvider).locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - appBar: AppBar(title: const Text(Globals.appTitle)), - body: const SizedBox(height: 400, child: WelcomeScreen()))); + locale: ref.watch(appLanguageProvider).locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + appBar: AppBar(title: const Text(Globals.appTitle)), + body: const SizedBox(height: 400, child: WelcomeScreen()), + ), + ); } } void main() { testWidgets('Test PromoBlock in German', (WidgetTester tester) async { - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('de')) - ], child: TestWelcomePage(TestObserver()))); - - expect(find.textContaining(countAvailableLanguages.toString()), - findsOneWidget); - expect(find.textContaining(worksheetCategories.length.toString()), - findsOneWidget); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + ], + child: TestWelcomePage(TestObserver()), + ), + ); + + expect( + find.textContaining(countAvailableLanguages.toString()), + findsOneWidget, + ); + expect( + find.textContaining(worksheetCategories.length.toString()), + findsOneWidget, + ); expect(find.textContaining('offline'), findsOneWidget); expect(find.textContaining('Kein Copyright'), findsOneWidget); }); @@ -66,10 +78,15 @@ void main() { final prefs = await SharedPreferences.getInstance(); final testObserver = TestObserver(); - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('en')), - sharedPrefsProvider.overrideWith((ref) => prefs) - ], child: TestWelcomePage(testObserver))); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: TestWelcomePage(testObserver), + ), + ); expect(find.text(AppLocalizationsEn().welcome), findsOneWidget); expect(find.text(AppLocalizationsEn().selectAppLanguage), findsOneWidget); @@ -83,30 +100,39 @@ void main() { expect(listEquals(testObserver.replacedRoutes, ['/onboarding/2']), isTrue); }); - testWidgets('appLanguage should get saved in SharedPrefs when user continues', - (WidgetTester tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - - await tester.pumpWidget(ProviderScope( - overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestWelcomePage(TestObserver()))); - - expect(prefs.getString('appLanguage'), null); - await tester.tap(find.byType(ElevatedButton)); - await tester.pump(); - // Even if user doesn't touch the appLanguage, after clicking "Continue" - // there should be an entry in the SharedPreferences - expect(prefs.getString('appLanguage'), 'system'); - }); - - testWidgets('Test that changing the app language to German works', - (WidgetTester tester) async { + testWidgets( + 'appLanguage should get saved in SharedPrefs when user continues', + (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPrefsProvider.overrideWithValue(prefs)], + child: TestWelcomePage(TestObserver()), + ), + ); + + expect(prefs.getString('appLanguage'), null); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + // Even if user doesn't touch the appLanguage, after clicking "Continue" + // there should be an entry in the SharedPreferences + expect(prefs.getString('appLanguage'), 'system'); + }, + ); + + testWidgets('Test that changing the app language to German works', ( + WidgetTester tester, + ) async { SharedPreferences.setMockInitialValues({'appLanguage': 'system'}); final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget(ProviderScope( + await tester.pumpWidget( + ProviderScope( overrides: [sharedPrefsProvider.overrideWithValue(prefs)], - child: TestWelcomePage(TestObserver()))); + child: TestWelcomePage(TestObserver()), + ), + ); // Select German and verify correct UI and saving in SharedPreferences expect(prefs.getString('appLanguage'), 'system'); @@ -119,15 +145,21 @@ void main() { }); testWidgets( - 'Test that view is scrollable and button reachable on very small device', - (WidgetTester tester) async { - await tester.pumpWidget(ProviderScope(overrides: [ - appLanguageProvider.overrideWith(() => TestAppLanguage('en')) - ], child: const TestSmallWelcomePage())); - - // the button should become visible after scrolling - expect(find.byType(ElevatedButton).hitTestable(), findsNothing); - await tester.ensureVisible(find.byType(ElevatedButton)); - expect(find.byType(ElevatedButton).hitTestable(), findsOneWidget); - }); + 'Test that view is scrollable and button reachable on very small device', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('en')), + ], + child: const TestSmallWelcomePage(), + ), + ); + + // the button should become visible after scrolling + expect(find.byType(ElevatedButton).hitTestable(), findsNothing); + await tester.ensureVisible(find.byType(ElevatedButton)); + expect(find.byType(ElevatedButton).hitTestable(), findsOneWidget); + }, + ); }