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/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..7569c61 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,103 @@ 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. +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. Runs the two-phase flow: **`backgroundCheck(ref)`** (phase 1) then **`backgroundDownload(ref)`** (phase 2). -### `backgroundCheck(ProviderContainer ref)` +### `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 +148,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 +156,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 +(`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 54eaf6e..215d339 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 6d80b31..a869edb 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. - "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 16e2114..83b4b6f 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`. @@ -37,7 +37,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 ref.read(backgroundSchedulerProvider.notifier).schedule() diff --git a/docs/state-management.md b/docs/state-management.md index bb0036c..0506ff6 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -88,7 +88,7 @@ This page is the index of every provider in the app — what it holds, what it d | 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/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 78f239c..4e5ad54 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -209,14 +209,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 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..4692f8f 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,65 @@ Future backgroundMain() async { overrides: [ sharedPrefsProvider.overrideWithValue(prefs), languageDownloaderProvider.overrideWithValue(languageDownloader), + connectivityServiceProvider.overrideWithValue(ConnectivityServiceImpl()), ], ); + + // 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); +} + +/// 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 +142,20 @@ 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 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/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/globals.dart b/lib/data/globals.dart index 519865b..0d2ad9e 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() { diff --git a/lib/data/updates.dart b/lib/data/updates.dart index 2420016..d0aae5c 100644 --- a/lib/data/updates.dart +++ b/lib/data/updates.dart @@ -247,6 +247,16 @@ final languageStatusProvider = 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? final updatesAvailableProvider = Provider((ref) { bool updatesAvailable = false; diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 7a1164d..16319eb 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 4b478f1..98b0913 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 c50e29d..940f6fb 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/locales/app_de.arb b/lib/l10n/locales/app_de.arb index 2dae244..488b833 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 e4fa8ad..3a355b3 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 18f2c23..5e2850c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:app4training/data/connectivity_service.dart'; import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:dio/dio.dart'; @@ -9,6 +10,8 @@ import 'package:app4training/routes/routes.dart'; import 'package:package_info_plus/package_info_plus.dart'; 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'; @@ -138,13 +141,16 @@ 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), + connectivityServiceProvider.overrideWithValue(ConnectivityServiceImpl()), ], child: const App4Training())); } diff --git a/lib/routes/onboarding/download_languages_page.dart b/lib/routes/onboarding/download_languages_page.dart index fe8f5d2..82962c4 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'; @@ -88,19 +89,13 @@ class DownloadLanguagesPage extends ConsumerWidget { } /// 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'; -*/ } } diff --git a/lib/routes/settings_page.dart b/lib/routes/settings_page.dart index 5ae4728..bb69b52 100644 --- a/lib/routes/settings_page.dart +++ b/lib/routes/settings_page.dart @@ -1,13 +1,14 @@ -//import 'package:app4training/widgets/dropdownbutton_automatic_updates.dart'; +import 'package:app4training/widgets/dropdownbutton_automatic_updates.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}); @@ -19,22 +20,26 @@ 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() - // 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(), + const SizedBox(height: 10), + // const DesignSettings() + ], + ), ), ), ); @@ -48,22 +53,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), + ], + ); } } @@ -78,51 +86,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 84f22a9..e623c19 100644 --- a/lib/routes/startup_page.dart +++ b/lib/routes/startup_page.dart @@ -37,11 +37,10 @@ class StartupPage extends ConsumerWidget { 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'; - }*/ + } // Start the periodic background task unawaited(ref.read(backgroundSchedulerProvider.notifier).schedule()); 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/languages_table.dart b/lib/widgets/languages_table.dart index be4dbba..c3bc100 100644 --- a/lib/widgets/languages_table.dart +++ b/lib/widgets/languages_table.dart @@ -65,64 +65,90 @@ 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()), + const SizedBox( + height: 32, width: 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 diskUsage = Text( + '${context.l10n.diskUsage}: $sizeInKB 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()), - const SizedBox( - height: 32, - width: 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), + diskUsage, + ], + ); + } + return SingleChildScrollView( + child: Column( + children: [ + header, + const SizedBox(height: 5), + languageList, + const SizedBox(height: 5), + diskUsage, ], ), - 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}: $sizeInKB kB $countLanguages', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ); + ); + }); } } diff --git a/lib/widgets/main_drawer.dart b/lib/widgets/main_drawer.dart index 535d6c1..7a5bc17 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'; @@ -90,6 +91,12 @@ class TableOfContent extends ConsumerWidget { 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(); diff --git a/pubspec.lock b/pubspec.lock index fa4dfa4..d87ac0c 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" dio: dependency: "direct main" description: @@ -524,6 +548,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: @@ -620,6 +652,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: @@ -1105,6 +1145,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 45dbdb6..c702d38 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 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/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/download_languages_page_test.dart b/test/download_languages_page_test.dart index c4e379e..c653deb 100644 --- a/test/download_languages_page_test.dart +++ b/test/download_languages_page_test.dart @@ -91,10 +91,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', ( @@ -105,7 +103,9 @@ void main() { ProviderScope( overrides: [ appLanguageProvider.overrideWith(() => TestAppLanguage('de')), - languageStatusProvider.overrideWith2((languageCode) => TestLanguageStatus()), + languageStatusProvider.overrideWith2( + (languageCode) => TestLanguageStatus(), + ), ], child: TestDownloadLanguagesPage(testObserver), ), diff --git a/test/main_drawer_test.dart b/test/main_drawer_test.dart index e62febd..3c041c9 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..d57ed24 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'; @@ -79,13 +80,11 @@ void main() { 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')); await tester.pumpAndSettle(); expect(find.byType(SetUpdatePrefsPage), findsOneWidget); -*/ // Test that routes are handled expect( @@ -94,7 +93,7 @@ void main() { '/onboarding/1', '/onboarding/2', '/onboarding/1', -// '/onboarding/3' + '/onboarding/3' ])); }); diff --git a/test/startup_page_test.dart b/test/startup_page_test.dart index 6fb2547..fedf3f3 100644 --- a/test/startup_page_test.dart +++ b/test/startup_page_test.dart @@ -93,7 +93,6 @@ void main() { expect(ref.read(backgroundSchedulerProvider), false); }); - /* TODO for version 0.9 testWidgets('Test continuing to third onboarding step', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); @@ -113,7 +112,6 @@ void main() { expect(route, equals('/onboarding/3')); expect(ref.read(backgroundSchedulerProvider), false); }); -*/ testWidgets('Test failing initFunction', (WidgetTester tester) async { SharedPreferences.setMockInitialValues({'appLanguage': 'de'}); 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', + ); + } + }); +}