diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50423d2a..2abce357 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,6 +148,333 @@ Do not add provider-name checks such as `if source == 'provider-name'` to the main app. The backend should parse and expose the generic declaration, and Dart should consume that declaration without knowing which extension uses it. +The sections below expand this layout. Folder guides hold the details that are +not repeated here: [android/README.md](android/README.md) (native channel +contract), [rust_backend/README.md](rust_backend/README.md) (crate map), +[test/README.md](test/README.md), +[integration_test/README.md](integration_test/README.md), +[lib/theme/README.md](lib/theme/README.md), and the `SPOTIFLAC_PATCHES.md` file +in each `third_party/` fork, for example +[audio_service](third_party/audio_service/SPOTIFLAC_PATCHES.md). + +## Codebase Map + +### Flutter (`lib/`) + +App files import each other as `package:spotiflac_android/...`. The intended +direction is screens and widgets on top, then controllers, providers, services, +and models and utilities at the bottom. The last column is the current state +found by scanning import lines, including today's exceptions; do not add new +ones. + +| Folder | What belongs there | Imports today | +| --- | --- | --- | +| `main.dart`, `app.dart` | Startup, `ProviderScope`, `SpotiFLACApp`, and the `GoRouter` (`/`, `/setup`, `/tutorial`) | Any folder | +| `screens/` | Routes and tab roots; large screens split into `_*.dart` files; settings pages in `screens/settings/` | Any folder | +| `widgets/` | Reusable widgets and design-system components | Any folder except `screens/`; exceptions: `library_search_results.dart`, `artist_concerts_button.dart`, `mini_player.dart` | +| `controllers/` | UI logic shared by screens and widgets: selection mixins, batch actions, the metadata editor | controllers, widgets, providers, services, models, utils, theme, l10n; `clickable_metadata.dart` also imports `screens/` | +| `providers/` | Riverpod notifiers and state; large notifiers split into `part` files | providers, services, models, utils, constants | +| `services/` | `PlatformBridge`, SQLite stores, the music player, FFmpeg, network storage, notifications | services, models, utils, constants; exceptions: re-export stubs into `providers/` and `controllers/`, and `notification_service.dart` imports `l10n/` | +| `models/` | Data classes and their `json_serializable` output (`*.g.dart`) | models, utils; `library_database_models.dart` imports `sqlite_helpers.dart` from `services/` | +| `utils/` | Small helpers, pure policies, and the settings search catalog | utils, models, constants, l10n; seven files import `services/` (for example `file_access.dart`, `logger.dart`), `saf_download_tree.dart` imports `providers/`, three re-export stubs point into `controllers/` | +| `theme/` | `AppTheme`, `AppTokens`, motion, icons, and the Mornye theme | theme, models; `dynamic_color_wrapper.dart` imports `providers/`, `cover_palette.dart` imports `widgets/` and `services/` | +| `l10n/` | ARB sources in `arb/`, the `context.l10n` extension in `l10n.dart`, and `supported_locales.dart`; `app_localizations*.dart` is generated | utils | +| `constants/` | App info, language choices, `MusicServices` IDs | l10n | +| `routes/` | `now_playing_route.dart` | services, theme | + +No provider or service imports `screens/` or `widgets/`. Among services, only +the navigation, orientation, music player, and player widget services import +Flutter's `material.dart` or `widgets.dart`; among providers, the preview +player, extension, download queue, download queue policy and theme providers +do. + +### Rust (`rust_backend/crates/`) + +Workspace dependencies point only downward: + +| Crate | Package | Role | Depends on | +| --- | --- | --- | --- | +| `core/` | `spotiflac-core` | Shared logic without bindings: tags, lyrics parsing and provider config, metadata, filenames, CUE, covers | none | +| `network/` | `spotiflac-network` | Shared, cancellable HTTP transport | none | +| `providers/` | `spotiflac-providers` | Built-in clients: Deezer, MusicBrainz, platform resolver, lyrics | core, network | +| `extensions/` | `spotiflac-extensions` | Extension JavaScript runtime (rquickjs), host functions, manager, repository, and backend operations such as downloads, library scan, and tags | core, network, providers | +| `mobile/` | `spotiflac-mobile` | UniFFI API, mostly methods on `ExtensionManager`; built as `cdylib` and `staticlib`; Kotlin package `com.spotiflac.backend`, Swift module `SpotiFLACBackend` | core, network, extensions | +| `bindgen/` | `spotiflac-bindgen` | UniFFI bindgen CLI used by `scripts/build_rust_backend.sh` | none | + +### Native + +Android Kotlin lives in `android/app/src/main/kotlin/com/zarz/spotiflac/`. +`android/app/build.gradle.kts` adds `android/app/src/rust/kotlin/com/zarz/spotiflac/` +and the generated bindings to the same source set. + +| Files | Role | +| --- | --- | +| `MainActivity.kt` | Backend channel `com.zarz.spotiflac/backend`, the `nativeBackendMethods` set, and the `when (call.method)` dispatch | +| `MainActivity*Calls.kt`, `MainActivitySaf*.kt`, `MainActivityProgressStreams.kt`, `MainActivitySmallChannels.kt` | `MainActivity.handleX(call, result)` extension functions grouped by area, progress EventChannels, small channels | +| `DownloadService*.kt`, `BackgroundWorkService.kt` | Foreground services: the native download worker and its snapshot, other background work | +| `NativeDownloadFinalizer.kt`, `NativeFinalizer*.kt`, `NativeHistoryStore.kt` | Post-download finalization and the native history write | +| `Saf*.kt` | Storage Access Framework helpers | +| `UsbBitPerfectPlugin.kt`, `Usb*.kt`, `BitPerfectPcm.kt`, `Dsd*.kt`, `HiResPlayback.kt`, `NativeAudio.kt` | Bit-perfect and USB playback (`com.zarz.spotiflac/usb_pcm`) | +| `PlayerWidget*.kt`, `DownloadQueueWidgetProvider.kt` | Home-screen widgets | +| `CoreBackend.kt`; `SelectedCoreBackend.kt`, `RustCoreBackendApplication.kt`, `NativeDataJobs.kt` | The `CoreBackend` interface and its Rust implementation `RustCoreBackend`, which maps method names to UniFFI calls | + +iOS code lives in `ios/Runner/`: `AppDelegate.swift` (backend channel, +`handleMethodCall` and its `osMethods` set, progress EventChannels, background +tasks), `CoreBackend.swift` (Rust calls through `SpotiFLACBackend`, +`invokeApplication`), and smaller bridges such as `PlayerWidgetBridge.swift` +and `ConcertCalendarBridge.swift`. The WidgetKit extension is in +`ios/PlayerWidget/`. + +## Where Each Feature Lives + +Dart paths are under `lib/`, Rust paths under `rust_backend/crates/`, and +Kotlin files in the Android folders above. + +| Feature | Dart | Rust | Native | +| --- | --- | --- | --- | +| Search and URL import | `providers/track_provider.dart` (`TrackNotifier`), `controllers/home_search_logic.dart` | `extensions/src/backend/provider_metadata.rs`, `mobile/src/metadata.rs` | `RustCoreBackendApplication.kt`, `CoreBackend.swift` | +| Download | `providers/download_queue_provider.dart` and its `download_queue_provider_*.dart` parts, `services/download_request_payload.dart` | `extensions/src/backend/downloads.rs`, `mobile/src/manager.rs` | `DownloadService.kt`, `SafDownloadHandler.kt`, `NativeDownloadFinalizer.kt` | +| Queue persistence and history | `services/download_queue_persistence.dart`, `providers/download_history_provider.dart`, `services/history_database.dart`, `services/history_schema.dart` | none | `NativeHistoryStore.kt`, `NativeFinalizerHistory.kt`, `DownloadServiceSnapshot.kt` | +| Local library scan | `providers/local_library_provider.dart` and its `local_library_provider_*.dart` parts, `services/library_database.dart` | `extensions/src/backend/library/scan.rs`, `mobile/src/tags.rs`, `mobile/src/native_library.rs` | `MainActivityLibraryScanCalls.kt`; SAF trees in `MainActivitySafScan.kt` and the `Saf*.kt` files | +| Playback and queue | `providers/playback_provider.dart`, `providers/music_player_provider.dart`, `services/music_player_service.dart` (automix and autoplay parts), `services/music_player_runtime.dart`, `services/music_playback_deck.dart` | AutoMix analysis: `core/src/native_audio_analysis.rs` via `mobile/src/data_jobs.rs` | `UsbBitPerfectPlugin.kt`; forks in `third_party/audio_service`, `third_party/audioplayers_android`, `third_party/audioplayers_darwin` | +| Lyrics | `utils/lyrics_parser.dart`, `utils/lyrics_metadata_helper.dart`, `controllers/player_metadata_controller.dart` | `providers/src/lyrics/`, `core/src/lyrics/`, `extensions/src/lyrics.rs`, `mobile/src/lyrics.rs`, `mobile/src/native_lyrics.rs` | `SelectedCoreBackend.kt`, `CoreBackend.swift` | +| Metadata editing and conversion | `controllers/metadata_editor_controller.dart`, `screens/track_metadata_edit_sheet.dart`, `screens/track_metadata_convert.dart`, `services/ffmpeg_service.dart` (FFmpeg Kit through `services/ffmpeg_support.dart`) | `core/src/tags/`, `extensions/src/backend/tags.rs`, `mobile/src/tags.rs` | `MainActivityMetadataCalls.kt` | +| Extensions and repository | `providers/extension_provider.dart`, `providers/repo_provider.dart`, `models/extension.dart`, `screens/settings/extensions_page.dart`, `screens/repo_tab.dart` | `extensions/src/manifest.rs`, `extensions/src/runtime.rs`, `extensions/src/manager/`, `extensions/src/repository/`, `mobile/src/extensions.rs`, `mobile/src/repository.rs` | `RustCoreBackendApplication.kt`, `ExtensionAuthCallbacks.kt`, `MainActivityExtensionIntents.kt`, `ExtensionCallbackParser.swift` | +| Settings | `providers/settings_provider.dart`, `models/settings.dart`, `screens/settings/`, `utils/settings_search_catalog.dart` | none; values the backend needs are pushed by the `_sync*ToBackend` methods of `SettingsNotifier` | none | +| Statistics and Rewind | `providers/listening_statistics_provider.dart`, `services/listening_statistics.dart`, `providers/listening_rewind_provider.dart`, `services/listening_rewind.dart` | none | none | +| Widgets and notifications | `services/player_widget_service.dart`, `services/notification_service.dart`, `services/playback_notification.dart`, `providers/playback_notification_provider.dart` | none | `PlayerWidgetProvider.kt`, `DownloadQueueWidgetProvider.kt`, notifications in `DownloadService.kt`, `PlayerWidgetBridge.swift`, `ios/PlayerWidget/` | + +## Data Flow + +### Download (Android native worker) + +1. `downloadSingleTrack` (`lib/widgets/track_detail_actions.dart`) calls + `DownloadQueueNotifier.addToQueue`, which appends a `DownloadItem`, saves + the queue, and schedules `_processQueue`. +2. `_processQueueSingleFlight` (`lib/providers/download_queue_provider.dart`) + checks storage and network mode, then calls + `_tryProcessQueueWithAndroidNativeWorker` + (`lib/providers/download_queue_provider_native_worker.dart`). It proceeds + only when `_canUseAndroidNativeWorker` holds: Android, + `nativeDownloadWorkerEnabled` and `useExtensionProviders` on, an enabled + extension with a download provider, a download tree URI when SAF storage is + in use, and no network-folder items. +3. It builds the first request with `_buildAndroidNativeWorkerRequest` and calls + `PlatformBridge.startNativeDownloadWorker`, which writes requests and + settings to JSON files and sends their paths. Remaining items follow with + `appendNativeDownloadWorkerRequests` and + `finishNativeDownloadWorkerPreparation`. +4. `MainActivity.handleStartNativeDownloadWorker` (`MainActivityServiceCalls.kt`) + calls `DownloadService.startNativeQueueFromFiles`, which starts the + foreground service. `onStartCommand` runs `startNativeWorker`, then + `runNativeWorkerConcurrent` and `processConcurrentNativeRequest` per item. +5. `SafDownloadHandler.handle` calls `CoreBackend.downloadByStrategy`. The Rust + implementation in `SelectedCoreBackend.kt` calls the UniFFI + `ExtensionManager.downloadByStrategy` (`mobile/src/manager.rs`), which runs + `download_by_strategy` in `extensions/src/backend/downloads.rs`. +6. On success, `NativeDownloadFinalizer.finalize` finishes the file and, when + `save_download_history` is set, writes the row with + `NativeHistoryStore.upsert`. `writeNativeWorkerSnapshot` + (`DownloadServiceSnapshot.kt`) publishes item state. +7. `NativeDownloadWorkerMonitor.run` polls + `PlatformBridge.getNativeDownloadWorkerSnapshot`. + `_applyAndroidNativeWorkerSnapshot` updates the items and acknowledges them + with `acknowledgeNativeDownloadWorkerItems`; `_completeAndroidNativeWorkerItem` + calls `reconcileNativeDownloadHistory` (`lib/services/native_download_history.dart`) + so `DownloadHistoryNotifier` adopts or reloads the native row. + +Without the native worker (iOS, or when the gate fails), items run through +`_downloadSingleItem` (`lib/providers/download_queue_provider_single_item.dart`), +which calls `PlatformBridge.downloadByStrategy`, finalizes in Dart, and records +history with `DownloadHistoryNotifier.addToHistory`. Android handles that call +in `handleDownloadByStrategy`; iOS in `CoreBackend.swift`. + +### Playback + +1. A screen calls `PlaybackController` (`lib/providers/playback_provider.dart`), + for example `playTrackList` from `library_tracks_folder_screen.dart`. When + `playerMode` is not `internal` and the source is not `network://`, it opens + the file in an external app instead. +2. `MusicPlayerController.playAll` (`lib/providers/music_player_provider.dart`) + calls `ensureInitialized`, which runs `MusicPlayerRuntime.initialize` + (`lib/services/music_player_runtime.dart`), + which creates the `MusicPlayerHandler` through `AudioService.init` from the + `third_party/audio_service` fork, then calls `setQueueAndPlay`. +3. `MusicPlayerHandler.setQueueAndPlay` (`lib/services/music_player_service.dart`) + publishes `queue`, then `_playIndex` and `_loadIndex` publish `mediaItem` + and call `_startResolvedSource`. +4. `_startResolvedSource` calls `MusicPlaybackDeck.setSource` or + `setNetworkSource` and `resume` (`lib/services/music_playback_deck.dart`). + The deck wraps an audioplayers `AudioPlayer`, served by the + `third_party/audioplayers_android` and `third_party/audioplayers_darwin` + overrides; with USB bit-perfect enabled on Android it first tries to + prepare the file through `UsbBitPerfectPlugin.kt`. +5. Player events reach `_broadcastState`, which updates `playbackState`. + `MusicPlayerRuntime.playbackStateEvents`, `mediaItemEvents`, and + `queueEvents` feed `playbackStateProvider`, `currentMediaItemProvider`, and + `playQueueProvider`, which the mini player and Now Playing screen watch. + +## How to Add Things + +### A Setting + +- Add the field and its default to `AppSettings` (`lib/models/settings.dart`, + constructor and `copyWith`), then run `build_runner` (see + [Generated Files](#generated-files)) to refresh `settings.g.dart`. +- `SettingsNotifier` stores `AppSettings.toJson()` as one JSON string under the + SharedPreferences key `app_settings`, so the JSON key is the Dart field name. + Renaming a field changes the stored key. Use a `@JsonKey(fromJson: ...)` + helper to clamp stored values, as the AutoMix fields do. +- Add a setter to `SettingsNotifier` (`lib/providers/settings_provider.dart`): + `state = state.copyWith(...)` followed by `_saveSettings()`. If the backend + needs the value, push it as `_syncLyricsSettingsToBackend` does. +- Rewrites of existing stored values go in `_runMigrations`: bump + `_currentMigrationVersion` and guard the step with `if (lastMigration < N)`. +- Add the row to the owning page in `lib/screens/settings/` and a + `SettingsSearchEntry` to the matching list in + `lib/utils/settings_search_catalog.dart`. +- Add strings to `lib/l10n/arb/app_en.arb` with an `@key` description and read + them through `context.l10n`. +- Test with the JSON round trip in `test/app_settings_model_test.dart`, setter + and persistence cases like `test/settings_persistence_test.dart` (a + `SettingsNotifier` subclass over a fake `SharedPreferences`), and + `test/settings_search_test.dart`. + +### A Screen + +- Put it in `lib/screens/` (settings pages in `lib/screens/settings/`) and split + a large screen into `_*.dart` files. +- Push it with `Navigator.push` and a `MaterialPageRoute` or `slidePageRoute` + (`lib/widgets/animation_utils.dart`). Tabs keep their own `Navigator` from + `_TabNavigator` in `lib/screens/main_shell.dart`; `GoRouter` in `lib/app.dart` + owns only `/`, `/setup`, and `/tutorial`. +- Use the shared components: `AppSliverHeader`, `showAppBottomSheet` and + `AppSheetHandle`, `SettingsGroup`, and the `AppTokens` scale. +- A track collection screen uses `CollectionScaffold` and `SelectionModeMixin`, + and its file name joins the `screens` list in the "every collection screen + goes through the shared shell" test. +- `test/design_system_test.dart` scans every file in `lib/`: each `IconButton` + and `AppIconButton` passes `tooltip`, and only allowlisted files may contain + `expandedTitleScale` (`app_sliver_header.dart`), a hand-built drag handle + (`app_bottom_sheet.dart`), a modal sheet shape (`app_theme.dart`), or + `OverlayEntry(` (`selection_bottom_bar.dart`, `app_menu_button.dart`). + +### A Platform Channel Method + +- Dart: add a static method to `PlatformBridge` in + `lib/services/platform_bridge.dart` that calls `_channel.invokeMethod` with + snake_case argument keys; helper classes and codecs can go in a part under + `lib/services/platform_bridge/`. Decode JSON results with + `_decodeJsonResultAsync` or `_decodeRequiredMapResultAsync`. +- Rust-backed methods: on Android a name outside `nativeBackendMethods` goes + through `dispatchBackendApplication` (`SelectedCoreBackend.kt`) to + `RustCoreBackend.dispatchApplication` (`RustCoreBackendApplication.kt`); add + the case there. On iOS add it to `invokeApplication` in + `ios/Runner/CoreBackend.swift`. +- Android-only methods: add the name to `nativeBackendMethods` and a case to + the `when (call.method)` block in `MainActivity.kt`, calling a + `MainActivity.handleX` function in the matching `MainActivity*Calls.kt` file. + `MainActivityRoutingTest.kt` fails when the set and the cases differ. +- iOS-only methods: add the name to `osMethods` in `handleMethodCall` + (`ios/Runner/AppDelegate.swift`) and handle it there or in `invokeOsMethod`. + `test/source_contracts_test.dart` pins parts of that file. +- Large JSON: `bridgeJsonResult` in `MainActivity.kt` and `AppDelegate.swift` + writes a result of 256 KiB or more to a temporary file and returns + `{"__json_file": ""}`; Dart reads and deletes it. Use it for any result + that can grow with the library. +- New Rust entry points are `#[uniffi::export]` items in + `rust_backend/crates/mobile/src/`; the build regenerates bindings (see + [Development Setup](#development-setup)). +- Dart tests stub `com.zarz.spotiflac/backend` with `setMockMethodCallHandler`, + for example `test/extension_initialization_test.dart`. + +### A Lyrics Provider + +- Provider-specific sources fit an extension first: installed lyrics + extensions appear in the provider order as `extension:` entries, and + `InstalledLyricsFetcher` (`rust_backend/crates/extensions/src/lyrics.rs`) + lists and calls them. +- A built-in provider is a `pub(super) fn` on `BuiltinLyricsClient` in a file + under `rust_backend/crates/providers/src/lyrics/builtin/` (`apple.rs`, + `direct.rs`, `proxy.rs`, `ttml.rs`), taking `&SearchRequest` and + `&Check<'_>` and returning `Result`. +- Add its match arm to `BuiltinLyricsClient::fetch_once` + (`rust_backend/crates/providers/src/lyrics/builtin.rs`). +- Add the ID to `BUILTIN_PROVIDERS` and `available_providers` in + `rust_backend/crates/core/src/lyrics/config.rs`; `normalize_provider_order` + drops unknown IDs. `DEFAULT_PROVIDERS` mirrors the Dart default + `AppSettings.lyricsProviders`; changing either for existing users needs a + settings migration (see Migration 13 in `_runMigrations`). +- Dart UI: `_builtInProviderIds` and `_getLyricsProviderInfo` in + `lib/screens/settings/lyrics_provider_priority_page.dart`, + `_providerDisplayNames` in `lib/screens/settings/lyrics_settings_page.dart`, + and a description key like `lyricsProviderLrclibDesc`. +- Tests: `rust_backend/crates/providers/src/lyrics/service/tests.rs`, plus the + shared usability cases described in [Validation](#validation). + +### An Extension-Facing API + +- The manifest contract is `rust_backend/crates/extensions/src/manifest.rs`. + Host functions are registered by `register` in + `rust_backend/crates/extensions/src/host.rs` and the `*_host.rs` modules; the + JavaScript side is `rust_backend/crates/extensions/src/prelude.js`. +- Surface results as generic fields, such as `has_download_provider` from + `rust_backend/crates/extensions/src/manager/info.rs` read by + `Extension.fromJson` (`lib/models/extension.dart`), never as extension-name + checks (see [Project Boundaries](#project-boundaries)). +- Treat existing host functions and manifest fields as a contract with + installed extensions; add new ones rather than changing them. +- Tests: `rust_backend/crates/extensions/src/runtime/tests.rs`, + `rust_backend/crates/extensions/src/manager/tests.rs`, + `rust_backend/crates/extensions/tests/`, and the `test/extension_*_test.dart` + files. +- Keep tests provider-neutral: generic extension IDs, display names, + namespaces, and example URLs; no copied extension source and no dependency + on the separate extension repository. Platform names stay only where the + fixed API, metadata schema, URL parsing, or legacy compatibility needs them. + +## Testing Each Layer + +Commands are in [Validation](#validation). + +| Layer | Location | Notes | +| --- | --- | --- | +| Dart unit and widget | `test/` | [test/README.md](test/README.md); fixtures in `test/fixtures/`, helpers in `test/support/` | +| Device and performance | `integration_test/` | [integration_test/README.md](integration_test/README.md) | +| Kotlin JVM | `android/app/src/test/kotlin/com/zarz/spotiflac/` | Case tables in `android/app/src/test/resources/` are also read by Dart tests | +| Rust | `tests.rs` modules beside the code, `rust_backend/crates/extensions/tests/` | For example `rust_backend/crates/extensions/src/runtime/tests.rs` | +| iOS | `ios/RunnerTests/` | `RunnerTests.swift` | + +## Conventions + +- Split large Dart classes with `part` files: the library declares + `part 'name_area.dart';`, the part starts with `part of 'name.dart';` and + holds `extension _Area on Name { ... }` or private helper classes. Parts that + use the notifier's `state` start with + `// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member`, + as most `download_queue_provider_*.dart` parts do. +- Public notifier methods stay on the class. More than 120 test classes + subclass `DownloadQueueNotifier`, `DownloadHistoryNotifier`, + `LocalLibraryNotifier`, `LibraryCollectionsNotifier`, `ExtensionNotifier`, + and `SettingsNotifier` to override them; only private members move to parts. +- After moving a file, leave a one-line `export` of the new path at the old one + while other files import it, as `lib/services/batch_track_actions.dart` does. +- Source-text contracts: `test/source_contracts_test.dart` pins literal text in + files such as `ios/Runner/AppDelegate.swift`, `NativeDownloadFinalizer.kt`, + `NativeHistoryStore.kt`, `DownloadServiceSnapshot.kt`, and + `lib/services/history_schema.dart`; `test/design_system_test.dart` pins file + names; `MainActivityRoutingTest.kt` reads `MainActivity.kt`. When code moves, + update the path or assertion in the same commit and never delete the check. +- Keep hand-written files under about 800 lines, split by responsibility. + Exceptions: generated or frozen tables such as + `rust_backend/crates/core/src/metadata/musicbrainz/case_data.rs`, the + settings search catalog `lib/utils/settings_search_catalog.dart`, upstream + code in `third_party/` and `rust_backend/vendor/`, ARB files, and generated + `*.g.dart` and localization files. +- Never write the em dash character (U+2014) in code, comments, strings, docs, + or commit messages; use a comma, colon, parentheses, or a new sentence. +- Commit messages follow [Code and Commit Style](#code-and-commit-style). + ## Generated Files - After changing ARB files, run `fvm flutter gen-l10n` (or `fvm flutter pub get`)