diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 611fa8604..07744c557 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -237,7 +237,7 @@ submersion/ │ │ ├── dive_types/ # Custom dive types │ │ ├── marine_life/ # Species sightings │ │ ├── tools/ # Calculators -│ │ └── onboarding/ # First-run experience +│ │ └── setup_wizard/ # First-run setup wizard + Settings re-entry │ │ │ └── shared/ # Reusable components │ ├── constants/ diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index 2cc91feb4..b3c165ce3 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -1303,6 +1303,13 @@ --- +### Setup Wizard (Completed) + +- [x] Setup wizard for new databases (discussion #523): first-run fork + (fresh start vs existing data), units with locale-aware preset, + appearance, backup schedule and cloud sync connect, restore/adopt + paths, feature-discovery finish screen, Settings re-entry + ## 15.2 Multi-User / Family Support | Feature | Status | Phase | Notes | diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index b542bb710..40cac3e53 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -157,7 +157,7 @@ lib/ │ ├── dive_types/ │ ├── marine_life/ │ ├── tools/ -│ └── onboarding/ +│ └── setup_wizard/ │ └── shared/ # Shared components ├── constants/ diff --git a/docs/developer/navigation.md b/docs/developer/navigation.md index 9515448fb..aebde5d09 100644 --- a/docs/developer/navigation.md +++ b/docs/developer/navigation.md @@ -330,7 +330,8 @@ GoRoute( | Path | Name | Screen | |------|------|--------| -| `/welcome` | welcome | WelcomePage | +| `/welcome` | welcome | SetupWizardPage (first-run mode) | +| `/settings/setup-assistant` | setupAssistant | SetupWizardPage (re-entry mode) | ## Navigation Patterns @@ -394,7 +395,10 @@ This provides instant transitions between main tabs, matching platform conventio ### First-Run Guard -The router redirects to onboarding if no divers exist: +The router redirects to the setup wizard if no divers exist. The wizard +offers a fresh-start path (profile, units, appearance, backup and sync) and +an existing-data path (restore a backup, connect cloud sync, or open an +existing storage folder): ```dart redirect: (context, state) async { diff --git a/docs/guide/README.md b/docs/guide/README.md index e49a80467..27d535619 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -30,7 +30,7 @@ Submersion has five main sections accessible from the bottom navigation bar (mob ### Core Workflow -1. **Create a diver profile** - On first launch, you'll set up your diver profile +1. **Run the setup wizard** - On first launch, create your profile and pick units, appearance, and backup options (or restore existing data) 2. **Add dive sites** - Build your site database (or import existing sites) 3. **Log dives** - Manually or download from a dive computer 4. **Track gear** - Add your equipment with service reminders diff --git a/docs/guide/first-dive.md b/docs/guide/first-dive.md index e130d708e..541825f6f 100644 --- a/docs/guide/first-dive.md +++ b/docs/guide/first-dive.md @@ -2,18 +2,23 @@ Let's log your first dive in Submersion! This quick tutorial will get you familiar with the basics. -## Step 1: Create Your Diver Profile +## Step 1: Run the Setup Wizard -When you first launch Submersion, you'll be asked to create a diver profile. +When you first launch Submersion, a short setup wizard walks you through +creating your logbook. If you already have Submersion data, choose +**I have existing Submersion data** to restore a backup, connect cloud +sync, or open an existing folder instead.
- Screenshot: Welcome/Onboarding Screen
- The welcome screen where you create your first diver profile + Screenshot: Setup Wizard Welcome Screen
+ The welcome screen where setup begins
-1. Enter your **name** -2. Optionally add your **email** and **phone** -3. Tap **Create Profile** +1. Choose **Set up a new logbook** +2. Enter your **name** +3. Pick your **units** (Metric or Imperial), appearance, and backup + preferences - every step after the name can be skipped +4. Tap **Start logging**
Tip: You can add more details to your profile later, including emergency contacts, medical info, and insurance details. diff --git a/docs/superpowers/plans/2026-07-10-setup-wizard.md b/docs/superpowers/plans/2026-07-10-setup-wizard.md new file mode 100644 index 000000000..81986c089 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-setup-wizard.md @@ -0,0 +1,3620 @@ +# Setup Wizard for New Databases Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the name-only welcome page with a multi-step setup wizard (units, appearance, backup/sync, existing-data restore/adopt paths) per the approved spec `docs/superpowers/specs/2026-07-10-setup-wizard-design.md` (discussion #523). + +**Architecture:** New `lib/features/setup_wizard/` feature with a draft-and-apply `StateNotifier` and a small `PageView` shell; `WizardStepDef`/`WizardStepIndicator` move to `lib/shared/widgets/wizard/` and are shared with the import wizard. Existing-data paths re-skin presentation over the existing restore, sync, and storage engines. First-run settings are seeded via `DiverSettingsRepository.createSettingsForDiver(id, settings: draft)` BEFORE `setCurrentDiver` (avoids the SettingsNotifier reload race — this supersedes the spec's `applySettings()` bulk-method idea; amend the spec in Task 5). + +**Tech Stack:** Flutter 3 / Material 3, Riverpod (StateNotifier), go_router, Drift (via existing repositories), file_picker, flutter gen-l10n (11 locales). + +## Global Constraints + +- All user-facing strings via `context.l10n.*`; new keys added to `lib/l10n/arb/app_en.arb` AND translated in all 10 other arb files (ar, de, es, fr, he, hu, it, nl, pt, zh); regenerate with `flutter gen-l10n` (generated `app_localizations*.dart` files are checked in — commit them). +- No emojis in code/comments/docs. Immutability (copyWith, no mutation). `dart format .` must produce no changes before each commit. +- Tests: run SPECIFIC test files (never the whole suite — it times out). Widget tests wrap post-pump Drift awaits in `tester.runAsync`; `FormSection` uppercases labels; never dispose controllers in `whenComplete()`. +- Commit after each task with `git add ` (never `git add -A`; the worktree may contain untracked build dirs). Do not push (pre-push hook runs against the MAIN tree; push is out of scope). +- Import order: dart, flutter, packages, local. Files 200-400 lines typical, 800 max. +- Riverpod 3: no provider mutation in `dispose()`; use `Future.microtask` + `mounted` guards where a callback mutates providers. +- Existing engines are called, never modified, except: `app.dart` gains a preview-locale hook (Task 9) and the router swaps the `/welcome` builder (Task 13). + +**Key facts (verified in code — trust these over guesses):** +- `DiverListNotifier.addDiver(Diver)` returns `Future` with the real id (`diver_providers.dart:251`). +- `CurrentDiverIdNotifier.setCurrentDiver(String)` triggers an UNAWAITED `SettingsNotifier` reload that resets `_validatedDiverId` and later overwrites state with fresh defaults — settings setters called immediately after `setCurrentDiver` are silently dropped. Sequence: create diver → seed settings row → THEN setCurrentDiver. +- `DiverSettingsRepository.createSettingsForDiver(String diverId, {AppSettings? settings})` seeds a full row in one insert (`diver_settings_repository.dart:45`); `getSettingsForDiver(diverId)` returns `AppSettings?`; `updateSettingsForDiver(diverId, settings)` updates. Provider: `diverSettingsRepositoryProvider` (settings_providers.dart). +- `SettingsNotifier` existing setters: `setMetric()`, `setImperial()` (6 units, NOT sacUnit), `setDepthUnit/setTemperatureUnit/setPressureUnit/setVolumeUnit/setWeightUnit/setAltitudeUnit/setSacUnit`, `setTimeFormat`, `setDateFormat`, `setThemeMode(ThemeMode)`, `setThemePresetId(String)`, `setMapStyle(MapStyle)`, `setLocale(String)`. Do NOT add methods to SettingsNotifier (3 hand-written full-implementer mocks would break). +- `AppSettings` fields used: 7 units + `timeFormat` + `dateFormat` + `themeMode` (Flutter `ThemeMode`) + `themePresetId` (String: submersion|console|tropical|minimalist|deep) + `locale` (String, 'system' or code) + `mapStyle` (`MapStyle{openStreetMap,openTopoMap,esriSatellite}` in `lib/core/constants/map_style.dart`). `UnitPreset{metric,imperial,custom}` lives in settings_providers.dart; `AppSettings.unitPreset` getter checks the 6 core units. +- Backup: `backupSettingsProvider` → `BackupSettingsNotifier.setEnabled(bool)/setFrequency(BackupFrequency{daily,weekly,monthly})/setCloudBackupEnabled(bool)`; state `BackupSettings{enabled=false, frequency=weekly, cloudBackupEnabled=false}` (SharedPreferences-backed). +- Restore: `FilePicker.pickFiles(type: FileType.any)` → temp `BackupRecord` → `RestoreConfirmationDialog.show(context, record, currentSchemaVersion: AppDatabase.currentSchemaVersion, offerReplace: ...)` → `RestoreMode?` → `ref.read(backupOperationProvider.notifier).restoreFromFilePath(path, mode: mode)`; listen for `BackupOperationStatus.restoreComplete` → `RestoreCompletePage.show(context)` (its Continue button calls `restartApp()`). +- Folder adopt: `ref.read(storageConfigNotifierProvider.notifier)`: `pickCustomFolder({chooser})` → `FolderPickResultWithBookmark?`; `checkForExistingDatabase(path)` → `ExistingDatabaseInfo?`; `switchToExistingDatabase(path)` → `MigrationResult` (`.success`). Wizard calls `restartApp()` after success (per spec). +- Sync activation contract (mirror `CloudSyncPage._selectProvider`): set `selectedCloudProviderTypeProvider.notifier.state = type` → `await cloudProviderInstanceFor(type).authenticate()` (`Future`, throws on failure) → `await ref.read(syncInitializerProvider).saveProvider(type)` → `ref.read(syncStateProvider.notifier).refreshState()`. On failure reset selection to null. +- iCloud gate: `isApplePlatformProvider` AND `iCloudAvailabilityProvider != ICloudAvailability.unsupported`. Dropbox gate: `dropboxConfiguredProvider`; connect via `showDialog(builder: (_) => DropboxConnectDialog(provider: ref.read(dropboxStorageProviderInstanceProvider)))`. S3: push existing route `/settings/cloud-sync/s3-config` (S3ConfigPage activates S3 itself on save). Google Drive stays hidden. +- Remote-library detection: `await ref.read(syncInitializerProvider).peerSyncFiles(providerInstance)` → `List`; non-empty = existing library. +- Pull: `ref.read(syncStateProvider.notifier).performSync()`; on a zero-dive DB it auto-adopts replaced libraries and plain-pulls otherwise. Watch `syncStateProvider` (`SyncState{status: SyncStatus{idle,syncing,success,error,hasConflicts}, progress, message}`). After success call `realignActiveDiverAfterDataReplace(ref.read(sharedPreferencesProvider))` (from diver_providers.dart) before leaving the wizard. +- Router: `/welcome` is a top-level GoRoute OUTSIDE the ShellRoute (`app_router.dart:169`); redirect gate reads `hasAnyDiversProvider.future` on navigation events only (no refreshListenable — no mid-wizard yank). Settings children are relative-path GoRoutes under `/settings` with plain `builder:`. +- Test helpers: `testApp({child, overrides})` / `testAppRouter({router, overrides})` in `test/helpers/test_app.dart`; `setUpTestDatabase()`/`tearDownTestDatabase()` in `test/helpers/test_database.dart`; `SharedPreferences.setMockInitialValues({})` then `sharedPreferencesProvider.overrideWithValue(prefs)`; `getBaseOverrides()` in `test/helpers/mock_providers.dart`. + +--- + +### Task 1: Move wizard primitives to lib/shared/widgets/wizard/ + +**Files:** +- Move: `lib/features/import_wizard/domain/models/wizard_step_def.dart` → `lib/shared/widgets/wizard/wizard_step_def.dart` +- Move: `lib/features/import_wizard/presentation/widgets/wizard_step_indicator.dart` → `lib/shared/widgets/wizard/wizard_step_indicator.dart` +- Modify (import line only): `lib/features/import_wizard/data/adapters/healthkit_adapter.dart:19`, `lib/features/import_wizard/data/adapters/universal_adapter.dart:36`, `lib/features/import_wizard/data/adapters/dive_computer_adapter.dart:28`, `lib/features/import_wizard/domain/services/step_skip_calculator.dart:1`, `lib/features/import_wizard/domain/adapters/import_source_adapter.dart:6`, `lib/features/import_wizard/presentation/pages/unified_import_wizard.dart:20,28` +- Modify (test imports): `test/features/import_wizard/domain/services/step_skip_calculator_test.dart:5`, `test/features/import_wizard/domain/models/wizard_step_def_test.dart:4`, `test/features/import_wizard/domain/adapters/import_source_adapter_test.dart:8`, `test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart:15`, `test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart:22`, `test/features/import_wizard/presentation/widgets/review_step_test.dart:14`, `test/features/import_wizard/presentation/widgets/review_step_pending_test.dart:12`, `test/features/import_wizard/presentation/widgets/import_summary_step_test.dart:14`, `test/features/import_wizard/presentation/widgets/import_progress_step_test.dart:11`, `test/features/import_wizard/presentation/widgets/wizard_step_indicator_test.dart:3` + +**Interfaces:** +- Produces: `package:submersion/shared/widgets/wizard/wizard_step_def.dart` exporting `WizardStepDef` (unchanged API) and `package:submersion/shared/widgets/wizard/wizard_step_indicator.dart` exporting `WizardStepIndicator({required List labels, required int currentStep})`. Later tasks import from these paths. + +- [ ] **Step 1: Move files with git mv** + +```bash +mkdir -p lib/shared/widgets/wizard +git mv lib/features/import_wizard/domain/models/wizard_step_def.dart lib/shared/widgets/wizard/wizard_step_def.dart +git mv lib/features/import_wizard/presentation/widgets/wizard_step_indicator.dart lib/shared/widgets/wizard/wizard_step_indicator.dart +``` + +- [ ] **Step 2: Update every import site** + +In each of the 16 files listed above, replace the old import string with the new one (both old strings map to the matching new path): + +```dart +// OLD +import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +// NEW +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; + +// OLD +import 'package:submersion/features/import_wizard/presentation/widgets/wizard_step_indicator.dart'; +// NEW +import 'package:submersion/shared/widgets/wizard/wizard_step_indicator.dart'; +``` + +Note `test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart` is a GENERATED file but is checked in — edit its import line the same way (build_runner would regenerate identically from the new path). + +- [ ] **Step 3: Verify no stale imports remain** + +Run: `rg -n "import_wizard/domain/models/wizard_step_def|import_wizard/presentation/widgets/wizard_step_indicator" lib test` +Expected: no matches. + +- [ ] **Step 4: Run the import wizard test suite** + +Run: `flutter test test/features/import_wizard/` +Expected: all tests PASS (move is behavior-neutral). + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/shared/widgets/wizard lib/features/import_wizard test/features/import_wizard +git commit -m "refactor: move wizard step primitives to lib/shared/widgets/wizard" +``` + +--- + +### Task 2: Localization strings for the setup wizard + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb` (add ~65 `setup_*` + 2 `settings_manage_setupAssistant*` keys, alphabetically placed) +- Modify: `lib/l10n/arb/app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb` (same keys, translated) +- Regenerate: `lib/l10n/arb/app_localizations*.dart` via `flutter gen-l10n` + +**Interfaces:** +- Produces: `context.l10n.setup_*` getters used by Tasks 6-13. Key names below are load-bearing — later tasks reference them verbatim. + +- [ ] **Step 1: Add the English keys to `app_en.arb`** + +Insert each key in correct alphabetical position (the file is sorted; `setup_*` sorts after `settings_*`). Placeholder keys need `@`-metadata blocks as shown: + +```json +"settings_manage_setupAssistant": "Setup assistant", +"settings_manage_setupAssistant_subtitle": "Revisit units, appearance, and backup choices", +"setup_appearance_language": "Language", +"setup_appearance_mapStyle": "Map style", +"setup_appearance_mapStyle_esriSatellite": "Satellite", +"setup_appearance_mapStyle_openStreetMap": "Street", +"setup_appearance_mapStyle_openTopoMap": "Topographic", +"setup_appearance_subtitle": "Make Submersion yours. Everything here can be changed later.", +"setup_appearance_theme": "Theme", +"setup_appearance_themeDark": "Dark", +"setup_appearance_themeLight": "Light", +"setup_appearance_themePreset": "Color theme", +"setup_appearance_themeSystem": "System", +"setup_appearance_title": "Appearance", +"setup_backup_cloudCopy": "Keep a backup copy in the cloud", +"setup_backup_frequency": "Frequency", +"setup_backup_frequency_daily": "Daily", +"setup_backup_frequency_monthly": "Monthly", +"setup_backup_frequency_weekly": "Weekly", +"setup_backup_scheduleSubtitle": "Back up your logbook on a schedule", +"setup_backup_scheduleToggle": "Automatic backups", +"setup_backup_subtitle": "Protect your dive log from day one.", +"setup_backup_title": "Backups & Sync", +"setup_common_back": "Back", +"setup_common_next": "Next", +"setup_common_skip": "Skip", +"setup_existing_folder_subtitle": "Point Submersion at a folder that already contains a library", +"setup_existing_folder_title": "Open an existing folder", +"setup_existing_restore_subtitle": "Pick a backup file exported from Submersion", +"setup_existing_restore_title": "Restore a backup file", +"setup_existing_subtitle": "Choose how to load your existing Submersion library", +"setup_existing_sync_subtitle": "Pull your library from iCloud, Dropbox, or S3", +"setup_existing_sync_title": "Connect cloud sync", +"setup_existing_title": "Bring your data", +"setup_finish_applying": "Setting up...", +"setup_finish_error": "Could not complete setup: {error}", +"@setup_finish_error": { "placeholders": { "error": { "type": "Object" } } }, +"setup_finish_feature_diveComputer": "Download dives from your dive computer", +"setup_finish_feature_gear": "Track gear and service intervals", +"setup_finish_feature_import": "Import logs from files and other apps", +"setup_finish_feature_sites": "Map your dive sites", +"setup_finish_feature_statistics": "Explore statistics about your diving", +"setup_finish_start": "Start logging", +"setup_finish_subtitle": "Submersion can also...", +"setup_finish_title": "You're all set", +"setup_folder_notFound_message": "The selected folder does not contain a Submersion database.", +"setup_folder_notFound_title": "No library in that folder", +"setup_folder_pick": "Choose folder", +"setup_folder_switching": "Opening library...", +"setup_folder_title": "Open existing folder", +"setup_profile_nameHint": "Enter your name", +"setup_profile_nameLabel": "Your Name", +"setup_profile_nameValidation": "Please enter your name", +"setup_profile_subtitle": "Enter your name to get started. You can add more details later in Settings.", +"setup_profile_title": "Create Your Profile", +"setup_restore_inProgress": "Restoring...", +"setup_restore_pick": "Choose backup file", +"setup_restore_title": "Restore backup", +"setup_step_appearance": "Appearance", +"setup_step_backup": "Backup", +"setup_step_finish": "Done", +"setup_step_profile": "Profile", +"setup_step_units": "Units", +"setup_sync_connectedTo": "Connected to {provider}", +"@setup_sync_connectedTo": { "placeholders": { "provider": { "type": "String" } } }, +"setup_sync_error": "Could not connect: {error}", +"@setup_sync_error": { "placeholders": { "error": { "type": "Object" } } }, +"setup_sync_header": "Cloud sync", +"setup_sync_icloudUnavailable": "iCloud is not available on this device", +"setup_sync_libraryFound_adopt": "Adopt existing library", +"setup_sync_libraryFound_keepFresh": "Start fresh", +"setup_sync_libraryFound_message": "This account already contains a Submersion library. Adopt it instead of starting fresh?", +"setup_sync_libraryFound_title": "Existing library found", +"setup_sync_manageInSettings": "Manage in Settings", +"setup_sync_notConnected": "Not connected", +"setup_sync_subtitle": "Sync your logbook across devices", +"setup_syncPull_continue": "Continue", +"setup_syncPull_noLibrary_message": "No existing Submersion library was found on this account. Start fresh instead? Your connection will be kept.", +"setup_syncPull_noLibrary_title": "No library found", +"setup_syncPull_success": "Library adopted", +"setup_syncPull_syncing": "Pulling your library...", +"setup_syncPull_title": "Connect and pull", +"setup_units_advanced": "Fine-tune units", +"setup_units_altitude": "Altitude", +"setup_units_dateFormat": "Date format", +"setup_units_depth": "Depth", +"setup_units_imperial": "Imperial", +"setup_units_metric": "Metric", +"setup_units_pressure": "Pressure", +"setup_units_sac": "SAC rate", +"setup_units_subtitle": "Choose how measurements are displayed. You can fine-tune each unit.", +"setup_units_temperature": "Temperature", +"setup_units_timeFormat": "Time format", +"setup_units_title": "Units", +"setup_units_volume": "Volume", +"setup_units_weight": "Weight", +"setup_welcome_existingData_subtitle": "Restore a backup, connect cloud sync, or open an existing folder", +"setup_welcome_existingData_title": "I have existing Submersion data", +"setup_welcome_skipSetup": "Skip setup", +"setup_welcome_startFresh_subtitle": "Create your diver profile and configure the app", +"setup_welcome_startFresh_title": "Set up a new logbook", +"setup_welcome_subtitle": "Advanced dive logging and analytics", +"setup_welcome_title": "Welcome to Submersion" +``` + +- [ ] **Step 2: Translate the same keys into the 10 other arb files** + +For each of `app_ar.arb, app_de.arb, app_es.arb, app_fr.arb, app_he.arb, app_hu.arb, app_it.arb, app_nl.arb, app_pt.arb, app_zh.arb`: add every key above (same alphabetical placement, same `@`-metadata blocks copied verbatim) with a fluent translation of the English value, matching the tone of each file's existing `onboarding_welcome_*` / `settings_*` translations. Keep the placeholders `{error}` / `{provider}` untranslated. Product name "Submersion", provider names (iCloud, Dropbox, S3), and "SAC" stay untranslated. + +- [ ] **Step 3: Regenerate localizations** + +Run: `flutter gen-l10n` +Expected: exits 0; `lib/l10n/arb/app_localizations*.dart` files regenerate with the new getters and no "untranslated message" warnings for the new keys. + +- [ ] **Step 4: Verify compilation** + +Run: `flutter analyze lib/l10n` +Expected: No issues found. + +- [ ] **Step 5: Commit** + +```bash +git add lib/l10n +git commit -m "feat(l10n): add setup wizard strings in all locales" +``` + +--- + +### Task 3: Domain model — draft, step list, unit-preset detection + +**Files:** +- Create: `lib/features/setup_wizard/domain/setup_wizard_models.dart` +- Create: `lib/features/setup_wizard/domain/unit_preset_detector.dart` +- Test: `test/features/setup_wizard/domain/setup_wizard_models_test.dart` +- Test: `test/features/setup_wizard/domain/unit_preset_detector_test.dart` + +**Interfaces:** +- Consumes: `AppSettings`, `UnitPreset` from `package:submersion/features/settings/presentation/providers/settings_providers.dart`; `BackupFrequency` from `package:submersion/features/backup/domain/entities/backup_settings.dart`; `CloudProviderType` from `package:submersion/core/data/repositories/sync_repository.dart`. +- Produces (used by Tasks 4-13): `SetupWizardMode{firstRun, settings}`, `SetupPath{undecided, fresh, existingData}`, `ExistingDataSource{none, restoreBackup, cloudSync, openFolder}`, `SetupStepId{welcomeFork, profile, units, appearance, backupSync, finish, existingChoice, restore, syncConnect, openFolder}`, `class SetupWizardDraft` (fields below, `copyWith`), `List computeSteps(SetupWizardDraft draft)`, `UnitPreset presetForLocale(Locale locale)`. + +- [ ] **Step 1: Write the failing tests** + +`test/features/setup_wizard/domain/setup_wizard_models_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +void main() { + group('computeSteps', () { + test('first run starts with fork only while path undecided', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + expect(computeSteps(draft), [SetupStepId.welcomeFork]); + }); + + test('fresh path shows full first-run flow', () { + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + ); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.units, + SetupStepId.appearance, + SetupStepId.backupSync, + SetupStepId.finish, + ]); + }); + + test('skip setup collapses fresh path to profile then finish', () { + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + skipRequested: true, + ); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]); + }); + + test('existing-data path shows choice, then source step', () { + const undecided = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + ); + expect(computeSteps(undecided), [ + SetupStepId.welcomeFork, + SetupStepId.existingChoice, + ]); + + const restore = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.restoreBackup, + ); + expect(computeSteps(restore), [ + SetupStepId.welcomeFork, + SetupStepId.existingChoice, + SetupStepId.restore, + ]); + + const sync = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.cloudSync, + ); + expect(computeSteps(sync).last, SetupStepId.syncConnect); + + const folder = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.openFolder, + ); + expect(computeSteps(folder).last, SetupStepId.openFolder); + }); + + test('settings mode hides fork and profile', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.settings); + expect(computeSteps(draft), [ + SetupStepId.units, + SetupStepId.appearance, + SetupStepId.backupSync, + SetupStepId.finish, + ]); + }); + }); + + group('SetupWizardDraft', () { + test('applyUnitPreset imperial sets the six core units', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + final imperial = draft.applyingUnitPreset(UnitPreset.imperial); + expect(imperial.settings.depthUnit, DepthUnit.feet); + expect(imperial.settings.temperatureUnit, TemperatureUnit.fahrenheit); + expect(imperial.settings.pressureUnit, PressureUnit.psi); + expect(imperial.settings.volumeUnit, VolumeUnit.cubicFeet); + expect(imperial.settings.weightUnit, WeightUnit.pounds); + expect(imperial.settings.altitudeUnit, AltitudeUnit.feet); + // sacUnit untouched by preset + expect(imperial.settings.sacUnit, const AppSettings().sacUnit); + // and back to metric + final metric = imperial.applyingUnitPreset(UnitPreset.metric); + expect(metric.settings.unitPreset, UnitPreset.metric); + }); + }); +} +``` + +Add these imports at the top of the test (needed by the assertions): `import 'package:submersion/core/constants/units.dart';` and `import 'package:submersion/features/settings/presentation/providers/settings_providers.dart';` + +`test/features/setup_wizard/domain/unit_preset_detector_test.dart`: + +```dart +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/unit_preset_detector.dart'; + +void main() { + test('US, Liberia, Myanmar map to imperial', () { + expect(presetForLocale(const Locale('en', 'US')), UnitPreset.imperial); + expect(presetForLocale(const Locale('en', 'LR')), UnitPreset.imperial); + expect(presetForLocale(const Locale('my', 'MM')), UnitPreset.imperial); + }); + + test('everything else maps to metric', () { + expect(presetForLocale(const Locale('de', 'DE')), UnitPreset.metric); + expect(presetForLocale(const Locale('en', 'GB')), UnitPreset.metric); + expect(presetForLocale(const Locale('en')), UnitPreset.metric); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/features/setup_wizard/` +Expected: FAIL — files under `lib/features/setup_wizard/domain/` do not exist. + +- [ ] **Step 3: Implement the domain files** + +`lib/features/setup_wizard/domain/setup_wizard_models.dart`: + +```dart +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Whether the wizard runs at first launch or from Settings. +enum SetupWizardMode { firstRun, settings } + +/// Which top-level branch the user chose on the fork step. +enum SetupPath { undecided, fresh, existingData } + +/// Which existing-data source the user chose. +enum ExistingDataSource { none, restoreBackup, cloudSync, openFolder } + +/// Identity of every wizard step; the shell maps these to widgets. +enum SetupStepId { + welcomeFork, + profile, + units, + appearance, + backupSync, + finish, + existingChoice, + restore, + syncConnect, + openFolder, +} + +/// Immutable wizard draft. Nothing is persisted until Finish applies it. +class SetupWizardDraft { + final SetupWizardMode mode; + final SetupPath path; + final ExistingDataSource source; + final bool skipRequested; + final String name; + final AppSettings settings; + final bool backupEnabled; + final BackupFrequency backupFrequency; + final bool cloudBackupEnabled; + final CloudProviderType? connectedProvider; + + const SetupWizardDraft({ + required this.mode, + this.path = SetupPath.undecided, + this.source = ExistingDataSource.none, + this.skipRequested = false, + this.name = '', + this.settings = const AppSettings(), + this.backupEnabled = false, + this.backupFrequency = BackupFrequency.weekly, + this.cloudBackupEnabled = false, + this.connectedProvider, + }); + + SetupWizardDraft copyWith({ + SetupPath? path, + ExistingDataSource? source, + bool? skipRequested, + String? name, + AppSettings? settings, + bool? backupEnabled, + BackupFrequency? backupFrequency, + bool? cloudBackupEnabled, + CloudProviderType? connectedProvider, + }) { + return SetupWizardDraft( + mode: mode, + path: path ?? this.path, + source: source ?? this.source, + skipRequested: skipRequested ?? this.skipRequested, + name: name ?? this.name, + settings: settings ?? this.settings, + backupEnabled: backupEnabled ?? this.backupEnabled, + backupFrequency: backupFrequency ?? this.backupFrequency, + cloudBackupEnabled: cloudBackupEnabled ?? this.cloudBackupEnabled, + connectedProvider: connectedProvider ?? this.connectedProvider, + ); + } + + /// Returns a copy with the six core units set to [preset]. + /// [UnitPreset.custom] returns this draft unchanged. + SetupWizardDraft applyingUnitPreset(UnitPreset preset) { + switch (preset) { + case UnitPreset.metric: + return copyWith( + settings: settings.copyWith( + depthUnit: DepthUnit.meters, + temperatureUnit: TemperatureUnit.celsius, + pressureUnit: PressureUnit.bar, + volumeUnit: VolumeUnit.liters, + weightUnit: WeightUnit.kilograms, + altitudeUnit: AltitudeUnit.meters, + ), + ); + case UnitPreset.imperial: + return copyWith( + settings: settings.copyWith( + depthUnit: DepthUnit.feet, + temperatureUnit: TemperatureUnit.fahrenheit, + pressureUnit: PressureUnit.psi, + volumeUnit: VolumeUnit.cubicFeet, + weightUnit: WeightUnit.pounds, + altitudeUnit: AltitudeUnit.feet, + ), + ); + case UnitPreset.custom: + return this; + } + } +} + +/// Computes the ordered step list for the current draft state. +List computeSteps(SetupWizardDraft draft) { + if (draft.mode == SetupWizardMode.settings) { + return const [ + SetupStepId.units, + SetupStepId.appearance, + SetupStepId.backupSync, + SetupStepId.finish, + ]; + } + switch (draft.path) { + case SetupPath.undecided: + return const [SetupStepId.welcomeFork]; + case SetupPath.fresh: + if (draft.skipRequested) { + return const [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]; + } + return const [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.units, + SetupStepId.appearance, + SetupStepId.backupSync, + SetupStepId.finish, + ]; + case SetupPath.existingData: + final steps = [SetupStepId.welcomeFork, SetupStepId.existingChoice]; + switch (draft.source) { + case ExistingDataSource.none: + break; + case ExistingDataSource.restoreBackup: + steps.add(SetupStepId.restore); + case ExistingDataSource.cloudSync: + steps.add(SetupStepId.syncConnect); + case ExistingDataSource.openFolder: + steps.add(SetupStepId.openFolder); + } + return steps; + } +} +``` + +`lib/features/setup_wizard/domain/unit_preset_detector.dart`: + +```dart +import 'dart:ui'; + +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Countries using imperial-style units for everyday measurement. +const _imperialCountries = {'US', 'LR', 'MM'}; + +/// Maps a device locale to the unit preset the wizard preselects. +UnitPreset presetForLocale(Locale locale) { + final country = locale.countryCode?.toUpperCase(); + if (country != null && _imperialCountries.contains(country)) { + return UnitPreset.imperial; + } + return UnitPreset.metric; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/` +Expected: all PASS. + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): setup wizard domain model and step computation" +``` + +--- + +### Task 4: SetupWizardNotifier and providers + +**Files:** +- Create: `lib/features/setup_wizard/presentation/providers/setup_wizard_providers.dart` +- Test: `test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart` + +**Interfaces:** +- Consumes: Task 3 domain types. +- Produces (used by Tasks 5-13): + - `final setupWizardProvider = StateNotifierProvider.autoDispose.family(...)` + - `SetupWizardNotifier` mutators: `void choosePath(SetupPath path)`, `void chooseSource(ExistingDataSource source)`, `void requestSkip()`, `void setName(String name)`, `void applyUnitPreset(UnitPreset preset)`, `void updateSettings(AppSettings settings)`, `void setBackupEnabled(bool value)`, `void setBackupFrequency(BackupFrequency value)`, `void setCloudBackupEnabled(bool value)`, `void setConnectedProvider(CloudProviderType? type)` + - `final previewLocaleProvider = StateProvider((ref) => null);` + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; + +void main() { + ProviderContainer makeContainer() { + final container = ProviderContainer(); + addTearDown(container.dispose); + return container; + } + + test('starts undecided in first-run mode, seeded from defaults', () { + final container = makeContainer(); + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.undecided); + expect(draft.settings, const AppSettings()); + }); + + test('mutators update the draft', () { + final container = makeContainer(); + final notifier = container.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.choosePath(SetupPath.fresh); + notifier.setName(' Eric '); + notifier.applyUnitPreset(UnitPreset.imperial); + notifier.setBackupEnabled(true); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.fresh); + expect(draft.name, 'Eric'); + expect(draft.settings.unitPreset, UnitPreset.imperial); + expect(draft.backupEnabled, isTrue); + expect( + computeSteps(draft), + contains(SetupStepId.backupSync), + ); + }); + + test('requestSkip trims the fresh path', () { + final container = makeContainer(); + final notifier = container.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.requestSkip(); + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.fresh); + expect(draft.skipRequested, isTrue); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart` +Expected: FAIL — provider file does not exist. + +- [ ] **Step 3: Implement the notifier** + +`lib/features/setup_wizard/presentation/providers/setup_wizard_providers.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/units.dart' as units; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +/// Locale code the wizard previews before settings are applied at Finish. +/// Consulted by the app's locale resolution (see app.dart) so a language +/// choice takes effect immediately while the wizard is open. +final previewLocaleProvider = StateProvider((ref) => null); + +/// Draft state for one wizard session, keyed by mode. +final setupWizardProvider = StateNotifierProvider.autoDispose + .family( + (ref, mode) => SetupWizardNotifier(ref, mode), + ); + +class SetupWizardNotifier extends StateNotifier { + final Ref _ref; + + SetupWizardNotifier(this._ref, SetupWizardMode mode) + : super(SetupWizardDraft(mode: mode)) { + if (mode == SetupWizardMode.settings) { + // Seed the draft from the live settings so re-entry edits start + // from what the diver already has. + state = state.copyWith(settings: _ref.read(settingsProvider)); + } + } + + void choosePath(SetupPath path) => state = state.copyWith(path: path); + + void chooseSource(ExistingDataSource source) => + state = state.copyWith(source: source); + + void requestSkip() => + state = state.copyWith(path: SetupPath.fresh, skipRequested: true); + + void setName(String name) => state = state.copyWith(name: name.trim()); + + void applyUnitPreset(UnitPreset preset) => + state = state.applyingUnitPreset(preset); + + void updateSettings(AppSettings settings) => + state = state.copyWith(settings: settings); + + void setBackupEnabled(bool value) => + state = state.copyWith(backupEnabled: value); + + void setBackupFrequency(BackupFrequency value) => + state = state.copyWith(backupFrequency: value); + + void setCloudBackupEnabled(bool value) => + state = state.copyWith(cloudBackupEnabled: value); + + void setConnectedProvider(CloudProviderType? type) => + state = state.copyWith(connectedProvider: type); +} +``` + +Note: the `units` import alias is only needed if the analyzer flags an unused import — the draft's preset logic lives in the domain file. Remove the import if unused. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart` +Expected: PASS. (The settings-mode seeding path is exercised in Task 5's tests, where settingsProvider is overridable.) + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): setup wizard draft notifier and preview locale provider" +``` + +--- + +### Task 5: Apply service — persist the draft at Finish + +**Files:** +- Create: `lib/features/setup_wizard/data/setup_apply_service.dart` +- Test: `test/features/setup_wizard/data/setup_apply_service_test.dart` +- Modify: `docs/superpowers/specs/2026-07-10-setup-wizard-design.md` (Architecture item 3: replace the `applySettings()` bulk-method sentence with the seed-before-switch approach) + +**Interfaces:** +- Consumes: `diverListNotifierProvider`, `currentDiverIdProvider`, `realignActiveDiverAfterDataReplace` (diver_providers.dart); `diverSettingsRepositoryProvider`, `settingsProvider` (settings_providers.dart); `backupSettingsProvider` (backup_providers.dart); Task 3/4 types. +- Produces (used by Task 11): `class SetupApplyService { SetupApplyService(this._ref); Future applyFirstRun(SetupWizardDraft draft); Future applySettingsMode(SetupWizardDraft draft); }` and `final setupApplyServiceProvider = Provider(...)`. + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/data/setup_apply_service_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/data/repositories/diver_settings_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/data/setup_apply_service.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +import '../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ProviderContainer makeContainer() { + final container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(container.dispose); + return container; + } + + test('applyFirstRun creates default diver, seeds settings, sets current', + () async { + final container = makeContainer(); + final service = container.read(setupApplyServiceProvider); + + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + name: 'Eric', + backupEnabled: true, + backupFrequency: BackupFrequency.daily, + ); + final imperialDraft = draft.applyingUnitPreset(UnitPreset.imperial); + + await service.applyFirstRun(imperialDraft); + + final divers = await DiverRepository().getAllDivers(); + expect(divers, hasLength(1)); + expect(divers.single.name, 'Eric'); + expect(divers.single.isDefault, isTrue); + + // Settings row was seeded with the draft BEFORE the diver switch. + final stored = + await DiverSettingsRepository().getSettingsForDiver(divers.single.id); + expect(stored, isNotNull); + expect(stored!.depthUnit, DepthUnit.feet); + expect(stored.pressureUnit, PressureUnit.psi); + expect(stored.weightUnit, WeightUnit.pounds); + + // Current diver persisted to prefs. + expect(prefs.getString(currentDiverIdKey), divers.single.id); + + // Backup schedule applied. + final backup = container.read(backupSettingsProvider); + expect(backup.enabled, isTrue); + expect(backup.frequency, BackupFrequency.daily); + }); + + test('applyFirstRun with empty name throws ArgumentError and writes nothing', + () async { + final container = makeContainer(); + final service = container.read(setupApplyServiceProvider); + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + + await expectLater(service.applyFirstRun(draft), throwsArgumentError); + expect(await DiverRepository().getAllDivers(), isEmpty); + }); + + test('applySettingsMode updates the current diver via existing setters', + () async { + final container = makeContainer(); + + // Seed a diver + make it current, mirroring a real re-entry session. + final repo = DiverRepository(); + final now = DateTime.now(); + final diver = await repo.createDiver(Diver( + id: '', + name: 'Existing', + isDefault: true, + createdAt: now, + updatedAt: now, + )); + await container + .read(currentDiverIdProvider.notifier) + .setCurrentDiver(diver.id); + // Let the SettingsNotifier finish its reload before applying. + await container.read(settingsProvider.notifier).stream.first; + + final service = container.read(setupApplyServiceProvider); + const base = SetupWizardDraft(mode: SetupWizardMode.settings); + final draft = base.applyingUnitPreset(UnitPreset.imperial); + await service.applySettingsMode(draft); + + final stored = + await DiverSettingsRepository().getSettingsForDiver(diver.id); + expect(stored!.depthUnit, DepthUnit.feet); + expect(stored.volumeUnit, VolumeUnit.cubicFeet); + }); +} +``` + +Add `import 'package:submersion/features/divers/domain/entities/diver.dart';` to the test imports (needed for the `Diver` constructor in the third test). + +Note on the third test: if `settingsProvider.notifier.stream.first` proves flaky for awaiting the reload, replace it with a polling loop (`while (container.read(settingsProvider) == const AppSettings()) { await Future.delayed(const Duration(milliseconds: 10)); }` bounded to ~1s) — the intent is only "reload settled before apply". + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/data/setup_apply_service_test.dart` +Expected: FAIL — `setup_apply_service.dart` does not exist. + +- [ ] **Step 3: Implement the apply service** + +`lib/features/setup_wizard/data/setup_apply_service.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +final setupApplyServiceProvider = Provider( + (ref) => SetupApplyService(ref), +); + +/// Persists a completed wizard draft. +/// +/// First-run ordering is load-bearing: the settings row is seeded through +/// the repository BEFORE the diver becomes current, because switching the +/// current diver triggers an unawaited SettingsNotifier reload that would +/// otherwise race the write (and create a defaults row that clobbers ours). +class SetupApplyService { + final Ref _ref; + + SetupApplyService(this._ref); + + Future applyFirstRun(SetupWizardDraft draft) async { + final name = draft.name.trim(); + if (name.isEmpty) { + throw ArgumentError('Diver name must not be empty'); + } + + final now = DateTime.now(); + final newDiver = await _ref + .read(diverListNotifierProvider.notifier) + .addDiver( + Diver( + id: '', + name: name, + isDefault: true, + createdAt: now, + updatedAt: now, + ), + ); + + // Seed the settings row with the draft BEFORE the switch (see class doc). + await _ref + .read(diverSettingsRepositoryProvider) + .createSettingsForDiver(newDiver.id, settings: draft.settings); + + await _ref + .read(currentDiverIdProvider.notifier) + .setCurrentDiver(newDiver.id); + + await _applyBackupChoices(draft); + } + + Future applySettingsMode(SetupWizardDraft draft) async { + // Re-entry: the current diver's settings row exists and the + // SettingsNotifier is loaded, so the existing setters are race-free. + final notifier = _ref.read(settingsProvider.notifier); + final s = draft.settings; + await notifier.setDepthUnit(s.depthUnit); + await notifier.setTemperatureUnit(s.temperatureUnit); + await notifier.setPressureUnit(s.pressureUnit); + await notifier.setVolumeUnit(s.volumeUnit); + await notifier.setWeightUnit(s.weightUnit); + await notifier.setAltitudeUnit(s.altitudeUnit); + await notifier.setSacUnit(s.sacUnit); + await notifier.setTimeFormat(s.timeFormat); + await notifier.setDateFormat(s.dateFormat); + await notifier.setThemeMode(s.themeMode); + await notifier.setThemePresetId(s.themePresetId); + await notifier.setMapStyle(s.mapStyle); + await notifier.setLocale(s.locale); + + await _applyBackupChoices(draft); + } + + Future _applyBackupChoices(SetupWizardDraft draft) async { + final backup = _ref.read(backupSettingsProvider.notifier); + await backup.setEnabled(draft.backupEnabled); + if (draft.backupEnabled) { + await backup.setFrequency(draft.backupFrequency); + } + if (draft.connectedProvider != null && draft.cloudBackupEnabled) { + await backup.setCloudBackupEnabled(true); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/setup_wizard/data/setup_apply_service_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Amend the spec's apply paragraph** + +In `docs/superpowers/specs/2026-07-10-setup-wizard-design.md`, replace item 3 of the "Apply order on Finish (first-run)" list (the sentence proposing `SettingsNotifier.applySettings(AppSettings)` and its mock-cost note) with: + +```markdown +3. Seed the draft settings into the new diver's `DiverSettings` row via + `DiverSettingsRepository.createSettingsForDiver(id, settings: draft)` + BEFORE step 2's diver switch. Switching current diver fires an unawaited + `SettingsNotifier` reload that would race (and clobber) post-switch + writes; seeding first means the reload simply loads the seeded row. No + new `SettingsNotifier` API is needed, so no settings mocks change. +``` + +Renumber the surrounding list so the switch happens AFTER the seed (create diver → seed settings → set current → backup choices), matching the implementation. + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard docs/superpowers/specs/2026-07-10-setup-wizard-design.md +git commit -m "feat(setup): draft apply service with race-safe settings seeding" +``` + +--- + +### Task 6: Wizard shell + fork + profile steps + +**Files:** +- Create: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/profile_step.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/placeholder_step.dart` (temporary; deleted as Tasks 7-11 land) +- Test: `test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart` + +**Interfaces:** +- Consumes: Tasks 3-4 types/providers; `WizardStepIndicator` + `WizardStepDef` from `package:submersion/shared/widgets/wizard/...`; `OceanBackground` from `package:submersion/core/presentation/widgets/ocean_background.dart`. +- Produces: `SetupWizardPage({required SetupWizardMode mode})` — the widget Tasks 12's routes build. Internal contract for step widgets (Tasks 7-11): each content step is a plain widget reading/writing `setupWizardProvider(mode)`; choice steps receive explicit callbacks; the shell exposes `SetupShellController` (advance/back) via constructor injection to steps that own their controls. + +- [ ] **Step 1: Write the failing widget test** + +`test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +void main() { + testWidgets('first run shows fork; fresh choice walks to profile and back', + (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget(testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + )); + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Submersion'), findsOneWidget); + expect(find.text('Set up a new logbook'), findsOneWidget); + expect(find.text('I have existing Submersion data'), findsOneWidget); + + await tester.tap(find.text('Set up a new logbook')); + await tester.pumpAndSettle(); + + expect(find.text('Create Your Profile'), findsOneWidget); + + // Next disabled with empty name. + final nextFinder = find.widgetWithText(FilledButton, 'Next'); + expect(tester.widget(nextFinder).onPressed, isNull); + + await tester.enterText(find.byType(TextFormField), 'Eric'); + await tester.pumpAndSettle(); + expect(tester.widget(nextFinder).onPressed, isNotNull); + + await tester.tap(nextFinder); + await tester.pumpAndSettle(); + // Units placeholder page (real step lands in Task 7). + expect(find.text('Units'), findsWidgets); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Back')); + await tester.pumpAndSettle(); + expect(find.text('Create Your Profile'), findsOneWidget); + }); + + testWidgets('skip setup jumps from profile straight to finish placeholder', + (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget(testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Skip setup')); + await tester.pumpAndSettle(); + expect(find.text('Create Your Profile'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'Eric'); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await tester.pumpAndSettle(); + + expect(find.text("You're all set"), findsOneWidget); + }); + + testWidgets('settings mode starts at units with no fork', (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget(testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.settings), + )); + await tester.pumpAndSettle(); + + expect(find.text('Welcome to Submersion'), findsNothing); + expect(find.text('Units'), findsWidgets); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart` +Expected: FAIL — `setup_wizard_page.dart` does not exist. + +- [ ] **Step 3: Implement the shell and first two steps** + +`lib/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart`: + +```dart +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// First-run fork: start fresh, bring existing data, or skip setup. +class WelcomeForkStep extends StatelessWidget { + final VoidCallback onStartFresh; + final VoidCallback onExistingData; + final VoidCallback onSkipSetup; + + const WelcomeForkStep({ + super.key, + required this.onStartFresh, + required this.onExistingData, + required this.onSkipSetup, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ExcludeSemantics( + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + 'assets/icon/icon.png', + width: 96, + height: 96, + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + l10n.setup_welcome_title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.setup_welcome_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _ForkCard( + icon: Icons.add_circle_outline, + title: l10n.setup_welcome_startFresh_title, + subtitle: l10n.setup_welcome_startFresh_subtitle, + onTap: onStartFresh, + ), + const SizedBox(height: 12), + _ForkCard( + icon: Icons.cloud_download_outlined, + title: l10n.setup_welcome_existingData_title, + subtitle: l10n.setup_welcome_existingData_subtitle, + onTap: onExistingData, + ), + const SizedBox(height: 24), + TextButton( + onPressed: onSkipSetup, + child: Text(l10n.setup_welcome_skipSetup), + ), + ], + ), + ); + } +} + +class _ForkCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _ForkCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, size: 32, color: theme.colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} +``` + +`lib/features/setup_wizard/presentation/widgets/steps/profile_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Collects the diver name (the wizard's only mandatory input). +class ProfileStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const ProfileStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _ProfileStepState(); +} + +class _ProfileStepState extends ConsumerState { + late final TextEditingController _nameController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: ref.read(setupWizardProvider(widget.mode)).name, + ); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_profile_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_profile_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.setup_profile_nameLabel, + hintText: l10n.setup_profile_nameHint, + prefixIcon: const Icon(Icons.person), + ), + textCapitalization: TextCapitalization.words, + autofocus: true, + textInputAction: TextInputAction.done, + onChanged: notifier.setName, + ), + ], + ), + ); + } +} +``` + +`lib/features/setup_wizard/presentation/widgets/steps/placeholder_step.dart` (temporary scaffolding so the shell compiles before Tasks 7-11; each later task replaces one usage): + +```dart +import 'package:flutter/material.dart'; + +/// Temporary stand-in for steps implemented in later tasks. +class PlaceholderStep extends StatelessWidget { + final String title; + + const PlaceholderStep({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ); + } +} +``` + +`lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/presentation/widgets/ocean_background.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/placeholder_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/profile_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_indicator.dart'; + +/// Multi-step setup wizard for new databases (first run) and Settings +/// re-entry. See docs/superpowers/specs/2026-07-10-setup-wizard-design.md. +class SetupWizardPage extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const SetupWizardPage({super.key, required this.mode}); + + @override + ConsumerState createState() => _SetupWizardPageState(); +} + +/// Steps that show the progress indicator and the shared Next/Back bar. +const _formZone = { + SetupStepId.profile, + SetupStepId.units, + SetupStepId.appearance, + SetupStepId.backupSync, +}; + +class _SetupWizardPageState extends ConsumerState { + final _pageController = PageController(); + int _currentIndex = 0; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + Future _animateTo(int index) async { + if (_pageController.hasClients) { + await _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + if (mounted) setState(() => _currentIndex = index); + } + + void _advance() { + final steps = computeSteps(ref.read(setupWizardProvider(widget.mode))); + if (_currentIndex < steps.length - 1) { + _animateTo(_currentIndex + 1); + } + } + + void _back() { + if (_currentIndex > 0) _animateTo(_currentIndex - 1); + } + + /// Choice steps mutate the draft (growing the step list), then advance + /// after the rebuild that adds the next page. + void _chooseAndAdvance(void Function() mutate) { + mutate(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _advance(); + }); + } + + String _stepLabel(SetupStepId id) { + final l10n = context.l10n; + switch (id) { + case SetupStepId.profile: + return l10n.setup_step_profile; + case SetupStepId.units: + return l10n.setup_step_units; + case SetupStepId.appearance: + return l10n.setup_step_appearance; + case SetupStepId.backupSync: + return l10n.setup_step_backup; + case SetupStepId.finish: + return l10n.setup_step_finish; + default: + return ''; + } + } + + WizardStepDef _defFor(SetupStepId id) { + final mode = widget.mode; + final notifier = ref.read(setupWizardProvider(mode).notifier); + switch (id) { + case SetupStepId.welcomeFork: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => WelcomeForkStep( + onStartFresh: () => + _chooseAndAdvance(() => notifier.choosePath(SetupPath.fresh)), + onExistingData: () => _chooseAndAdvance( + () => notifier.choosePath(SetupPath.existingData), + ), + onSkipSetup: () => _chooseAndAdvance(notifier.requestSkip), + ), + ); + case SetupStepId.profile: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider( + mode, + ).select((d) => d.name.trim().isNotEmpty), + builder: (_) => ProfileStep(mode: mode), + ); + case SetupStepId.units: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Units'), + ); + case SetupStepId.appearance: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Appearance'), + ); + case SetupStepId.backupSync: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Backups & Sync'), + ); + case SetupStepId.finish: + return WizardStepDef( + label: _stepLabel(id), + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: "You're all set"), + ); + case SetupStepId.existingChoice: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Bring your data'), + ); + case SetupStepId.restore: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Restore backup'), + ); + case SetupStepId.syncConnect: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Connect and pull'), + ); + case SetupStepId.openFolder: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const PlaceholderStep(title: 'Open existing folder'), + ); + } + } + + @override + Widget build(BuildContext context) { + final draft = ref.watch(setupWizardProvider(widget.mode)); + final steps = computeSteps(draft); + if (_currentIndex >= steps.length) { + _currentIndex = steps.length - 1; + } + final defs = steps.map(_defFor).toList(); + final currentId = steps[_currentIndex]; + final inFormZone = _formZone.contains(currentId); + + // Indicator covers the contiguous labelled tail of the flow (profile or + // units onward, plus finish); choice steps stay indicator-free. + final labelledIds = steps + .where((s) => _formZone.contains(s) || s == SetupStepId.finish) + .toList(); + final labelledIndex = labelledIds.indexOf(currentId); + + return Scaffold( + body: OceanBackground( + child: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + if (labelledIndex >= 0) + WizardStepIndicator( + labels: labelledIds.map(_stepLabel).toList(), + currentStep: labelledIndex, + ), + Expanded( + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (final def in defs) + Builder(builder: def.builder), + ], + ), + ), + if (inFormZone) _buildBottomBar(defs[_currentIndex]), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomBar(WizardStepDef def) { + final l10n = context.l10n; + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + if (_currentIndex > 0) + OutlinedButton( + onPressed: _back, + child: Text(l10n.setup_common_back), + ), + const Spacer(), + if (_currentIndex < computeStepCount() - 1 && + _skippable(currentStepId())) + TextButton( + onPressed: _advance, + child: Text(l10n.setup_common_skip), + ), + const SizedBox(width: 8), + _NextButton(def: def, onNext: _advance), + ], + ), + ), + ); + } + + SetupStepId currentStepId() { + final steps = computeSteps(ref.read(setupWizardProvider(widget.mode))); + return steps[_currentIndex]; + } + + int computeStepCount() { + return computeSteps(ref.read(setupWizardProvider(widget.mode))).length; + } + + bool _skippable(SetupStepId id) => id != SetupStepId.profile; +} + +class _NextButton extends ConsumerWidget { + final WizardStepDef def; + final VoidCallback onNext; + + const _NextButton({required this.def, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canAdvance = ref.watch(def.canAdvance); + return FilledButton( + style: FilledButton.styleFrom(minimumSize: const Size(100, 44)), + onPressed: canAdvance ? onNext : null, + child: Text(context.l10n.setup_common_next), + ); + } +} +``` + +Implementation note: `Expanded(child: PageView(...))` inside a `Card` inside `Center` needs bounded height — the `Center > ConstrainedBox > Padding > Card > Column` chain is height-unbounded on desktop. Wrap the `ConstrainedBox` content so the column gets the full safe-area height: replace `Center(child: ConstrainedBox(...))` with `Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 560), child: SizedBox.expand(child: ...)))` — `SizedBox.expand` inside `Center` clamps to the parent's max size and keeps the card full-height. If the first test run throws unbounded-height layout errors, this is the fix to apply. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart` +Expected: 3 tests PASS. + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): wizard shell with fork and profile steps" +``` + +--- + +### Task 7: Units step + +**Files:** +- Create: `lib/features/setup_wizard/presentation/widgets/steps/units_step.dart` +- Modify: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` (replace the units `PlaceholderStep` with `UnitsStep(mode: mode)`) +- Test: `test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart` + +**Interfaces:** +- Consumes: `setupWizardProvider`, `presetForLocale`, `UnitPreset`, the unit enums, `TimeFormat`, `DateFormatPreference`. +- Produces: `UnitsStep({required SetupWizardMode mode})`. + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/units_step.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('preset toggle drives the draft units', (tester) async { + late ProviderContainer container; + await tester.pumpWidget(testApp( + child: Builder(builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Imperial')); + await tester.pumpAndSettle(); + + final draft = + container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.unitPreset, UnitPreset.imperial); + expect(draft.settings.depthUnit, DepthUnit.feet); + }); + + testWidgets('advanced expander exposes per-unit overrides', (tester) async { + late ProviderContainer container; + await tester.pumpWidget(testApp( + child: Builder(builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Fine-tune units')); + await tester.pumpAndSettle(); + + // Depth row: switch to feet only. + await tester.ensureVisible(find.text('Depth')); + await tester.tap(find.byKey(const ValueKey('setup-unit-depth-ft'))); + await tester.pumpAndSettle(); + + final draft = + container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.depthUnit, DepthUnit.feet); + expect(draft.settings.temperatureUnit, TemperatureUnit.celsius); + expect(draft.settings.unitPreset, UnitPreset.custom); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart` +Expected: FAIL — `units_step.dart` does not exist. + +- [ ] **Step 3: Implement the step** + +`lib/features/setup_wizard/presentation/widgets/steps/units_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/domain/unit_preset_detector.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Metric/Imperial preset with an expander for per-unit fine-tuning. +class UnitsStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const UnitsStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _UnitsStepState(); +} + +class _UnitsStepState extends ConsumerState { + @override + void initState() { + super.initState(); + // First-run: preselect the preset matching the device locale, once. + if (widget.mode == SetupWizardMode.firstRun) { + final draft = ref.read(setupWizardProvider(widget.mode)); + if (draft.settings.unitPreset == UnitPreset.metric) { + final locale = + WidgetsBinding.instance.platformDispatcher.locale; + final preset = presetForLocale(locale); + if (preset != UnitPreset.metric) { + Future.microtask(() { + if (mounted) { + ref + .read(setupWizardProvider(widget.mode).notifier) + .applyUnitPreset(preset); + } + }); + } + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final draft = ref.watch(setupWizardProvider(widget.mode)); + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + final s = draft.settings; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_units_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_units_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + SegmentedButton( + segments: [ + ButtonSegment( + value: UnitPreset.metric, + label: Text(l10n.setup_units_metric), + ), + ButtonSegment( + value: UnitPreset.imperial, + label: Text(l10n.setup_units_imperial), + ), + ], + emptySelectionAllowed: true, + selected: s.unitPreset == UnitPreset.custom + ? const {} + : {s.unitPreset}, + onSelectionChanged: (selection) { + if (selection.isNotEmpty) { + notifier.applyUnitPreset(selection.first); + } + }, + ), + const SizedBox(height: 16), + ExpansionTile( + title: Text(l10n.setup_units_advanced), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + _unitRow( + label: l10n.setup_units_depth, + keyPrefix: 'setup-unit-depth', + values: DepthUnit.values, + selected: s.depthUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(depthUnit: u)), + ), + _unitRow( + label: l10n.setup_units_temperature, + keyPrefix: 'setup-unit-temperature', + values: TemperatureUnit.values, + selected: s.temperatureUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(temperatureUnit: u)), + ), + _unitRow( + label: l10n.setup_units_pressure, + keyPrefix: 'setup-unit-pressure', + values: PressureUnit.values, + selected: s.pressureUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(pressureUnit: u)), + ), + _unitRow( + label: l10n.setup_units_volume, + keyPrefix: 'setup-unit-volume', + values: VolumeUnit.values, + selected: s.volumeUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(volumeUnit: u)), + ), + _unitRow( + label: l10n.setup_units_weight, + keyPrefix: 'setup-unit-weight', + values: WeightUnit.values, + selected: s.weightUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(weightUnit: u)), + ), + _unitRow( + label: l10n.setup_units_altitude, + keyPrefix: 'setup-unit-altitude', + values: AltitudeUnit.values, + selected: s.altitudeUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(altitudeUnit: u)), + ), + _unitRow( + label: l10n.setup_units_sac, + keyPrefix: 'setup-unit-sac', + values: SacUnit.values, + selected: s.sacUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(sacUnit: u)), + ), + _unitRow( + label: l10n.setup_units_timeFormat, + keyPrefix: 'setup-unit-timeformat', + values: TimeFormat.values, + selected: s.timeFormat, + symbol: (u) => u.displayName, + onChanged: (u) => + notifier.updateSettings(s.copyWith(timeFormat: u)), + ), + _unitRow( + label: l10n.setup_units_dateFormat, + keyPrefix: 'setup-unit-dateformat', + values: DateFormatPreference.values, + selected: s.dateFormat, + symbol: (u) => u.displayName, + onChanged: (u) => + notifier.updateSettings(s.copyWith(dateFormat: u)), + ), + ], + ), + ], + ), + ); + } + + Widget _unitRow({ + required String label, + required String keyPrefix, + required List values, + required T selected, + required String Function(T) symbol, + required void Function(T) onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded(child: Text(label)), + SegmentedButton( + segments: [ + for (final v in values) + ButtonSegment( + value: v, + label: Text( + symbol(v), + key: ValueKey('$keyPrefix-${_segmentKey(symbol(v))}'), + ), + ), + ], + selected: {selected}, + showSelectedIcon: false, + onSelectionChanged: (sel) => onChanged(sel.first), + ), + ], + ), + ); + } + + String _segmentKey(String symbol) => + symbol.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); +} +``` + +(The depth "ft" segment therefore carries `ValueKey('setup-unit-depth-ft')`, matching the test.) + +- [ ] **Step 4: Replace the placeholder in the shell** + +In `setup_wizard_page.dart`, add `import 'package:submersion/features/setup_wizard/presentation/widgets/steps/units_step.dart';` and change the `SetupStepId.units` case builder from `const PlaceholderStep(title: 'Units')` to `UnitsStep(mode: mode)`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/` +Expected: all PASS (the shell test's `find.text('Units')` assertion still matches the real step's title). + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): units step with locale-aware preset and fine-tuning" +``` + +--- + +### Task 8: Appearance step + locale preview hook + +**Files:** +- Create: `lib/features/setup_wizard/presentation/widgets/steps/appearance_step.dart` +- Modify: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` (replace appearance placeholder) +- Modify: `lib/app.dart` (preview-locale hook, 2 lines) +- Test: `test/features/setup_wizard/presentation/widgets/steps/appearance_step_test.dart` + +**Interfaces:** +- Consumes: `previewLocaleProvider` (Task 4), `AppThemeRegistry` (`lib/core/theme/app_theme_registry.dart`, preset ids: submersion, console, tropical, minimalist, deep — display name via each preset's `nameKey` l10n key), `MapStyle`. +- Produces: `AppearanceStep({required SetupWizardMode mode})`; app-wide locale preview while the wizard is open. + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/widgets/steps/appearance_step_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/appearance_step.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('theme, map style, and language update draft and preview', + (tester) async { + late ProviderContainer container; + await tester.pumpWidget(testApp( + child: Builder(builder: (context) { + container = ProviderScope.containerOf(context); + return const AppearanceStep(mode: SetupWizardMode.firstRun); + }), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Map style')); + await tester.tap(find.text('Satellite')); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byType(DropdownButtonFormField)); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Deutsch').last); + await tester.pumpAndSettle(); + + final draft = + container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.themeMode, ThemeMode.dark); + expect(draft.settings.mapStyle, MapStyle.esriSatellite); + expect(draft.settings.locale, 'de'); + expect(container.read(previewLocaleProvider), 'de'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/widgets/steps/appearance_step_test.dart` +Expected: FAIL — `appearance_step.dart` does not exist. + +- [ ] **Step 3: Implement the step** + +`lib/features/setup_wizard/presentation/widgets/steps/appearance_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/map_style.dart'; +import 'package:submersion/core/theme/app_theme_registry.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Language options offered by the wizard. Codes must stay in sync with +/// AppLocalizations.supportedLocales; names render in their own language. +const _localeOptions = <(String, String)>[ + ('system', ''), + ('en', 'English'), + ('es', 'Espanol'), + ('fr', 'Francais'), + ('de', 'Deutsch'), + ('it', 'Italiano'), + ('nl', 'Nederlands'), + ('pt', 'Portugues'), + ('hu', 'Magyar'), + ('ar', 'العربية'), + ('he', 'עברית'), + ('zh', '简体中文'), +]; + +/// Theme mode, color theme, map style, and language. +class AppearanceStep extends ConsumerWidget { + final SetupWizardMode mode; + + const AppearanceStep({super.key, required this.mode}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final l10n = context.l10n; + final draft = ref.watch(setupWizardProvider(mode)); + final notifier = ref.read(setupWizardProvider(mode).notifier); + final s = draft.settings; + + Widget sectionLabel(String text) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 8), + child: Text(text, style: theme.textTheme.titleSmall), + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_appearance_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_appearance_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + sectionLabel(l10n.setup_appearance_theme), + SegmentedButton( + segments: [ + ButtonSegment( + value: ThemeMode.system, + label: Text(l10n.setup_appearance_themeSystem), + ), + ButtonSegment( + value: ThemeMode.light, + label: Text(l10n.setup_appearance_themeLight), + ), + ButtonSegment( + value: ThemeMode.dark, + label: Text(l10n.setup_appearance_themeDark), + ), + ], + selected: {s.themeMode}, + showSelectedIcon: false, + onSelectionChanged: (sel) => notifier.updateSettings( + s.copyWith(themeMode: sel.first), + ), + ), + sectionLabel(l10n.setup_appearance_themePreset), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in AppThemeRegistry.presets) + ChoiceChip( + label: Text(preset.id), + selected: s.themePresetId == preset.id, + onSelected: (_) => notifier.updateSettings( + s.copyWith(themePresetId: preset.id), + ), + ), + ], + ), + sectionLabel(l10n.setup_appearance_mapStyle), + SegmentedButton( + segments: [ + ButtonSegment( + value: MapStyle.openStreetMap, + label: Text(l10n.setup_appearance_mapStyle_openStreetMap), + ), + ButtonSegment( + value: MapStyle.openTopoMap, + label: Text(l10n.setup_appearance_mapStyle_openTopoMap), + ), + ButtonSegment( + value: MapStyle.esriSatellite, + label: Text(l10n.setup_appearance_mapStyle_esriSatellite), + ), + ], + selected: {s.mapStyle}, + showSelectedIcon: false, + onSelectionChanged: (sel) => notifier.updateSettings( + s.copyWith(mapStyle: sel.first), + ), + ), + sectionLabel(l10n.setup_appearance_language), + DropdownButtonFormField( + initialValue: s.locale, + items: [ + for (final (code, nativeName) in _localeOptions) + DropdownMenuItem( + value: code, + child: Text( + code == 'system' + ? l10n.setup_appearance_themeSystem + : nativeName, + ), + ), + ], + onChanged: (code) { + if (code == null) return; + notifier.updateSettings(s.copyWith(locale: code)); + ref.read(previewLocaleProvider.notifier).state = + code == 'system' ? null : code; + }, + ), + ], + ), + ); + } +} +``` + +Note: theme preset chips label with the raw preset `id` — the registry's `nameKey` values (`theme_submersion` etc.) are existing l10n keys; if `context.l10n` exposes them via a lookup helper elsewhere in the codebase, prefer that. Check `rg -n "nameKey" lib --glob '*.dart'` for the existing resolution pattern (the appearance settings page uses one) and mirror it; fall back to capitalized ids only if no pattern exists. +Note: if the analyzer version in this repo rejects the `(String, String)` record-list syntax or `DropdownButtonFormField.initialValue` (older Flutter), use a small private class with `code`/`nativeName` fields and the `value:` parameter respectively. + +- [ ] **Step 4: Wire the preview locale into app.dart** + +In `lib/app.dart`: +1. Add import: `import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart';` +2. In `build` (near line 269 where `localeProvider` is read): add `final previewLocale = ref.watch(previewLocaleProvider);` +3. Change the MaterialApp.router line `locale: _resolveLocale(localeSetting),` to `locale: _resolveLocale(previewLocale ?? localeSetting),` + +- [ ] **Step 5: Replace the placeholder in the shell** + +In `setup_wizard_page.dart`, import `appearance_step.dart` and change the `SetupStepId.appearance` case builder to `AppearanceStep(mode: mode)`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/ && flutter analyze lib/app.dart lib/features/setup_wizard` +Expected: tests PASS; analyze clean. + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard lib/app.dart test/features/setup_wizard +git commit -m "feat(setup): appearance step with live language preview" +``` + +--- + +### Task 9: Backup & Sync step + +**Files:** +- Create: `lib/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart` +- Modify: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` (replace backupSync placeholder) +- Test: `test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart` + +**Interfaces:** +- Consumes: `backupSettingsProvider` state only for re-entry status display; draft fields for the toggles; sync gates `isApplePlatformProvider`, `iCloudAvailabilityProvider`, `dropboxConfiguredProvider`, `selectedCloudProviderTypeProvider`, `dropboxStorageProviderInstanceProvider`, `syncInitializerProvider`, `syncStateProvider`, `cloudProviderInstanceFor` (all from `lib/features/settings/presentation/providers/sync_providers.dart`); `DropboxConnectDialog`; `CloudProviderType`; `ICloudAvailability` from `lib/core/services/cloud_storage/icloud_native_service.dart`. +- Produces: `BackupSyncStep({required SetupWizardMode mode, this.onLibraryFound})` where `onLibraryFound: void Function()?` is invoked when a freshly connected provider already holds a library (fresh-path pivot; wired in Task 11). + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('backup toggle reveals frequency and updates draft', + (tester) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + await tester.pumpWidget(testApp( + overrides: [ + ...overrides, + // Deterministic gates: not Apple, no Dropbox key. + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: Builder(builder: (context) { + container = ProviderScope.containerOf(context); + return const BackupSyncStep(mode: SetupWizardMode.firstRun); + }), + )); + await tester.pumpAndSettle(); + + expect(find.text('Frequency'), findsNothing); + await tester.tap(find.text('Automatic backups')); + await tester.pumpAndSettle(); + + expect(find.text('Frequency'), findsOneWidget); + await tester.tap(find.text('Daily')); + await tester.pumpAndSettle(); + + final draft = + container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.backupEnabled, isTrue); + expect(draft.backupFrequency, BackupFrequency.daily); + }); + + testWidgets('non-Apple platform without Dropbox shows S3 card only', + (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget(testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: const BackupSyncStep(mode: SetupWizardMode.firstRun), + )); + await tester.pumpAndSettle(); + + expect(find.text('iCloud'), findsNothing); + expect(find.text('Dropbox'), findsNothing); + expect(find.text('S3'), findsOneWidget); + expect(find.text('Not connected'), findsOneWidget); + }); +} +``` + +Override note: `isApplePlatformProvider` and `dropboxConfiguredProvider` are plain `Provider`s, so `overrideWithValue` is correct. If either is declared differently in `sync_providers.dart`, switch to `overrideWith((ref) => false)`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart` +Expected: FAIL — `backup_sync_step.dart` does not exist. + +- [ ] **Step 3: Implement the step** + +`lib/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/dropbox_connect_dialog.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Backup schedule plus optional cloud sync provider connection. +class BackupSyncStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + /// Fresh-path pivot: called when a just-connected provider already holds + /// a Submersion library (Task 11 wires the adopt offer). + final void Function()? onLibraryFound; + + const BackupSyncStep({super.key, required this.mode, this.onLibraryFound}); + + @override + ConsumerState createState() => _BackupSyncStepState(); +} + +class _BackupSyncStepState extends ConsumerState { + bool _connecting = false; + + Future _connect(CloudProviderType type) async { + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + setState(() => _connecting = true); + try { + if (type == CloudProviderType.dropbox) { + final connected = await showDialog( + context: context, + builder: (_) => DropboxConnectDialog( + provider: ref.read(dropboxStorageProviderInstanceProvider), + ), + ); + ref.invalidate(dropboxAuthDataProvider); + if (connected != true) return; + } + // Activation contract mirrored from CloudSyncPage._selectProvider. + ref.read(selectedCloudProviderTypeProvider.notifier).state = type; + final instance = cloudProviderInstanceFor(type); + await instance.authenticate(); + await ref.read(syncInitializerProvider).saveProvider(type); + ref.read(syncStateProvider.notifier).refreshState(); + notifier.setConnectedProvider(type); + + // Fresh-path pivot check: does this account already hold a library? + if (widget.onLibraryFound != null) { + final peers = await ref + .read(syncInitializerProvider) + .peerSyncFiles(instance); + if (peers.isNotEmpty && mounted) widget.onLibraryFound!(); + } + } catch (e) { + ref.read(selectedCloudProviderTypeProvider.notifier).state = null; + notifier.setConnectedProvider(null); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setup_sync_error(e))), + ); + } + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final draft = ref.watch(setupWizardProvider(widget.mode)); + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + + final isApple = ref.watch(isApplePlatformProvider); + final iCloudAvailability = + ref.watch(iCloudAvailabilityProvider).valueOrNull; + final iCloudAvailable = + isApple && iCloudAvailability != ICloudAvailability.unsupported; + final dropboxConfigured = ref.watch(dropboxConfiguredProvider); + final connected = draft.connectedProvider; + + Widget sectionLabel(String text) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 4), + child: Text(text, style: theme.textTheme.titleSmall), + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_backup_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_backup_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.setup_backup_scheduleToggle), + subtitle: Text(l10n.setup_backup_scheduleSubtitle), + value: draft.backupEnabled, + onChanged: notifier.setBackupEnabled, + ), + if (draft.backupEnabled) ...[ + sectionLabel(l10n.setup_backup_frequency), + SegmentedButton( + segments: [ + ButtonSegment( + value: BackupFrequency.daily, + label: Text(l10n.setup_backup_frequency_daily), + ), + ButtonSegment( + value: BackupFrequency.weekly, + label: Text(l10n.setup_backup_frequency_weekly), + ), + ButtonSegment( + value: BackupFrequency.monthly, + label: Text(l10n.setup_backup_frequency_monthly), + ), + ], + selected: {draft.backupFrequency}, + showSelectedIcon: false, + onSelectionChanged: (sel) => + notifier.setBackupFrequency(sel.first), + ), + ], + sectionLabel(l10n.setup_sync_header), + Text( + l10n.setup_sync_subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + if (connected != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cloud_done), + title: Text( + l10n.setup_sync_connectedTo(_providerName(connected)), + ), + ) + else ...[ + Text( + l10n.setup_sync_notConnected, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + if (iCloudAvailable) + _providerCard( + icon: Icons.cloud, + name: 'iCloud', + onTap: _connecting + ? null + : () => _connect(CloudProviderType.icloud), + ), + if (dropboxConfigured) + _providerCard( + icon: Icons.cloud_queue, + name: 'Dropbox', + onTap: _connecting + ? null + : () => _connect(CloudProviderType.dropbox), + ), + _providerCard( + icon: Icons.storage, + name: 'S3', + // S3ConfigPage performs its own activation on save. + onTap: _connecting + ? null + : () => context.push('/settings/cloud-sync/s3-config'), + ), + ], + if (connected != null) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.setup_backup_cloudCopy), + value: draft.cloudBackupEnabled, + onChanged: notifier.setCloudBackupEnabled, + ), + ], + ), + ); + } + + Widget _providerCard({ + required IconData icon, + required String name, + required VoidCallback? onTap, + }) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: Icon(icon), + title: Text(name), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } + + String _providerName(CloudProviderType type) { + switch (type) { + case CloudProviderType.icloud: + return 'iCloud'; + case CloudProviderType.dropbox: + return 'Dropbox'; + case CloudProviderType.s3: + return 'S3'; + case CloudProviderType.googledrive: + return 'Google Drive'; + } + } +} +``` + +Re-entry nuance: in settings mode with a provider already active (`ref.watch(selectedCloudProviderTypeProvider) != null` and no draft connection), render the connected tile using that provider plus a `TextButton` labelled `l10n.setup_sync_manageInSettings` navigating `context.push('/settings/cloud-sync')` instead of the connect cards. Implement as: `final effectiveConnected = connected ?? (widget.mode == SetupWizardMode.settings ? ref.watch(selectedCloudProviderTypeProvider) : null);` and branch on `effectiveConnected`, showing the manage button only in settings mode. + +S3 return detection: after `context.push('/settings/cloud-sync/s3-config')` resolves, S3ConfigPage has already activated S3 on save; add `.then((_) { final sel = ref.read(selectedCloudProviderTypeProvider); if (sel == CloudProviderType.s3 && mounted) { ref.read(setupWizardProvider(widget.mode).notifier).setConnectedProvider(sel); } })` to the push call so the wizard reflects it. + +- [ ] **Step 4: Replace the placeholder in the shell** + +In `setup_wizard_page.dart`, import `backup_sync_step.dart` and change the `SetupStepId.backupSync` builder to `BackupSyncStep(mode: mode)` (the `onLibraryFound` pivot is wired in Task 11). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/` +Expected: all PASS. + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): backup schedule and cloud sync connection step" +``` + +--- + +### Task 10: Finish step — feature highlights + apply + +**Files:** +- Create: `lib/features/setup_wizard/presentation/widgets/steps/finish_step.dart` +- Modify: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` (replace finish placeholder) +- Test: `test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart` + +**Interfaces:** +- Consumes: `setupApplyServiceProvider` (Task 5), `previewLocaleProvider`, draft. +- Produces: `FinishStep({required SetupWizardMode mode})`. Navigation contract: first-run completion goes `context.go('/dashboard')` (or a tapped feature route); settings mode pops. + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/finish_step.dart'; + +import '../../../../../helpers/test_app.dart'; +import '../../../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + testWidgets('start logging applies draft and navigates to dashboard', + (tester) async { + var dashboardShown = false; + final router = GoRouter( + initialLocation: '/wizard', + routes: [ + GoRoute( + path: '/wizard', + builder: (context, state) => Consumer( + builder: (context, ref, _) { + // Seed a completed fresh-path draft before rendering. + final notifier = ref.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.choosePath(SetupPath.fresh); + notifier.setName('Eric'); + return const Scaffold( + body: FinishStep(mode: SetupWizardMode.firstRun), + ); + }, + ), + ), + GoRoute( + path: '/dashboard', + builder: (context, state) { + dashboardShown = true; + return const Scaffold(body: Text('dashboard')); + }, + ), + ], + ); + + await tester.pumpWidget(testAppRouter( + router: router, + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + )); + await tester.pumpAndSettle(); + + expect(find.text("You're all set"), findsOneWidget); + expect(find.text('Download dives from your dive computer'), + findsOneWidget); + + await tester.tap(find.text('Start logging')); + await tester.runAsync(() async { + // Apply performs real DB writes; let them settle outside fake time. + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect(dashboardShown, isTrue); + final divers = await tester.runAsync(() => DiverRepository().getAllDivers()); + expect(divers, hasLength(1)); + expect(divers!.single.name, 'Eric'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart` +Expected: FAIL — `finish_step.dart` does not exist. + +- [ ] **Step 3: Implement the step** + +`lib/features/setup_wizard/presentation/widgets/steps/finish_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/features/setup_wizard/data/setup_apply_service.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Final step: feature discovery plus the apply-and-go action. +class FinishStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const FinishStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _FinishStepState(); +} + +class _FinishStepState extends ConsumerState { + bool _applying = false; + + Future _complete({String route = '/dashboard'}) async { + if (_applying) return; + setState(() => _applying = true); + try { + final draft = ref.read(setupWizardProvider(widget.mode)); + final service = ref.read(setupApplyServiceProvider); + if (widget.mode == SetupWizardMode.firstRun) { + await service.applyFirstRun(draft); + } else { + await service.applySettingsMode(draft); + } + // Locale now persists via settings; drop the preview override. + ref.read(previewLocaleProvider.notifier).state = null; + if (!mounted) return; + if (widget.mode == SetupWizardMode.firstRun) { + context.go(route); + } else if (route != '/dashboard') { + context.go(route); + } else { + context.pop(); + } + } catch (e) { + if (mounted) { + setState(() => _applying = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setup_finish_error(e))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + final features = <(IconData, String, String)>[ + ( + Icons.watch, + l10n.setup_finish_feature_diveComputer, + '/dive-computers/discover', + ), + (Icons.file_upload, l10n.setup_finish_feature_import, '/transfer'), + (Icons.query_stats, l10n.setup_finish_feature_statistics, '/stats'), + (Icons.map, l10n.setup_finish_feature_sites, '/sites'), + (Icons.build, l10n.setup_finish_feature_gear, '/gear'), + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.check_circle, + size: 56, color: theme.colorScheme.primary), + const SizedBox(height: 16), + Text( + l10n.setup_finish_title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.setup_finish_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + for (final (icon, label, route) in features) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(label), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: _applying ? null : () => _complete(route: route), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _applying ? null : _complete, + icon: _applying + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_forward), + label: Text( + _applying ? l10n.setup_finish_applying : l10n.setup_finish_start, + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 4: Replace the placeholder in the shell** + +In `setup_wizard_page.dart`, import `finish_step.dart` and change the `SetupStepId.finish` builder to `FinishStep(mode: mode)`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/` +Expected: all PASS. + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): finish step applies draft and offers feature links" +``` + +--- + +### Task 11: Existing-data path — choice, restore, sync pull, open folder + +**Files:** +- Create: `lib/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/restore_step.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart` +- Create: `lib/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart` +- Modify: `lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart` (replace remaining placeholders, delete `placeholder_step.dart`, wire fresh-path `onLibraryFound` pivot) +- Test: `test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart` + +**Interfaces:** +- Consumes: `backupOperationProvider` + `RestoreConfirmationDialog` + `RestoreCompletePage` (backup feature); `storageConfigNotifierProvider` (storage_providers.dart); `syncStateProvider`, sync gates (Task 9's set); `realignActiveDiverAfterDataReplace`, `sharedPreferencesProvider`, `hasAnyDiversProvider`; `restartApp` from `package:submersion/main.dart`; `FilePicker` from `package:file_picker/file_picker.dart`; `AppDatabase.currentSchemaVersion`. +- Produces: the four step widgets; shell fully placeholder-free. + +- [ ] **Step 1: Write the failing test** + +`test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart`: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('existing choice cards set the draft source', (tester) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + var advanced = 0; + await tester.pumpWidget(testApp( + overrides: overrides, + child: Builder(builder: (context) { + container = ProviderScope.containerOf(context); + return ExistingChoiceStep( + mode: SetupWizardMode.firstRun, + onChosen: () => advanced++, + ); + }), + )); + await tester.pumpAndSettle(); + + expect(find.text('Bring your data'), findsOneWidget); + await tester.tap(find.text('Restore a backup file')); + await tester.pumpAndSettle(); + + final draft = + container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.source, ExistingDataSource.restoreBackup); + expect(advanced, 1); + }); +} +``` + +(Restore/sync/folder steps orchestrate platform pickers, dialogs, and app restarts — they get integration coverage via the full-wizard smoke run in Task 14; their pure logic lives in already-tested engines.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart` +Expected: FAIL — `existing_choice_step.dart` does not exist. + +- [ ] **Step 3: Implement the four steps** + +`lib/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Chooses which existing-data source to load from. +class ExistingChoiceStep extends ConsumerWidget { + final SetupWizardMode mode; + final VoidCallback onChosen; + + const ExistingChoiceStep({ + super.key, + required this.mode, + required this.onChosen, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final l10n = context.l10n; + final notifier = ref.read(setupWizardProvider(mode).notifier); + + void choose(ExistingDataSource source) { + notifier.chooseSource(source); + onChosen(); + } + + Widget card(IconData icon, String title, String subtitle, + ExistingDataSource source) { + return Card( + child: ListTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => choose(source), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_existing_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_existing_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + card( + Icons.restore, + l10n.setup_existing_restore_title, + l10n.setup_existing_restore_subtitle, + ExistingDataSource.restoreBackup, + ), + card( + Icons.cloud_sync, + l10n.setup_existing_sync_title, + l10n.setup_existing_sync_subtitle, + ExistingDataSource.cloudSync, + ), + card( + Icons.folder_open, + l10n.setup_existing_folder_title, + l10n.setup_existing_folder_subtitle, + ExistingDataSource.openFolder, + ), + ], + ), + ); + } +} +``` + +`lib/features/setup_wizard/presentation/widgets/steps/restore_step.dart`: + +```dart +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/backup/domain/entities/backup_record.dart'; +import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/backup/presentation/widgets/restore_confirmation_dialog.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Restores a backup file; exits the wizard via the existing +/// RestoreCompletePage -> restartApp() flow. +class RestoreStep extends ConsumerWidget { + const RestoreStep({super.key}); + + Future _pickAndRestore(BuildContext context, WidgetRef ref) async { + final FilePickerResult? result; + try { + result = await FilePicker.pickFiles(type: FileType.any); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + return; + } + if (result == null || result.files.isEmpty) return; + final filePath = result.files.single.path; + if (filePath == null || !context.mounted) return; + + final file = File(filePath); + final record = BackupRecord( + id: 'setup-wizard', + filename: result.files.single.name, + timestamp: await file.lastModified(), + sizeBytes: await file.length(), + location: BackupLocation.local, + diveCount: 0, + siteCount: 0, + ); + if (!context.mounted) return; + + final mode = await RestoreConfirmationDialog.show( + context, + record, + currentSchemaVersion: AppDatabase.currentSchemaVersion, + ); + if (mode != null) { + await ref + .read(backupOperationProvider.notifier) + .restoreFromFilePath(filePath, mode: mode); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final l10n = context.l10n; + final operation = ref.watch(backupOperationProvider); + + ref.listen(backupOperationProvider, (prev, next) { + if (next.status == BackupOperationStatus.restoreComplete && + context.mounted) { + RestoreCompletePage.show(context); + } + }); + + final inProgress = operation.status == BackupOperationStatus.inProgress; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.setup_restore_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + if (inProgress) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(operation.message ?? l10n.setup_restore_inProgress), + ] else ...[ + if (operation.status == BackupOperationStatus.error && + operation.message != null) ...[ + Text( + operation.message!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + ], + FilledButton.icon( + onPressed: () => _pickAndRestore(context, ref), + icon: const Icon(Icons.folder_open), + label: Text(l10n.setup_restore_pick), + ), + ], + ], + ), + ), + ); + } +} +``` + +Check the actual `BackupRecord` import path/constructor before compiling (`rg -n "class BackupRecord" lib/features/backup`) — mirror `backup_settings_page.dart:208-254`, which is the canonical caller (including the `BackupLocation.local` enum import if it lives in a separate file, and `offerReplace:` which the wizard omits because a fresh install has no cloud provider yet). + +`lib/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Existing-data path: connect a provider, pull the library, land on the +/// dashboard once data (and its divers) have arrived. +class SyncConnectStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + /// Called when the connected account has no library (pivot to fresh). + final VoidCallback onNoLibrary; + + const SyncConnectStep({ + super.key, + required this.mode, + required this.onNoLibrary, + }); + + @override + ConsumerState createState() => _SyncConnectStepState(); +} + +enum _PullPhase { connect, pulling, done, empty } + +class _SyncConnectStepState extends ConsumerState { + _PullPhase _phase = _PullPhase.connect; + + Future _startPull() async { + setState(() => _phase = _PullPhase.pulling); + final connected = ref + .read(setupWizardProvider(widget.mode)) + .connectedProvider; + if (connected == null) { + setState(() => _phase = _PullPhase.connect); + return; + } + final instance = cloudProviderInstanceFor(connected); + final peers = await ref + .read(syncInitializerProvider) + .peerSyncFiles(instance); + if (peers.isEmpty) { + if (mounted) setState(() => _phase = _PullPhase.empty); + return; + } + await ref.read(syncStateProvider.notifier).performSync(); + await realignActiveDiverAfterDataReplace( + ref.read(sharedPreferencesProvider), + ); + final hasDivers = await ref.read(hasAnyDiversProvider.future); + if (mounted) { + setState(() => _phase = hasDivers ? _PullPhase.done : _PullPhase.empty); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final syncState = ref.watch(syncStateProvider); + + switch (_phase) { + case _PullPhase.connect: + // Reuse the provider cards; a successful connect starts the pull. + return Column( + children: [ + Expanded( + child: BackupSyncStep(mode: widget.mode), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: _ConnectedGate( + mode: widget.mode, + onContinue: _startPull, + ), + ), + ), + ], + ); + case _PullPhase.pulling: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(value: syncState.progress), + const SizedBox(height: 12), + Text(syncState.message ?? l10n.setup_syncPull_syncing), + ], + ), + ); + case _PullPhase.done: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, + size: 56, color: theme.colorScheme.primary), + const SizedBox(height: 12), + Text(l10n.setup_syncPull_success, + style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.go('/dashboard'), + child: Text(l10n.setup_syncPull_continue), + ), + ], + ), + ); + case _PullPhase.empty: + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.setup_syncPull_noLibrary_title, + style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + l10n.setup_syncPull_noLibrary_message, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: widget.onNoLibrary, + child: Text(l10n.setup_sync_libraryFound_keepFresh), + ), + ], + ), + ), + ); + } + } +} + +/// Continue button enabled once a provider is connected in the draft. +class _ConnectedGate extends ConsumerWidget { + final SetupWizardMode mode; + final VoidCallback onContinue; + + const _ConnectedGate({required this.mode, required this.onContinue}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final connected = ref.watch( + setupWizardProvider(mode).select((d) => d.connectedProvider != null), + ); + return FilledButton( + onPressed: connected ? onContinue : null, + child: Text(context.l10n.setup_syncPull_continue), + ); + } +} +``` + +Add the l10n key `setup_syncPull_continue` ("Continue") to Task 2's key set if not already present — it IS present in the list as `setup_syncPull_continue`? If missing, add it to all 11 arb files in this task and regenerate. (Check: Task 2 defines `setup_syncPull_*` keys; `setup_syncPull_continue` must exist — add "setup_syncPull_continue": "Continue" if the Task 2 block lacks it.) + +`lib/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/features/settings/presentation/providers/storage_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/main.dart' show restartApp; + +/// Points the app at a folder that already contains a Submersion database. +class OpenFolderStep extends ConsumerStatefulWidget { + const OpenFolderStep({super.key}); + + @override + ConsumerState createState() => _OpenFolderStepState(); +} + +class _OpenFolderStepState extends ConsumerState { + bool _busy = false; + String? _error; + + Future _pickFolder() async { + setState(() { + _busy = true; + _error = null; + }); + final l10n = context.l10n; + try { + final notifier = ref.read(storageConfigNotifierProvider.notifier); + final picked = await notifier.pickCustomFolder(); + if (picked == null) return; + + final existing = await notifier.checkForExistingDatabase(picked.path); + if (existing == null) { + setState(() => _error = l10n.setup_folder_notFound_message); + return; + } + + final result = await notifier.switchToExistingDatabase(picked.path); + if (result.success) { + // Full provider rebuild: the swapped database has divers, so the + // relaunch lands on the dashboard. + restartApp(); + } else { + setState(() => _error = result.error ?? ''); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.setup_folder_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + if (_busy) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(l10n.setup_folder_switching), + ] else ...[ + if (_error != null) ...[ + Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + ], + FilledButton.icon( + onPressed: _pickFolder, + icon: const Icon(Icons.folder_open), + label: Text(l10n.setup_folder_pick), + ), + ], + ], + ), + ), + ); + } +} +``` + +Verify `MigrationResult`'s error field name (`rg -n "class MigrationResult" -A 8 lib/core/services/database_migration_service.dart`) — use its actual field (e.g. `result.error` vs `result.message`). Verify `StorageConfigNotifier.pickCustomFolder`'s required params — `storage_settings_page.dart` passes `chooser: _chooseStorageVolume`; if `chooser` is required, port that page's `_chooseStorageVolume` dialog into this step as a private method (Android external-volume selection). + +- [ ] **Step 4: Wire the shell** + +In `setup_wizard_page.dart`: +1. Import the four new step files; delete `placeholder_step.dart` and its import. +2. `SetupStepId.existingChoice` builder → `ExistingChoiceStep(mode: mode, onChosen: () => WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _advance(); }))` (reuse `_chooseAndAdvance` by passing `() {}` as the mutation — the step already mutates; simplest is `onChosen: () => _chooseAndAdvance(() {})`). +3. `SetupStepId.restore` builder → `const RestoreStep()`. +4. `SetupStepId.syncConnect` builder → `SyncConnectStep(mode: mode, onNoLibrary: () { ref.read(setupWizardProvider(mode).notifier).choosePath(SetupPath.fresh); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _animateTo(1); }); })` — pivoting to the fresh path re-enters at the profile step (index 1 of the fresh list) with the provider still connected in the draft. +5. `SetupStepId.openFolder` builder → `const OpenFolderStep()`. +6. `SetupStepId.backupSync` builder gains the fresh-path pivot: `BackupSyncStep(mode: mode, onLibraryFound: () => _offerAdoptPivot())` where `_offerAdoptPivot` shows an AlertDialog (`setup_sync_libraryFound_title` / `_message`, actions `setup_sync_libraryFound_keepFresh` → pop, `setup_sync_libraryFound_adopt` → pop then `notifier.choosePath(SetupPath.existingData); notifier.chooseSource(ExistingDataSource.cloudSync);` and post-frame `_animateTo(computeSteps(...).length - 1)` to land on the sync-pull step). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/` +Expected: all PASS. + +- [ ] **Step 6: Format and commit** + +```bash +dart format . +git add lib/features/setup_wizard test/features/setup_wizard +git commit -m "feat(setup): existing-data path with restore, sync pull, and folder adopt" +``` + +--- + +### Task 12: Router swap, Settings entry, delete WelcomePage + +**Files:** +- Modify: `lib/core/router/app_router.dart` (welcome builder swap at ~line 172; new settings child route; imports at lines 18/77-100) +- Modify: `lib/features/settings/presentation/pages/settings_page.dart` (Setup assistant tile in the Manage section, after the tank-presets tile at ~line 1640) +- Delete: `lib/features/onboarding/presentation/pages/welcome_page.dart` (and the now-empty `lib/features/onboarding/` tree) +- Modify: all 11 `lib/l10n/arb/app_*.arb` (remove the 10 `onboarding_welcome_*` keys + the one `@onboarding_welcome_errorCreatingProfile` metadata block from each) + regenerate +- Test: `test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart` + +**Interfaces:** +- Consumes: `SetupWizardPage` (Task 6), `hasAnyDiversProvider`. +- Produces: `/welcome` renders the wizard; `/settings/setup-assistant` (name `setupAssistant`) renders re-entry mode; Settings Manage card gains the launcher tile. + +- [ ] **Step 1: Write the failing redirect test** + +`test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:submersion/core/router/app_router.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +void main() { + testWidgets('zero divers redirects initial route to the setup wizard', + (tester) async { + final overrides = await getBaseOverrides(); + final container = ProviderContainer(overrides: [ + ...overrides, + hasAnyDiversProvider.overrideWith((ref) async => false), + ]); + addTearDown(container.dispose); + final router = container.read(appRouterProvider); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: testAppRouter(router: router), + )); + await tester.pumpAndSettle(); + + expect(find.byType(SetupWizardPage), findsOneWidget); + expect(find.text('Welcome to Submersion'), findsOneWidget); + }); +} +``` + +Adjust `testAppRouter` usage if it wraps its own ProviderScope (it does — see helper): in that case skip `UncontrolledProviderScope` and instead pass the overrides list directly to `testAppRouter(router: ..., overrides: [...])`, reading the router from a container built with the SAME overrides is not possible then — so mirror `test/core/router/app_router_test.dart`'s established pattern for constructing the router with overrides, which that file already solves; copy its approach exactly. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart` +Expected: FAIL — `/welcome` still builds the old `WelcomePage`, so `find.byType(SetupWizardPage)` finds nothing. + +- [ ] **Step 3: Swap the router** + +In `lib/core/router/app_router.dart`: + +1. Replace the import at line 18: +```dart +// OLD +import 'package:submersion/features/onboarding/presentation/pages/welcome_page.dart'; +// NEW +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; +``` +2. Replace the `/welcome` builder: +```dart + GoRoute( + path: '/welcome', + name: 'welcome', + builder: (context, state) => + const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), +``` +3. Inside the `/settings` route's `routes: [...]` list (after the `backup` child at ~line 892), add: +```dart + GoRoute( + path: 'setup-assistant', + name: 'setupAssistant', + builder: (context, state) => + const SetupWizardPage(mode: SetupWizardMode.settings), + ), +``` + +- [ ] **Step 4: Add the Settings tile and delete the old page** + +In `settings_page.dart`, inside the Manage section's `Card > Column` (after the tank-presets `ListTile` + `Divider`), add: + +```dart + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.auto_fix_high), + title: Text(context.l10n.settings_manage_setupAssistant), + subtitle: Text( + context.l10n.settings_manage_setupAssistant_subtitle, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/setup-assistant'), + ), +``` + +Then: +```bash +git rm lib/features/onboarding/presentation/pages/welcome_page.dart +``` +Confirm nothing references it: `rg -n "WelcomePage|onboarding/presentation" lib test` → expected: no matches. + +- [ ] **Step 5: Remove the orphaned l10n keys** + +In each of the 11 `lib/l10n/arb/app_*.arb` files, delete the 10 `onboarding_welcome_*` entries and the `@onboarding_welcome_errorCreatingProfile` metadata block. Run `flutter gen-l10n`. Then `rg -n "onboarding_welcome" lib` → expected: no matches. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `flutter test test/features/setup_wizard/ test/core/router/` +Expected: all PASS (including the pre-existing router config tests, which override `hasAnyDiversProvider` to true and never touched WelcomePage). + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add lib/core/router lib/features/settings lib/l10n test/features/setup_wizard +git rm -q lib/features/onboarding/presentation/pages/welcome_page.dart 2>/dev/null || true +git commit -m "feat(setup): route first run and settings entry to the setup wizard" +``` + +--- + +### Task 13: Documentation updates + +**Files:** +- Modify: `docs/developer/navigation.md` (First-Run Guard section ~L395-407 + Onboarding route entry ~L329-334: `/welcome` now renders SetupWizardPage; add `/settings/setup-assistant`) +- Modify: `docs/ARCHITECTURE.md` (~L240) and `docs/developer/architecture.md` (~L160): replace `onboarding/ — First-run experience` with `setup_wizard/ — First-run setup wizard and Settings re-entry` +- Modify: `docs/guide/README.md` (~L33) and `docs/guide/first-dive.md` (~L7-10): describe the new first-run flow (fork; fresh path name/units/appearance/backup-sync/finish; existing-data restore/sync/folder options; everything skippable) +- Modify: `FEATURE_ROADMAP.md`: add a completed entry under the appropriate release section: `- [x] Setup wizard for new databases (units, appearance, backup/sync, restore/adopt paths) — discussion #523` + +**Interfaces:** none (prose only). + +- [ ] **Step 1: Apply the edits above** + +Keep each doc's existing voice and formatting; update code samples in navigation.md that show the redirect (the redirect logic itself is unchanged — only the page it lands on). Do not document placeholder internals; describe user-visible behavior. + +- [ ] **Step 2: Verify no stale references** + +Run: `rg -n "WelcomePage|name-only|onboarding/" docs FEATURE_ROADMAP.md | rg -v "setup_wizard|setup wizard"` +Expected: no hits describing the old flow as current (historical spec/plan docs under docs/superpowers/ are exempt — leave them). + +- [ ] **Step 3: Commit** + +```bash +git add docs FEATURE_ROADMAP.md +git commit -m "docs: describe the setup wizard first-run flow" +``` + +--- + +### Task 14: Full verification sweep + +**Files:** none new (fixes only, if the sweep finds issues). + +- [ ] **Step 1: Format check** + +Run: `dart format .` +Expected: `0 changed` (any change means a prior task missed formatting — commit the fix). + +- [ ] **Step 2: Whole-project analyze** + +Run: `flutter analyze` +Expected: `No issues found!` — run WITHOUT piping through head/tail (piping masks failures). Fix anything reported. + +- [ ] **Step 3: Targeted test sweep (never the whole suite)** + +Run, in order: +```bash +flutter test test/features/setup_wizard/ +flutter test test/features/import_wizard/ +flutter test test/core/router/ +flutter test test/features/settings/presentation/pages/settings_page_test.dart +flutter test test/features/settings/presentation/providers/settings_notifier_real_test.dart +flutter test test/features/divers/ +flutter test test/features/backup/ +``` +Expected: all PASS. The settings/backup/divers suites guard the engines the wizard now calls. + +- [ ] **Step 4: Desktop smoke run (manual, macOS)** + +Run: `flutter run -d macos` +Walk: fresh sandbox DB (or use the wizard from Settings → Manage → Setup assistant): fork renders → fresh path → name → units (imperial toggle) → appearance (dark + language preview) → backup toggle → finish → dashboard; confirm Settings → Units shows the chosen units. This validates the layout-bounds note in Task 6 on a real window. Report results; do not mark the task complete on test output alone. + +- [ ] **Step 5: Final commit (if fixes were made)** + +```bash +git add -u +git commit -m "chore: verification fixes for setup wizard" +``` + + + + + diff --git a/docs/superpowers/specs/2026-07-10-setup-wizard-design.md b/docs/superpowers/specs/2026-07-10-setup-wizard-design.md new file mode 100644 index 000000000..5be1c6d44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-setup-wizard-design.md @@ -0,0 +1,322 @@ +# Setup Wizard for New Databases — Design + +**Date:** 2026-07-10 +**Source:** [Discussion #523](https://github.com/submersion-app/submersion/discussions/523) +**Status:** Implemented (PR #544) + +## Revisions (post-implementation) + +- **Appearance step removed** from the fresh path per user feedback during + device testing. Theme, map style, and language stay in Settings. The fresh + path is now Profile → Units → Backup & Sync → Finish. The `previewLocale` + locale-preview mechanism (below) was removed with it — the sections + describing Appearance and locale preview are retained for history but no + longer reflect the shipped flow. +- **S3 config opens on the root navigator**, not `context.push`. During first + run the database has zero divers, so the router's onboarding redirect + bounces any go_router navigation back to `/welcome`; S3 config is a modal + action pushed above the wizard instead. +- **Every step past the fork is reversible.** The existing-data steps (choice, + restore, sync, open-folder) render a top back bar; the form-zone steps keep + their bottom-bar Back button. + +## Problem + +Submersion's first-run experience is a single name-only welcome page +(`lib/features/onboarding/presentation/pages/welcome_page.dart`). Every other +piece of configuration — units, sync, backups, appearance — is deferred to +scattered Settings pages that casual users may never discover. Two concrete +failures today: + +1. **US divers silently get metric.** Unit settings are per-diver + (`DiverSettings` table) and default to meters/celsius/bar. The welcome page + never asks. +2. **Returning users on a new device have no path.** A user with an existing + backup or cloud-synced library must create a throwaway diver profile just + to reach Settings → Data → restore/sync. Restore-before-profile is not + possible. + +"New database" is broader than fresh install: it also occurs on storage-folder +switch to an empty folder and after resets. All of these funnel through the +same zero-divers router gate. + +## Goals + +- Replace the name-only welcome page with a multi-step setup wizard that runs + whenever the database has zero divers. +- Offer an **existing-data path** (restore backup, connect cloud sync and + adopt, open an existing storage folder) *before* any profile is created. +- Configure the essentials on the fresh path: profile name, units, + appearance (theme, map style, language), backups, and optional cloud sync. +- End with a feature-discovery screen ("Submersion can also…"). +- Make the wizard re-runnable from Settings so existing users get the same + discoverability (with data-sensitive steps hidden). +- Every step after the profile name is skippable; a "skip setup" escape + reproduces today's minimal flow exactly. + +## Non-Goals + +- Interactive coach-marks/overlay tour of the real UI. +- Exposing Google Drive (stays hidden, matching `CloudSyncPage`). +- Deco/GF or diving-defaults configuration in the wizard (novice-hostile; + the 50/85 defaults stand). +- A trimmed wizard for creating *additional* divers. +- Prior-experience fields on the profile step (considered, rejected — stays + in Settings → Diver Profile). +- Persisting draft wizard state across app restarts. + +## User-Facing Flow + +One page, two modes. + +### First-run mode + +Trigger: existing zero-divers redirect in `app_router.dart` sends the user to +`/welcome`, which now renders `SetupWizardPage(mode: firstRun)`. + +``` +Welcome (fork) +├─ "Set up a new logbook" → FRESH PATH +│ 1. Profile name (required; sole mandatory input) +│ 2. Units Metric | Imperial preset + advanced per-unit fine-tune +│ 3. Backup&Sync scheduled-backup toggle (+ frequency); optional cloud +│ provider connect; cloud-copy toggle once connected +│ 4. Finish feature highlights → "Start logging" +│ └─ applies draft: create diver → set current → write settings +│ → navigate to /dashboard +│ +└─ "I have existing Submersion data" → EXISTING-DATA PATH (choose one) + a. Restore a backup file → pick file → confirm → restore → soft restart + b. Connect cloud sync → pick provider → connect → adopt library → + pull progress → "Adopted N dives" → done + c. Open existing folder → pick storage folder → use existing database + → swap + restart +``` + +Flow rules: + +- **Fork first, profile later.** Each existing-data option ends with divers + present, so the wizard exits without creating a profile. Restore and + folder-adopt exit via the existing soft restart (`restartApp()` / + `SubmersionRestart`); sync-adopt exits when the user acknowledges the + completion screen. +- **Draft-and-apply.** Nothing is written until Finish (see Architecture). + Abandoning the wizard mid-flow leaves the database untouched; next launch + starts the wizard fresh. +- **Skippable steps.** Units, Appearance, and Backup & Sync each have a Skip + affordance; skipping accepts the draft defaults. A "Skip setup" escape on + the fork step asks only for the name, then applies all defaults — + byte-for-byte today's outcome. +- **Locale-aware unit preset.** The Units step preselects Imperial when the + device locale is US, Liberia, or Myanmar; Metric otherwise. The user + confirms rather than configures. The advanced expander exposes the same + seven unit preferences plus time/date format that Settings → Units has, + driving the existing `UnitPreset {metric, imperial, custom}` semantics. +- **Cross-path pivots.** If a fresh-path user connects a provider that already + holds a library, offer "Adopt the existing library instead?" (jumps to path + b semantics, discarding the draft). If an existing-data user's provider has + no library, offer "Start fresh instead?" and continue the fresh path with + the provider already connected. No dead ends. +- **Finish screen.** Feature highlights with tap-to-go links: dive computer + download, file import, statistics, dive sites map, gear service tracking. + Links navigate after apply completes, so the app is fully initialized. + +### Re-entry mode + +Trigger: a "Setup assistant" tile in Settings pushes +`/settings/setup-assistant` → `SetupWizardPage(mode: settings)`. + +- Hidden: fork, existing-data steps, Profile step (name editing lives in + Diver Profile). +- Shown: Units, Appearance, Backup & Sync, Finish. +- The draft seeds from the **current diver's live settings** instead of + defaults; apply updates that diver's `DiverSettings` row (no diver + creation). +- If a sync provider is already connected, the Backup & Sync step shows + connection status instead of connect prompts. + +## Architecture + +### Feature module + +``` +lib/features/setup_wizard/ +├── presentation/ +│ ├── pages/setup_wizard_page.dart # shell: PageController + step list +│ ├── providers/setup_wizard_providers.dart # SetupWizardNotifier (draft state) +│ └── widgets/steps/ +│ welcome_fork_step.dart, profile_step.dart, units_step.dart, +│ appearance_step.dart, backup_sync_step.dart, finish_step.dart, +│ restore_step.dart, sync_connect_step.dart, open_folder_step.dart +``` + +`lib/features/onboarding/` (the old `WelcomePage`) is deleted; docs that +reference it are updated. + +### Shared wizard primitives (the one refactor) + +`WizardStepDef` (`import_wizard/domain/models/wizard_step_def.dart`) and +`WizardStepIndicator` (`import_wizard/presentation/widgets/`) move to +`lib/shared/widgets/wizard/`. The import wizard updates imports only; its +shell (`UnifiedImportWizard`) is untouched. The setup wizard builds its own +small shell because it needs what the import shell does not model: branching +at the fork, mode-dependent step lists, and no hardcoded +Review → Import → Summary tail. + +### State: draft-and-apply + +`SetupWizardNotifier` holds a pure-Dart draft: + +- `mode` (firstRun | settings), `path` (fresh | existingData | undecided) +- `name` +- draft `AppSettings` — seeded from `const AppSettings()` in first-run mode, + from the live `settingsProvider` value in re-entry mode +- backup schedule choice (enabled + `BackupFrequency`) + +Apply order on Finish (first-run): + +1. `diverListNotifier.addDiver(...)` — create the diver (`isDefault: true`). + `DiverRepository.createDiver` also seeds a defaults `DiverSettings` row. +2. Overwrite that row with the draft via + `DiverSettingsRepository.updateSettingsForDiver(id, draft)` — BEFORE the + diver becomes current. Switching the current diver fires an unawaited + `SettingsNotifier` reload that would race (and clobber) any post-switch + settings writes; writing first means the reload simply loads the drafted + row. No new `SettingsNotifier` API is needed, so no settings mocks change. +3. `setCurrentDiver(id)` — existing dual-write to SharedPreferences + the + `Settings` table; the notifier reload now picks up the drafted settings. +4. Apply backup choices via `backupSettingsProvider`: schedule (`setEnabled`, + `setFrequency`) and, when a cloud provider was connected in the wizard and + the user opted in, `cloudBackupEnabled`. These are device-level + SharedPreferences-backed values (`BackupPreferences`), not per-diver rows. + +**Live actions vs. draft settings.** External handshakes cannot be drafted: +sync provider connect (OAuth, iCloud availability, S3 validation), restore, +adopt, and folder swap act immediately when the user confirms them, exactly +as their Settings counterparts do. Everything else (name, units, appearance, +backup schedule) stays in the draft until Finish. + +### Existing-data path: purpose-built UI over existing engines + +| Wizard step | Reused engine | Exit | +|---|---|---| +| Restore backup | backup/restore services behind `BackupSettingsPage` (`backupOperationProvider`, restore confirmation semantics) | soft restart → divers exist → gate passes | +| Connect sync | provider connect logic from `CloudSyncPage` (iCloud availability, Dropbox OAuth, S3 config validation); adopt + pull via existing sync machinery | pull completes → user acknowledges → wizard navigates | +| Open existing folder | `DatabaseLocationService` + the `ExistingDatabaseChoice.useExisting` path from `ExistingDatabaseDialog` | database swap + restart | + +The wizard re-skins presentation (wizard-native cards/forms) but calls the +same services, so config logic keeps one source of truth. + +### Routing + +- `/welcome` keeps its route name and its zero-divers redirect, now building + `SetupWizardPage(mode: firstRun)`. +- New route `/settings/setup-assistant` builds + `SetupWizardPage(mode: settings)`; a Settings tile launches it. +- **No redirect race exists.** The router has no `refreshListenable`; the + redirect only evaluates on navigation events and reads + `hasAnyDiversProvider.future` fresh each time (`app_router.dart`). + Divers appearing mid-wizard (sync adopt) therefore cannot yank the user + off the wizard; the wizard exits by navigating itself, at which point the + gate re-evaluates and passes. `allDiversProvider` self-invalidates from + `watchDiversChanges()`, so the value is fresh by then. + +### Locale preview + +Draft-and-apply would leave the wizard rendering in the old language after +the user picks a new one. A small `previewLocaleProvider` (in the setup +wizard's providers) is consulted by the app's existing `_resolveLocale` +(`app.dart`): when set, it overrides the settings locale for rendering. +Cleared/persisted at Finish. First-run mode has no diver row, so this is the +only way a language choice can take effect mid-wizard. + +### Layout + +Full-screen steps on mobile; centered constrained-width card over +`OceanBackground` on desktop (macOS/Windows/Linux) — the same visual family +as the splash and the old welcome page. `WizardStepIndicator` heads the fresh +path; the fork and existing-data screens are indicator-free (they are choices, +not progress). All layouts must be direction-agnostic for RTL locales +(ar, he). + +## Edge Cases and Error Handling + +Principle: no step dead-ends, no partial state. + +- **Restore fails** → inline error with retry / back-to-fork. The database is + untouched on failure (existing restore-service guarantees). Same for + folder-adopt. +- **Provider connect fails** → inline error on the provider card, retry or + pick another provider; never blocks Back. +- **Sync connects, no library found** (existing-data path) → offer "Start + fresh instead?" → continues into the fresh path with the provider already + connected. +- **Sync connects, library found** (fresh path) → offer "Adopt the existing + library instead?" → switches to the adopt flow; draft is discarded. +- **App killed mid-wizard** → draft was in-memory; next launch lands back at + the wizard start. Acceptable for a two-minute flow. +- **Platform gating** → provider cards reuse existing gates: iCloud only on + Apple platforms and hidden in Developer ID builds; Google Drive hidden + everywhere; S3/Dropbox on all platforms. +- **Migration interplay** → none: `StartupWrapper` completes migrations before + the router renders anything, and the redirect skips while + `DatabaseService.instance.isMigrating`. +- **Re-entry with sync configured** → Backup & Sync step becomes a status + view (connected provider, last backup) with a link to Settings → Data. + +## Testing + +Per the repo's 80% coverage bar: + +- **Unit — `SetupWizardNotifier`:** step-list computation per mode and path + (first-run shows fork; settings mode hides fork/profile; existing-data path + replaces fresh steps), draft mutations, apply ordering + (create → select → settings → backup), locale→preset detection + (`en_US` → Imperial, `de_DE` → Metric). +- **Widget — steps in isolation:** profile name validation (empty name blocks + advance), units preset toggle drives the advanced fields, appearance + choices update the draft, skip affordances advance without mutating the + draft, fork branching renders the right next step. +- **Widget — shell:** next/back navigation across the branch, "Skip setup" + fast-path, re-entry mode step visibility. +- **Integration-style (in-memory Drift):** Finish produces exactly one diver + (`isDefault: true`, current) and a `DiverSettings` row matching the draft — + e.g. Imperial preset writes all seven unit columns; backup schedule lands in + backup settings. +- **Regression:** import wizard tests pass untouched after the + `WizardStepDef`/`WizardStepIndicator` move; router tests confirm the + zero-divers redirect still lands on the wizard and that completing the + wizard reaches `/dashboard`. +- Known widget-test traps apply (theme animation duration zero; wrap + post-pump Drift awaits in `tester.runAsync`; `FormSection` uppercases + labels). + +## Localization + +- All new strings in `lib/l10n/arb/app_en.arb` plus the 10 other locales, + regenerated. Estimate 60–80 keys under a `setup_` prefix. +- The ~10 `onboarding_welcome_*` keys are removed with the old welcome page. +- RTL: no special-casing beyond Material defaults, but step indicator and + fork cards must not hardcode left/right. + +## Documentation + +- `docs/developer/navigation.md` — first-run guard section and route table. +- `docs/guide/first-dive.md` + user-guide wiki source — new first-run flow. +- `docs/ARCHITECTURE.md` / `docs/developer/architecture.md` — feature list + (onboarding → setup_wizard). +- `FEATURE_ROADMAP.md` — entry for the setup wizard. + +## Resolved Questions (verified in code) + +- **Map style is a real setting:** `MapStyle {openStreetMap, openTopoMap, + esriSatellite}` on `AppSettings.mapStyle` with an existing notifier setter — + included in the Appearance step. +- **Auto-backup exists:** `backupSettingsProvider` with `setEnabled` / + `setFrequency(BackupFrequency)` and a `cloudBackupEnabled` flag — the + Backup & Sync step drives it. `cloudBackupEnabled` (SharedPreferences, + default false) only takes effect when a cloud provider is configured + (`cloudStorageProviderProvider`), so the wizard surfaces the toggle only + after a provider connects — mirroring `BackupSettingsPage`. +- **No router refresh race:** redirect is navigation-driven only; wizard + controls its own exit (see Routing). diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index d1c688cf6..220edc4ec 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -15,7 +15,8 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_provide import 'package:submersion/features/import_wizard/data/adapters/healthkit_adapter.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/import_wizard/presentation/pages/unified_import_wizard.dart'; -import 'package:submersion/features/onboarding/presentation/pages/welcome_page.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; import 'package:submersion/features/buddies/presentation/pages/buddy_detail_page.dart'; import 'package:submersion/features/buddies/presentation/pages/buddy_edit_page.dart'; import 'package:submersion/features/buddies/presentation/pages/buddy_merge_page.dart'; @@ -173,7 +174,8 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/welcome', name: 'welcome', - builder: (context, state) => const WelcomePage(), + builder: (context, state) => + const SetupWizardPage(mode: SetupWizardMode.firstRun), ), ShellRoute( builder: (context, state, child) => CallbackShortcuts( @@ -894,6 +896,12 @@ final appRouterProvider = Provider((ref) { name: 'backupSettings', builder: (context, state) => const BackupSettingsPage(), ), + GoRoute( + path: 'setup-assistant', + name: 'setupAssistant', + builder: (context, state) => + const SetupWizardPage(mode: SetupWizardMode.settings), + ), GoRoute( path: 'cloud-sync', name: 'cloudSync', diff --git a/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart b/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart index 333dee14d..d545d2f26 100644 --- a/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart +++ b/lib/features/import_wizard/data/adapters/dive_computer_adapter.dart @@ -25,7 +25,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_cancellat import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/dc_adapter_steps.dart'; // --------------------------------------------------------------------------- diff --git a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart index e99722e98..1bcba48cf 100644 --- a/lib/features/import_wizard/data/adapters/healthkit_adapter.dart +++ b/lib/features/import_wizard/data/adapters/healthkit_adapter.dart @@ -16,7 +16,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_cancellat import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/healthkit_adapter_steps.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; diff --git a/lib/features/import_wizard/data/adapters/universal_adapter.dart b/lib/features/import_wizard/data/adapters/universal_adapter.dart index c631e8234..4bb7dd9fd 100644 --- a/lib/features/import_wizard/data/adapters/universal_adapter.dart +++ b/lib/features/import_wizard/data/adapters/universal_adapter.dart @@ -37,8 +37,8 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da as wizard show ImportEntityType; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/tank_presets/domain/services/default_tank_preset_resolver.dart'; diff --git a/lib/features/import_wizard/domain/adapters/import_source_adapter.dart b/lib/features/import_wizard/domain/adapters/import_source_adapter.dart index 63f8a37e9..3caa48573 100644 --- a/lib/features/import_wizard/domain/adapters/import_source_adapter.dart +++ b/lib/features/import_wizard/domain/adapters/import_source_adapter.dart @@ -3,7 +3,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; /// Interface for source-specific import adapters. /// diff --git a/lib/features/import_wizard/domain/services/step_skip_calculator.dart b/lib/features/import_wizard/domain/services/step_skip_calculator.dart index 1153e2676..59f57c716 100644 --- a/lib/features/import_wizard/domain/services/step_skip_calculator.dart +++ b/lib/features/import_wizard/domain/services/step_skip_calculator.dart @@ -1,4 +1,4 @@ -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; /// Calculates the next page index by skipping steps whose auto-advance /// condition is already met. diff --git a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart index c0dd5720f..588c119f1 100644 --- a/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart +++ b/lib/features/import_wizard/presentation/pages/unified_import_wizard.dart @@ -17,7 +17,7 @@ import 'package:submersion/features/import_wizard/data/adapters/dive_computer_ad import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart'; import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart'; import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/domain/services/step_skip_calculator.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; @@ -25,7 +25,7 @@ import 'package:submersion/features/trips/presentation/providers/trip_providers. import 'package:submersion/features/import_wizard/presentation/widgets/import_progress_step.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/import_summary_step.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/review_step.dart'; -import 'package:submersion/features/import_wizard/presentation/widgets/wizard_step_indicator.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_indicator.dart'; /// The unified import wizard shell. /// diff --git a/lib/features/onboarding/presentation/pages/welcome_page.dart b/lib/features/onboarding/presentation/pages/welcome_page.dart deleted file mode 100644 index 3797d5b5f..000000000 --- a/lib/features/onboarding/presentation/pages/welcome_page.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:submersion/core/providers/provider.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:submersion/core/presentation/widgets/ocean_background.dart'; -import 'package:submersion/features/divers/domain/entities/diver.dart'; -import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; -import 'package:submersion/l10n/l10n_extension.dart'; - -class WelcomePage extends ConsumerStatefulWidget { - const WelcomePage({super.key}); - - @override - ConsumerState createState() => _WelcomePageState(); -} - -class _WelcomePageState extends ConsumerState { - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - bool _isSaving = false; - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - Future _createProfile() async { - if (!_formKey.currentState!.validate()) return; - - setState(() => _isSaving = true); - - try { - final now = DateTime.now(); - final diver = Diver( - id: '', - name: _nameController.text.trim(), - emergencyContact: const EmergencyContact(), - insurance: const DiverInsurance(), - medicalNotes: '', - notes: '', - isDefault: true, - createdAt: now, - updatedAt: now, - ); - - final newDiver = await ref - .read(diverListNotifierProvider.notifier) - .addDiver(diver); - - // Set as current diver - await ref - .read(currentDiverIdProvider.notifier) - .setCurrentDiver(newDiver.id); - - if (mounted) { - // Navigate to main app - context.go('/dives'); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.onboarding_welcome_errorCreatingProfile( - e.toString(), - ), - ), - ), - ); - setState(() => _isSaving = false); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - body: OceanBackground( - child: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // App icon/logo - ExcludeSemantics( - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Image.asset( - 'assets/icon/icon.png', - width: 120, - height: 120, - ), - ), - ), - const SizedBox(height: 32), - - // Welcome text - Text( - context.l10n.onboarding_welcome_title, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - - Text( - context.l10n.onboarding_welcome_subtitle, - style: theme.textTheme.bodyLarge?.copyWith( - color: Colors.white.withValues(alpha: 0.8), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - - // Profile creation card - Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - context.l10n.onboarding_welcome_createProfile, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - context - .l10n - .onboarding_welcome_createProfileSubtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 24), - TextFormField( - controller: _nameController, - decoration: InputDecoration( - labelText: - context.l10n.onboarding_welcome_nameLabel, - prefixIcon: const Icon(Icons.person), - hintText: - context.l10n.onboarding_welcome_nameHint, - ), - textCapitalization: TextCapitalization.words, - autofocus: true, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _createProfile(), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return context - .l10n - .onboarding_welcome_nameValidation; - } - return null; - }, - ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _isSaving ? null : _createProfile, - icon: _isSaving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.arrow_forward), - label: Text( - _isSaving - ? context.l10n.onboarding_welcome_creating - : context - .l10n - .onboarding_welcome_getStarted, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 52b13b1d5..2eb38279a 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1653,6 +1653,16 @@ class _ManageSectionContent extends StatelessWidget { onTap: () => context.push('/tank-presets'), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.auto_fix_high), + title: Text(context.l10n.settings_manage_setupAssistant), + subtitle: Text( + context.l10n.settings_manage_setupAssistant_subtitle, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/setup-assistant'), + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.checklist), title: Text(context.l10n.settings_manage_checklistTemplates), diff --git a/lib/features/setup_wizard/data/setup_apply_service.dart b/lib/features/setup_wizard/data/setup_apply_service.dart new file mode 100644 index 000000000..5b04b7de9 --- /dev/null +++ b/lib/features/setup_wizard/data/setup_apply_service.dart @@ -0,0 +1,90 @@ +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +final setupApplyServiceProvider = Provider( + (ref) => SetupApplyService(ref), +); + +/// Persists a completed wizard draft. +/// +/// First-run ordering is load-bearing: createDiver seeds a defaults +/// settings row, which is overwritten with the draft BEFORE the diver +/// becomes current, because switching the current diver triggers an +/// unawaited SettingsNotifier reload that would otherwise race (and +/// clobber) any post-switch settings writes. +class SetupApplyService { + final Ref _ref; + + SetupApplyService(this._ref); + + Future applyFirstRun(SetupWizardDraft draft) async { + final name = draft.name.trim(); + if (name.isEmpty) { + throw ArgumentError('Diver name must not be empty'); + } + + final now = DateTime.now(); + final newDiver = await _ref + .read(diverListNotifierProvider.notifier) + .addDiver( + Diver( + id: '', + name: name, + isDefault: true, + createdAt: now, + updatedAt: now, + ), + ); + + // Overwrite the defaults row createDiver just made with the draft, + // BEFORE the switch (see class doc). + await _ref + .read(diverSettingsRepositoryProvider) + .updateSettingsForDiver(newDiver.id, draft.settings); + + await _ref + .read(currentDiverIdProvider.notifier) + .setCurrentDiver(newDiver.id); + + await _applyBackupChoices(draft); + } + + Future applySettingsMode(SetupWizardDraft draft) async { + // Re-entry: the current diver's settings row exists and the + // SettingsNotifier is loaded, so the existing setters are race-free. + final notifier = _ref.read(settingsProvider.notifier); + final s = draft.settings; + await notifier.setDepthUnit(s.depthUnit); + await notifier.setTemperatureUnit(s.temperatureUnit); + await notifier.setPressureUnit(s.pressureUnit); + await notifier.setVolumeUnit(s.volumeUnit); + await notifier.setWeightUnit(s.weightUnit); + await notifier.setAltitudeUnit(s.altitudeUnit); + await notifier.setSacUnit(s.sacUnit); + await notifier.setTimeFormat(s.timeFormat); + await notifier.setDateFormat(s.dateFormat); + await notifier.setThemeMode(s.themeMode); + await notifier.setThemePresetId(s.themePresetId); + await notifier.setMapStyle(s.mapStyle); + await notifier.setLocale(s.locale); + + await _applyBackupChoices(draft); + } + + Future _applyBackupChoices(SetupWizardDraft draft) async { + final backup = _ref.read(backupSettingsProvider.notifier); + await backup.setEnabled(draft.backupEnabled); + if (draft.backupEnabled) { + await backup.setFrequency(draft.backupFrequency); + } + // Apply the cloud-copy choice in both directions so re-entry can turn it + // off. The flag is inert without a configured provider, so writing it is + // safe even when none is connected. + await backup.setCloudBackupEnabled(draft.cloudBackupEnabled); + } +} diff --git a/lib/features/setup_wizard/domain/setup_wizard_models.dart b/lib/features/setup_wizard/domain/setup_wizard_models.dart new file mode 100644 index 000000000..087d295aa --- /dev/null +++ b/lib/features/setup_wizard/domain/setup_wizard_models.dart @@ -0,0 +1,155 @@ +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Whether the wizard runs at first launch or from Settings. +enum SetupWizardMode { firstRun, settings } + +/// Which top-level branch the user chose on the fork step. +enum SetupPath { undecided, fresh, existingData } + +/// Which existing-data source the user chose. +enum ExistingDataSource { none, restoreBackup, cloudSync, openFolder } + +/// Identity of every wizard step; the shell maps these to widgets. +enum SetupStepId { + welcomeFork, + profile, + units, + backupSync, + finish, + existingChoice, + restore, + syncConnect, + openFolder, +} + +/// Immutable wizard draft. Nothing is persisted until Finish applies it. +class SetupWizardDraft { + final SetupWizardMode mode; + final SetupPath path; + final ExistingDataSource source; + final bool skipRequested; + final String name; + final AppSettings settings; + final bool backupEnabled; + final BackupFrequency backupFrequency; + final bool cloudBackupEnabled; + final CloudProviderType? connectedProvider; + + const SetupWizardDraft({ + required this.mode, + this.path = SetupPath.undecided, + this.source = ExistingDataSource.none, + this.skipRequested = false, + this.name = '', + this.settings = const AppSettings(), + this.backupEnabled = false, + this.backupFrequency = BackupFrequency.weekly, + this.cloudBackupEnabled = false, + this.connectedProvider, + }); + + SetupWizardDraft copyWith({ + SetupPath? path, + ExistingDataSource? source, + bool? skipRequested, + String? name, + AppSettings? settings, + bool? backupEnabled, + BackupFrequency? backupFrequency, + bool? cloudBackupEnabled, + CloudProviderType? connectedProvider, + bool clearConnectedProvider = false, + }) { + return SetupWizardDraft( + mode: mode, + path: path ?? this.path, + source: source ?? this.source, + skipRequested: skipRequested ?? this.skipRequested, + name: name ?? this.name, + settings: settings ?? this.settings, + backupEnabled: backupEnabled ?? this.backupEnabled, + backupFrequency: backupFrequency ?? this.backupFrequency, + cloudBackupEnabled: cloudBackupEnabled ?? this.cloudBackupEnabled, + connectedProvider: clearConnectedProvider + ? null + : (connectedProvider ?? this.connectedProvider), + ); + } + + /// Returns a copy with the six core units set to [preset]. + /// [UnitPreset.custom] returns this draft unchanged. + SetupWizardDraft applyingUnitPreset(UnitPreset preset) { + switch (preset) { + case UnitPreset.metric: + return copyWith( + settings: settings.copyWith( + depthUnit: DepthUnit.meters, + temperatureUnit: TemperatureUnit.celsius, + pressureUnit: PressureUnit.bar, + volumeUnit: VolumeUnit.liters, + weightUnit: WeightUnit.kilograms, + altitudeUnit: AltitudeUnit.meters, + ), + ); + case UnitPreset.imperial: + return copyWith( + settings: settings.copyWith( + depthUnit: DepthUnit.feet, + temperatureUnit: TemperatureUnit.fahrenheit, + pressureUnit: PressureUnit.psi, + volumeUnit: VolumeUnit.cubicFeet, + weightUnit: WeightUnit.pounds, + altitudeUnit: AltitudeUnit.feet, + ), + ); + case UnitPreset.custom: + return this; + } + } +} + +/// Computes the ordered step list for the current draft state. +List computeSteps(SetupWizardDraft draft) { + if (draft.mode == SetupWizardMode.settings) { + return const [ + SetupStepId.units, + SetupStepId.backupSync, + SetupStepId.finish, + ]; + } + switch (draft.path) { + case SetupPath.undecided: + return const [SetupStepId.welcomeFork]; + case SetupPath.fresh: + if (draft.skipRequested) { + return const [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]; + } + return const [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.units, + SetupStepId.backupSync, + SetupStepId.finish, + ]; + case SetupPath.existingData: + final steps = [SetupStepId.welcomeFork, SetupStepId.existingChoice]; + switch (draft.source) { + case ExistingDataSource.none: + break; + case ExistingDataSource.restoreBackup: + steps.add(SetupStepId.restore); + case ExistingDataSource.cloudSync: + steps.add(SetupStepId.syncConnect); + case ExistingDataSource.openFolder: + steps.add(SetupStepId.openFolder); + } + return steps; + } +} diff --git a/lib/features/setup_wizard/domain/unit_preset_detector.dart b/lib/features/setup_wizard/domain/unit_preset_detector.dart new file mode 100644 index 000000000..67356c310 --- /dev/null +++ b/lib/features/setup_wizard/domain/unit_preset_detector.dart @@ -0,0 +1,15 @@ +import 'dart:ui'; + +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; + +/// Countries using imperial-style units for everyday measurement. +const _imperialCountries = {'US', 'LR', 'MM'}; + +/// Maps a device locale to the unit preset the wizard preselects. +UnitPreset presetForLocale(Locale locale) { + final country = locale.countryCode?.toUpperCase(); + if (country != null && _imperialCountries.contains(country)) { + return UnitPreset.imperial; + } + return UnitPreset.metric; +} diff --git a/lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart b/lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart new file mode 100644 index 000000000..6b4130216 --- /dev/null +++ b/lib/features/setup_wizard/presentation/pages/setup_wizard_page.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/presentation/widgets/ocean_background.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/finish_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/restore_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/profile_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/units_step.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_indicator.dart'; + +/// Multi-step setup wizard for new databases (first run) and Settings +/// re-entry. See docs/superpowers/specs/2026-07-10-setup-wizard-design.md. +class SetupWizardPage extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const SetupWizardPage({super.key, required this.mode}); + + @override + ConsumerState createState() => _SetupWizardPageState(); +} + +/// Steps that show the progress indicator and the shared Next/Back bar. +const _formZone = { + SetupStepId.profile, + SetupStepId.units, + SetupStepId.backupSync, +}; + +class _SetupWizardPageState extends ConsumerState { + final _pageController = PageController(); + int _currentIndex = 0; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + Future _animateTo(int index) async { + if (_pageController.hasClients) { + await _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + if (mounted) setState(() => _currentIndex = index); + } + + void _advance() { + final steps = computeSteps(ref.read(setupWizardProvider(widget.mode))); + if (_currentIndex < steps.length - 1) { + _animateTo(_currentIndex + 1); + } + } + + void _back() { + if (_currentIndex > 0) _animateTo(_currentIndex - 1); + } + + /// Choice steps mutate the draft (growing the step list), then advance + /// after the rebuild that adds the next page. + void _chooseAndAdvance(void Function() mutate) { + mutate(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _advance(); + }); + } + + String _stepLabel(SetupStepId id) { + final l10n = context.l10n; + switch (id) { + case SetupStepId.profile: + return l10n.setup_step_profile; + case SetupStepId.units: + return l10n.setup_step_units; + case SetupStepId.backupSync: + return l10n.setup_step_backup; + case SetupStepId.finish: + return l10n.setup_step_finish; + default: + return ''; + } + } + + WizardStepDef _defFor(SetupStepId id) { + final mode = widget.mode; + final notifier = ref.read(setupWizardProvider(mode).notifier); + switch (id) { + case SetupStepId.welcomeFork: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => WelcomeForkStep( + onStartFresh: () => + _chooseAndAdvance(() => notifier.choosePath(SetupPath.fresh)), + onExistingData: () => _chooseAndAdvance( + () => notifier.choosePath(SetupPath.existingData), + ), + onSkipSetup: () => _chooseAndAdvance(notifier.requestSkip), + ), + ); + case SetupStepId.profile: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider( + mode, + ).select((d) => d.name.trim().isNotEmpty), + builder: (_) => ProfileStep(mode: mode), + ); + case SetupStepId.units: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => UnitsStep(mode: mode), + ); + case SetupStepId.backupSync: + return WizardStepDef( + label: _stepLabel(id), + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => + BackupSyncStep(mode: mode, onLibraryFound: _offerAdoptPivot), + ); + case SetupStepId.finish: + return WizardStepDef( + label: _stepLabel(id), + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => FinishStep(mode: mode), + ); + case SetupStepId.existingChoice: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => ExistingChoiceStep( + mode: mode, + onChosen: () => _chooseAndAdvance(() {}), + ), + ); + case SetupStepId.restore: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const RestoreStep(), + ); + case SetupStepId.syncConnect: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => SyncConnectStep( + mode: mode, + onNoLibrary: () { + notifier.choosePath(SetupPath.fresh); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _animateTo(1); + }); + }, + ), + ); + case SetupStepId.openFolder: + return WizardStepDef( + label: '', + hideBottomBar: true, + canAdvance: setupWizardProvider(mode).select((_) => true), + builder: (_) => const OpenFolderStep(), + ); + } + } + + /// Fresh-path pivot: a just-connected provider already holds a library. + Future _offerAdoptPivot() async { + final l10n = context.l10n; + final adopt = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.setup_sync_libraryFound_title), + content: Text(l10n.setup_sync_libraryFound_message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.setup_sync_libraryFound_keepFresh), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.setup_sync_libraryFound_adopt), + ), + ], + ), + ); + if (adopt != true || !mounted) return; + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + notifier.choosePath(SetupPath.existingData); + notifier.chooseSource(ExistingDataSource.cloudSync); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final steps = computeSteps(ref.read(setupWizardProvider(widget.mode))); + _animateTo(steps.length - 1); + }); + } + + SetupStepId _currentStepId() { + final steps = computeSteps(ref.read(setupWizardProvider(widget.mode))); + return steps[_currentIndex.clamp(0, steps.length - 1)]; + } + + int _stepCount() { + return computeSteps(ref.read(setupWizardProvider(widget.mode))).length; + } + + bool _skippable(SetupStepId id) => id != SetupStepId.profile; + + @override + Widget build(BuildContext context) { + final draft = ref.watch(setupWizardProvider(widget.mode)); + final steps = computeSteps(draft); + if (_currentIndex >= steps.length) { + _currentIndex = steps.length - 1; + // The step list shrank under us (e.g. the adopt pivot from the fresh + // path to the shorter existing-data path). Resync the controller + // post-frame so the PageView can't sit on an out-of-range page. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _pageController.hasClients) { + _pageController.jumpToPage(_currentIndex); + } + }); + } + final defs = steps.map(_defFor).toList(); + final currentId = steps[_currentIndex]; + final inFormZone = _formZone.contains(currentId); + + // Indicator covers the contiguous labelled tail of the flow (profile or + // units onward, plus finish); choice steps stay indicator-free. + final labelledIds = steps + .where((s) => _formZone.contains(s) || s == SetupStepId.finish) + .toList(); + final labelledIndex = labelledIds.indexOf(currentId); + + return Scaffold( + body: OceanBackground( + child: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SizedBox.expand( + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + if (labelledIndex >= 0) + WizardStepIndicator( + labels: labelledIds.map(_stepLabel).toList(), + currentStep: labelledIndex, + ) + // Choice/existing-data steps have no bottom bar, so a + // slim top back bar keeps every step past the fork + // reversible. + else if (_currentIndex > 0) + _buildBackBar(), + // Keyed so the PageView survives the indicator and + // bottom bar toggling in and out of the Column (a + // slot shift would otherwise remount it at page 0). + Expanded( + key: const ValueKey('setup_wizard_pages'), + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (final def in defs) + Builder(builder: def.builder), + ], + ), + ), + if (inFormZone) _buildBottomBar(defs[_currentIndex]), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildBackBar() { + return Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: context.l10n.setup_common_back, + onPressed: _back, + ), + ), + ); + } + + Widget _buildBottomBar(WizardStepDef def) { + final l10n = context.l10n; + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + if (_currentIndex > 0) + OutlinedButton( + onPressed: _back, + child: Text(l10n.setup_common_back), + ), + const Spacer(), + if (_currentIndex < _stepCount() - 1 && + _skippable(_currentStepId())) + TextButton( + onPressed: _advance, + child: Text(l10n.setup_common_skip), + ), + const SizedBox(width: 8), + _NextButton(def: def, onNext: _advance), + ], + ), + ), + ); + } +} + +class _NextButton extends ConsumerWidget { + final WizardStepDef def; + final VoidCallback onNext; + + const _NextButton({required this.def, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canAdvance = ref.watch(def.canAdvance); + return FilledButton( + style: FilledButton.styleFrom(minimumSize: const Size(100, 44)), + onPressed: canAdvance ? onNext : null, + child: Text(context.l10n.setup_common_next), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/providers/setup_wizard_providers.dart b/lib/features/setup_wizard/presentation/providers/setup_wizard_providers.dart new file mode 100644 index 000000000..6273300d5 --- /dev/null +++ b/lib/features/setup_wizard/presentation/providers/setup_wizard_providers.dart @@ -0,0 +1,65 @@ +import 'package:submersion/core/providers/provider.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +/// Draft state for one wizard session, keyed by mode. +final setupWizardProvider = StateNotifierProvider.autoDispose + .family( + (ref, mode) => SetupWizardNotifier(ref, mode), + ); + +class SetupWizardNotifier extends StateNotifier { + final Ref _ref; + + SetupWizardNotifier(this._ref, SetupWizardMode mode) + : super(SetupWizardDraft(mode: mode)) { + if (mode == SetupWizardMode.settings) { + // Seed the draft from live state so re-entry edits start from what the + // diver already has -- and so pressing Finish without touching a step + // re-applies the current values instead of resetting them to defaults + // (e.g. disabling an existing backup schedule). + final backup = _ref.read(backupSettingsProvider); + state = state.copyWith( + settings: _ref.read(settingsProvider), + backupEnabled: backup.enabled, + backupFrequency: backup.frequency, + cloudBackupEnabled: backup.cloudBackupEnabled, + connectedProvider: _ref.read(selectedCloudProviderTypeProvider), + ); + } + } + + void choosePath(SetupPath path) => state = state.copyWith(path: path); + + void chooseSource(ExistingDataSource source) => + state = state.copyWith(source: source); + + void requestSkip() => + state = state.copyWith(path: SetupPath.fresh, skipRequested: true); + + void setName(String name) => state = state.copyWith(name: name.trim()); + + void applyUnitPreset(UnitPreset preset) => + state = state.applyingUnitPreset(preset); + + void updateSettings(AppSettings settings) => + state = state.copyWith(settings: settings); + + void setBackupEnabled(bool value) => + state = state.copyWith(backupEnabled: value); + + void setBackupFrequency(BackupFrequency value) => + state = state.copyWith(backupFrequency: value); + + void setCloudBackupEnabled(bool value) => + state = state.copyWith(cloudBackupEnabled: value); + + void setConnectedProvider(CloudProviderType? type) => state = type == null + ? state.copyWith(clearConnectedProvider: true) + : state.copyWith(connectedProvider: type); +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart new file mode 100644 index 000000000..7e269f4a1 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/pages/s3_config_page.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/settings/presentation/widgets/dropbox_connect_dialog.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Backup schedule plus optional cloud sync provider connection. +class BackupSyncStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + /// Fresh-path pivot: called when a just-connected provider already holds + /// a Submersion library (the shell offers adopting it instead). + final void Function()? onLibraryFound; + + const BackupSyncStep({super.key, required this.mode, this.onLibraryFound}); + + @override + ConsumerState createState() => _BackupSyncStepState(); +} + +class _BackupSyncStepState extends ConsumerState { + bool _connecting = false; + + Future _connect(CloudProviderType type) async { + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + setState(() => _connecting = true); + try { + if (type == CloudProviderType.dropbox) { + final connected = await showDialog( + context: context, + builder: (_) => DropboxConnectDialog( + provider: ref.read(dropboxStorageProviderInstanceProvider), + ), + ); + ref.invalidate(dropboxAuthDataProvider); + if (connected != true) return; + } + // Activation contract mirrored from CloudSyncPage._selectProvider. + ref.read(selectedCloudProviderTypeProvider.notifier).state = type; + final instance = cloudProviderInstanceFor(type); + await instance.authenticate(); + await ref.read(syncInitializerProvider).saveProvider(type); + ref.read(syncStateProvider.notifier).refreshState(); + notifier.setConnectedProvider(type); + + // Fresh-path pivot check: does this account already hold a library? + if (widget.onLibraryFound != null) { + final peers = await ref + .read(syncInitializerProvider) + .peerSyncFiles(instance); + if (peers.isNotEmpty && mounted) widget.onLibraryFound!(); + } + } catch (e) { + ref.read(selectedCloudProviderTypeProvider.notifier).state = null; + notifier.setConnectedProvider(null); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setup_sync_error(e))), + ); + } + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + void _disconnect() { + // Drop the in-wizard selection so the provider cards return; don't + // persist a backend the user is abandoning. + ref.read(selectedCloudProviderTypeProvider.notifier).state = null; + ref.read(syncInitializerProvider).saveProvider(null); + ref.read(syncStateProvider.notifier).refreshState(); + ref + .read(setupWizardProvider(widget.mode).notifier) + .setConnectedProvider(null); + } + + Future _openS3Config() async { + // Push on the ROOT navigator, not go_router: during first run the wizard + // has zero divers, so the router's onboarding redirect bounces any + // context.push to a real route straight back to /welcome (the fork). + // S3 config is a modal action within the wizard, not a route destination. + await Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => const S3ConfigPage())); + if (!mounted) return; + // S3ConfigPage activates S3 itself on save; reflect it in the draft. + final sel = ref.read(selectedCloudProviderTypeProvider); + if (sel == CloudProviderType.s3) { + ref + .read(setupWizardProvider(widget.mode).notifier) + .setConnectedProvider(sel); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final draft = ref.watch(setupWizardProvider(widget.mode)); + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + + final isApple = ref.watch(isApplePlatformProvider); + final iCloudAvailability = ref + .watch(iCloudAvailabilityProvider) + .valueOrNull; + final iCloudAvailable = + isApple && iCloudAvailability != ICloudAvailability.unsupported; + final dropboxConfigured = ref.watch(dropboxConfiguredProvider); + + // The draft holds the connected provider in both modes: seeded from the + // active provider on settings re-entry, set by the connect flow at first + // run. A non-null value renders connected status instead of the cards. + final connected = draft.connectedProvider; + + Widget sectionLabel(String text) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 4), + child: Text(text, style: theme.textTheme.titleSmall), + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_backup_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_backup_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.setup_backup_scheduleToggle), + subtitle: Text(l10n.setup_backup_scheduleSubtitle), + value: draft.backupEnabled, + onChanged: notifier.setBackupEnabled, + ), + if (draft.backupEnabled) ...[ + sectionLabel(l10n.setup_backup_frequency), + SegmentedButton( + segments: [ + ButtonSegment( + value: BackupFrequency.daily, + label: Text(l10n.setup_backup_frequency_daily), + ), + ButtonSegment( + value: BackupFrequency.weekly, + label: Text(l10n.setup_backup_frequency_weekly), + ), + ButtonSegment( + value: BackupFrequency.monthly, + label: Text(l10n.setup_backup_frequency_monthly), + ), + ], + selected: {draft.backupFrequency}, + showSelectedIcon: false, + onSelectionChanged: (sel) => + notifier.setBackupFrequency(sel.first), + ), + ], + sectionLabel(l10n.setup_sync_header), + Text( + l10n.setup_sync_subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + if (connected != null) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cloud_done), + title: Text( + l10n.setup_sync_connectedTo(_providerName(connected)), + ), + ), + if (widget.mode == SetupWizardMode.settings) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: () => context.push('/settings/cloud-sync'), + child: Text(l10n.setup_sync_manageInSettings), + ), + ) + else + // First run: let the user disconnect and pick a different + // backend (e.g. after a failed pull), instead of being locked + // into the connected provider with no way to change it. + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: _connecting ? null : _disconnect, + child: Text(l10n.setup_sync_changeProvider), + ), + ), + ] else ...[ + Text( + l10n.setup_sync_notConnected, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + if (iCloudAvailable) + _providerCard( + icon: Icons.cloud, + name: 'iCloud', + onTap: _connecting + ? null + : () => _connect(CloudProviderType.icloud), + ) + // Apple platform where iCloud can't be used (signed out or a + // build without the entitlement): explain rather than hide. + else if (isApple && + iCloudAvailability == ICloudAvailability.unsupported) + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + enabled: false, + leading: const Icon(Icons.cloud_off), + title: const Text('iCloud'), + subtitle: Text(l10n.setup_sync_icloudUnavailable), + ), + ), + if (dropboxConfigured) + _providerCard( + icon: Icons.cloud_queue, + name: 'Dropbox', + onTap: _connecting + ? null + : () => _connect(CloudProviderType.dropbox), + ), + _providerCard( + icon: Icons.storage, + name: 'S3', + onTap: _connecting ? null : _openS3Config, + ), + ], + if (connected != null) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.setup_backup_cloudCopy), + value: draft.cloudBackupEnabled, + onChanged: notifier.setCloudBackupEnabled, + ), + ], + ), + ); + } + + Widget _providerCard({ + required IconData icon, + required String name, + required VoidCallback? onTap, + }) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: Icon(icon), + title: Text(name), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } + + String _providerName(CloudProviderType type) { + switch (type) { + case CloudProviderType.icloud: + return 'iCloud'; + case CloudProviderType.dropbox: + return 'Dropbox'; + case CloudProviderType.s3: + return 'S3'; + case CloudProviderType.googledrive: + return 'Google Drive'; + } + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart new file mode 100644 index 000000000..23b0e6ded --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Chooses which existing-data source to load from. +class ExistingChoiceStep extends ConsumerWidget { + final SetupWizardMode mode; + final VoidCallback onChosen; + + const ExistingChoiceStep({ + super.key, + required this.mode, + required this.onChosen, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final l10n = context.l10n; + // Keeps the autoDispose family instance alive while this step shows. + ref.watch(setupWizardProvider(mode)); + + void choose(ExistingDataSource source) { + // Read at tap time: a build-time capture could outlive the notifier. + ref.read(setupWizardProvider(mode).notifier).chooseSource(source); + onChosen(); + } + + Widget card( + IconData icon, + String title, + String subtitle, + ExistingDataSource source, + ) { + return Card( + child: ListTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => choose(source), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_existing_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_existing_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + card( + Icons.restore, + l10n.setup_existing_restore_title, + l10n.setup_existing_restore_subtitle, + ExistingDataSource.restoreBackup, + ), + card( + Icons.cloud_sync, + l10n.setup_existing_sync_title, + l10n.setup_existing_sync_subtitle, + ExistingDataSource.cloudSync, + ), + card( + Icons.folder_open, + l10n.setup_existing_folder_title, + l10n.setup_existing_folder_subtitle, + ExistingDataSource.openFolder, + ), + ], + ), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/finish_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/finish_step.dart new file mode 100644 index 000000000..6d941eeb7 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/finish_step.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/setup_wizard/data/setup_apply_service.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Feature-discovery destinations offered on the finish screen, in display +/// order. Public so a router test can assert each one resolves to a real +/// route (a bad link 404s to the "Page Not Found" screen). +const kSetupFinishFeatureRoutes = [ + '/dive-computers/discover', // dive computer download + '/transfer', // file import + '/statistics', // statistics + '/sites', // dive sites map + '/equipment', // gear / service tracking +]; + +/// Final step: feature discovery plus the apply-and-go action. +class FinishStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const FinishStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _FinishStepState(); +} + +class _FinishStepState extends ConsumerState { + bool _applying = false; + + Future _complete({String route = '/dashboard'}) async { + if (_applying) return; + setState(() => _applying = true); + try { + final draft = ref.read(setupWizardProvider(widget.mode)); + final service = ref.read(setupApplyServiceProvider); + if (widget.mode == SetupWizardMode.firstRun) { + await service.applyFirstRun(draft); + } else { + await service.applySettingsMode(draft); + } + if (!mounted) return; + if (widget.mode == SetupWizardMode.firstRun || route != '/dashboard') { + context.go(route); + } else { + context.pop(); + } + } catch (e) { + if (mounted) { + setState(() => _applying = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setup_finish_error(e))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + final features = <(IconData, String, String)>[ + ( + Icons.watch, + l10n.setup_finish_feature_diveComputer, + kSetupFinishFeatureRoutes[0], + ), + ( + Icons.file_upload, + l10n.setup_finish_feature_import, + kSetupFinishFeatureRoutes[1], + ), + ( + Icons.query_stats, + l10n.setup_finish_feature_statistics, + kSetupFinishFeatureRoutes[2], + ), + ( + Icons.map, + l10n.setup_finish_feature_sites, + kSetupFinishFeatureRoutes[3], + ), + ( + Icons.build, + l10n.setup_finish_feature_gear, + kSetupFinishFeatureRoutes[4], + ), + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.check_circle, size: 56, color: theme.colorScheme.primary), + const SizedBox(height: 16), + Text( + l10n.setup_finish_title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.setup_finish_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + for (final (icon, label, route) in features) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(label), + trailing: const Icon(Icons.chevron_right, size: 18), + onTap: _applying ? null : () => _complete(route: route), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _applying ? null : _complete, + icon: _applying + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_forward), + label: Text( + _applying ? l10n.setup_finish_applying : l10n.setup_finish_start, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart new file mode 100644 index 000000000..078e96464 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; +import 'package:submersion/main.dart' show restartApp; + +/// Points the app at a folder that already contains a Submersion database. +class OpenFolderStep extends ConsumerStatefulWidget { + const OpenFolderStep({super.key}); + + @override + ConsumerState createState() => _OpenFolderStepState(); +} + +class _OpenFolderStepState extends ConsumerState { + bool _busy = false; + String? _error; + + Future _pickFolder() async { + setState(() { + _busy = true; + _error = null; + }); + final l10n = context.l10n; + try { + final notifier = ref.read(storageConfigNotifierProvider.notifier); + final picked = await notifier.pickCustomFolder(); + if (picked == null) return; + + final existing = await notifier.checkForExistingDatabase(picked.path); + if (existing == null) { + setState(() => _error = l10n.setup_folder_notFound_message); + return; + } + + final result = await notifier.switchToExistingDatabase(picked.path); + if (result.success) { + // Full provider rebuild: the swapped database has divers, so the + // relaunch lands on the dashboard. + restartApp(); + } else { + setState(() => _error = result.errorMessage ?? ''); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.setup_folder_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + if (_busy) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(l10n.setup_folder_switching), + ] else ...[ + if (_error != null) ...[ + Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + ], + FilledButton.icon( + onPressed: _pickFolder, + icon: const Icon(Icons.folder_open), + label: Text(l10n.setup_folder_pick), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/profile_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/profile_step.dart new file mode 100644 index 000000000..026c4b832 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/profile_step.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Collects the diver name (the wizard's only mandatory input). +class ProfileStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const ProfileStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _ProfileStepState(); +} + +class _ProfileStepState extends ConsumerState { + late final TextEditingController _nameController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: ref.read(setupWizardProvider(widget.mode)).name, + ); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_profile_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_profile_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.setup_profile_nameLabel, + hintText: l10n.setup_profile_nameHint, + prefixIcon: const Icon(Icons.person), + ), + textCapitalization: TextCapitalization.words, + autofocus: true, + textInputAction: TextInputAction.done, + onChanged: notifier.setName, + ), + ], + ), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/restore_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/restore_step.dart new file mode 100644 index 000000000..cbbc72daf --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/restore_step.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/backup/domain/entities/backup_record.dart'; +import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/backup/presentation/widgets/restore_confirmation_dialog.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// A chosen backup file: its absolute path and display name. +typedef PickedBackupFile = ({String path, String name}); + +/// Restores a backup file; exits the wizard via the existing +/// RestoreCompletePage -> restartApp() flow. +class RestoreStep extends ConsumerWidget { + /// [pickBackupFile] is injectable so the restore flow can be exercised + /// without a real platform file picker; it defaults to file_picker. + const RestoreStep({super.key, this.pickBackupFile = _pickViaFilePicker}); + + final Future Function() pickBackupFile; + + static Future _pickViaFilePicker() async { + final result = await FilePicker.pickFiles(type: FileType.any); + if (result == null || result.files.isEmpty) return null; + final file = result.files.single; + final path = file.path; + return path == null ? null : (path: path, name: file.name); + } + + Future _pickAndRestore(BuildContext context, WidgetRef ref) async { + final PickedBackupFile? picked; + try { + picked = await pickBackupFile(); + } on Exception catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$e'))); + } + return; + } + if (picked == null || !context.mounted) return; + + final file = File(picked.path); + final record = BackupRecord( + id: 'setup-wizard', + filename: picked.name, + timestamp: await file.lastModified(), + sizeBytes: await file.length(), + location: BackupLocation.local, + diveCount: 0, + siteCount: 0, + ); + if (!context.mounted) return; + + final mode = await RestoreConfirmationDialog.show( + context, + record, + currentSchemaVersion: AppDatabase.currentSchemaVersion, + ); + if (mode != null) { + await ref + .read(backupOperationProvider.notifier) + .restoreFromFilePath(picked.path, mode: mode); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final l10n = context.l10n; + final operation = ref.watch(backupOperationProvider); + + ref.listen(backupOperationProvider, (prev, next) { + if (next.status == BackupOperationStatus.restoreComplete && + context.mounted) { + RestoreCompletePage.show(context); + } + }); + + final inProgress = operation.status == BackupOperationStatus.inProgress; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.setup_restore_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + if (inProgress) ...[ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(operation.message ?? l10n.setup_restore_inProgress), + ] else ...[ + if (operation.status == BackupOperationStatus.error && + operation.message != null) ...[ + Text( + operation.message!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + ], + FilledButton.icon( + onPressed: () => _pickAndRestore(context, ref), + icon: const Icon(Icons.folder_open), + label: Text(l10n.setup_restore_pick), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart new file mode 100644 index 000000000..5d9c0a8bb --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Existing-data path: connect a provider, pull the library, land on the +/// dashboard once data (and its divers) have arrived. +class SyncConnectStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + /// Called when the connected account has no library (pivot to fresh). + final VoidCallback onNoLibrary; + + const SyncConnectStep({ + super.key, + required this.mode, + required this.onNoLibrary, + }); + + @override + ConsumerState createState() => _SyncConnectStepState(); +} + +enum _PullPhase { connect, pulling, done, empty } + +class _SyncConnectStepState extends ConsumerState { + _PullPhase _phase = _PullPhase.connect; + + Future _startPull() async { + setState(() => _phase = _PullPhase.pulling); + final connected = ref + .read(setupWizardProvider(widget.mode)) + .connectedProvider; + if (connected == null) { + setState(() => _phase = _PullPhase.connect); + return; + } + try { + final instance = cloudProviderInstanceFor(connected); + final peers = await ref + .read(syncInitializerProvider) + .peerSyncFiles(instance); + if (peers.isEmpty) { + if (mounted) setState(() => _phase = _PullPhase.empty); + return; + } + await ref.read(syncStateProvider.notifier).performSync(); + final syncState = ref.read(syncStateProvider); + if (syncState.status == SyncStatus.error) { + // A failed sync must not fall through to the "No library found" UI. + if (mounted) { + setState(() => _phase = _PullPhase.connect); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.setup_sync_error(syncState.message ?? ''), + ), + ), + ); + } + return; + } + await realignActiveDiverAfterDataReplace( + ref.read(sharedPreferencesProvider), + ); + final hasDivers = await ref.read(hasAnyDiversProvider.future); + if (mounted) { + setState(() => _phase = hasDivers ? _PullPhase.done : _PullPhase.empty); + } + } catch (e) { + // Listing peers or syncing can fail (network/auth). Return to the + // connect UI with an error rather than crashing or hanging on the + // spinner. + if (mounted) { + setState(() => _phase = _PullPhase.connect); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.setup_sync_error(e))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final syncState = ref.watch(syncStateProvider); + + switch (_phase) { + case _PullPhase.connect: + // Reuse the provider cards; connecting enables the continue gate. + return Column( + children: [ + Expanded(child: BackupSyncStep(mode: widget.mode)), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: _ConnectedGate( + mode: widget.mode, + onContinue: _startPull, + ), + ), + ), + ], + ); + case _PullPhase.pulling: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(value: syncState.progress), + const SizedBox(height: 12), + Text(syncState.message ?? l10n.setup_syncPull_syncing), + ], + ), + ); + case _PullPhase.done: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle, + size: 56, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 12), + Text( + l10n.setup_syncPull_success, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.go('/dashboard'), + child: Text(l10n.setup_syncPull_continue), + ), + ], + ), + ); + case _PullPhase.empty: + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.setup_syncPull_noLibrary_title, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + l10n.setup_syncPull_noLibrary_message, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: widget.onNoLibrary, + child: Text(l10n.setup_sync_libraryFound_keepFresh), + ), + ], + ), + ), + ); + } + } +} + +/// Continue button enabled once a provider is connected in the draft. +class _ConnectedGate extends ConsumerWidget { + final SetupWizardMode mode; + final VoidCallback onContinue; + + const _ConnectedGate({required this.mode, required this.onContinue}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final connected = ref.watch( + setupWizardProvider(mode).select((d) => d.connectedProvider != null), + ); + return FilledButton( + onPressed: connected ? onContinue : null, + child: Text(context.l10n.setup_syncPull_continue), + ); + } +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/units_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/units_step.dart new file mode 100644 index 000000000..8c0e70331 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/units_step.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/domain/unit_preset_detector.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Metric/Imperial preset with an expander for per-unit fine-tuning. +class UnitsStep extends ConsumerStatefulWidget { + final SetupWizardMode mode; + + const UnitsStep({super.key, required this.mode}); + + @override + ConsumerState createState() => _UnitsStepState(); +} + +class _UnitsStepState extends ConsumerState { + @override + void initState() { + super.initState(); + // First-run: preselect the preset matching the device locale, once. + if (widget.mode == SetupWizardMode.firstRun) { + final draft = ref.read(setupWizardProvider(widget.mode)); + if (draft.settings.unitPreset == UnitPreset.metric) { + final locale = WidgetsBinding.instance.platformDispatcher.locale; + final preset = presetForLocale(locale); + if (preset != UnitPreset.metric) { + Future.microtask(() { + if (mounted) { + ref + .read(setupWizardProvider(widget.mode).notifier) + .applyUnitPreset(preset); + } + }); + } + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + final draft = ref.watch(setupWizardProvider(widget.mode)); + final notifier = ref.read(setupWizardProvider(widget.mode).notifier); + final s = draft.settings; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setup_units_title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.setup_units_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + SegmentedButton( + segments: [ + ButtonSegment( + value: UnitPreset.metric, + label: Text(l10n.setup_units_metric), + ), + ButtonSegment( + value: UnitPreset.imperial, + label: Text(l10n.setup_units_imperial), + ), + ], + emptySelectionAllowed: true, + selected: s.unitPreset == UnitPreset.custom + ? const {} + : {s.unitPreset}, + onSelectionChanged: (selection) { + if (selection.isNotEmpty) { + notifier.applyUnitPreset(selection.first); + } + }, + ), + const SizedBox(height: 16), + ExpansionTile( + title: Text(l10n.setup_units_advanced), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + _unitRow( + label: l10n.setup_units_depth, + keyPrefix: 'setup-unit-depth', + values: DepthUnit.values, + selected: s.depthUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(depthUnit: u)), + ), + _unitRow( + label: l10n.setup_units_temperature, + keyPrefix: 'setup-unit-temperature', + values: TemperatureUnit.values, + selected: s.temperatureUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(temperatureUnit: u)), + ), + _unitRow( + label: l10n.setup_units_pressure, + keyPrefix: 'setup-unit-pressure', + values: PressureUnit.values, + selected: s.pressureUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(pressureUnit: u)), + ), + _unitRow( + label: l10n.setup_units_volume, + keyPrefix: 'setup-unit-volume', + values: VolumeUnit.values, + selected: s.volumeUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(volumeUnit: u)), + ), + _unitRow( + label: l10n.setup_units_weight, + keyPrefix: 'setup-unit-weight', + values: WeightUnit.values, + selected: s.weightUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(weightUnit: u)), + ), + _unitRow( + label: l10n.setup_units_altitude, + keyPrefix: 'setup-unit-altitude', + values: AltitudeUnit.values, + selected: s.altitudeUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(altitudeUnit: u)), + ), + _unitRow( + label: l10n.setup_units_sac, + keyPrefix: 'setup-unit-sac', + values: SacUnit.values, + selected: s.sacUnit, + symbol: (u) => u.symbol, + onChanged: (u) => + notifier.updateSettings(s.copyWith(sacUnit: u)), + ), + _unitRow( + label: l10n.setup_units_timeFormat, + keyPrefix: 'setup-unit-timeformat', + values: TimeFormat.values, + selected: s.timeFormat, + symbol: (u) => u.displayName, + onChanged: (u) => + notifier.updateSettings(s.copyWith(timeFormat: u)), + ), + // Five wide options; a segmented control overflows the row. + _dropdownRow( + label: l10n.setup_units_dateFormat, + values: DateFormatPreference.values, + selected: s.dateFormat, + display: (u) => u.displayName, + onChanged: (u) => + notifier.updateSettings(s.copyWith(dateFormat: u)), + ), + ], + ), + ], + ), + ); + } + + Widget _unitRow({ + required String label, + required String keyPrefix, + required List values, + required T selected, + required String Function(T) symbol, + required void Function(T) onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded(child: Text(label)), + SegmentedButton( + segments: [ + for (final v in values) + ButtonSegment( + value: v, + label: Text( + symbol(v), + key: ValueKey('$keyPrefix-${_segmentKey(symbol(v))}'), + ), + ), + ], + selected: {selected}, + showSelectedIcon: false, + onSelectionChanged: (sel) => onChanged(sel.first), + ), + ], + ), + ); + } + + Widget _dropdownRow({ + required String label, + required List values, + required T selected, + required String Function(T) display, + required void Function(T) onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded(child: Text(label)), + DropdownButton( + value: selected, + items: [ + for (final v in values) + DropdownMenuItem(value: v, child: Text(display(v))), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ), + ); + } + + String _segmentKey(String symbol) => + symbol.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); +} diff --git a/lib/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart b/lib/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart new file mode 100644 index 000000000..3cf6568e7 --- /dev/null +++ b/lib/features/setup_wizard/presentation/widgets/steps/welcome_fork_step.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/l10n/l10n_extension.dart'; + +/// First-run fork: start fresh, bring existing data, or skip setup. +class WelcomeForkStep extends StatelessWidget { + final VoidCallback onStartFresh; + final VoidCallback onExistingData; + final VoidCallback onSkipSetup; + + const WelcomeForkStep({ + super.key, + required this.onStartFresh, + required this.onExistingData, + required this.onSkipSetup, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ExcludeSemantics( + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.asset( + 'assets/icon/icon.png', + width: 96, + height: 96, + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + l10n.setup_welcome_title, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.setup_welcome_subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _ForkCard( + icon: Icons.add_circle_outline, + title: l10n.setup_welcome_startFresh_title, + subtitle: l10n.setup_welcome_startFresh_subtitle, + onTap: onStartFresh, + ), + const SizedBox(height: 12), + _ForkCard( + icon: Icons.cloud_download_outlined, + title: l10n.setup_welcome_existingData_title, + subtitle: l10n.setup_welcome_existingData_subtitle, + onTap: onExistingData, + ), + const SizedBox(height: 24), + TextButton( + onPressed: onSkipSetup, + child: Text(l10n.setup_welcome_skipSetup), + ), + ], + ), + ); + } +} + +class _ForkCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _ForkCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, size: 32, color: theme.colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 70b410e5c..3db76ae60 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "تتم مزامنة مكتبتك المستعادة مع السحابة…", "settings_cloudSync_postRestore_synced": "تمت مزامنة المكتبة المستعادة.", "settings_cloudSync_replace_reviewAction": "مراجعة", + "settings_manage_setupAssistant": "مساعد الإعداد", + "settings_manage_setupAssistant_subtitle": "مراجعة الوحدات والمظهر وخيارات النسخ الاحتياطي", + "setup_backup_cloudCopy": "الاحتفاظ بنسخة احتياطية في السحابة", + "setup_backup_frequency": "التكرار", + "setup_backup_frequency_daily": "يوميًا", + "setup_backup_frequency_monthly": "شهريًا", + "setup_backup_frequency_weekly": "أسبوعيًا", + "setup_backup_scheduleSubtitle": "انسخ سجل غوصك احتياطيًا وفق جدول زمني", + "setup_backup_scheduleToggle": "نسخ احتياطي تلقائي", + "setup_backup_subtitle": "احمِ سجل غوصك من اليوم الأول.", + "setup_backup_title": "النسخ الاحتياطي والمزامنة", + "setup_common_back": "رجوع", + "setup_common_next": "التالي", + "setup_common_skip": "تخطي", + "setup_existing_folder_subtitle": "وجّه Submersion إلى مجلد يحتوي بالفعل على مكتبة", + "setup_existing_folder_title": "فتح مجلد موجود", + "setup_existing_restore_subtitle": "اختر ملف نسخ احتياطي تم تصديره من Submersion", + "setup_existing_restore_title": "استعادة ملف نسخ احتياطي", + "setup_existing_subtitle": "اختر طريقة تحميل مكتبة Submersion الحالية", + "setup_existing_sync_subtitle": "اسحب مكتبتك من iCloud أو Dropbox أو S3", + "setup_existing_sync_title": "توصيل مزامنة السحابة", + "setup_existing_title": "أحضر بياناتك", + "setup_finish_applying": "جارٍ الإعداد...", + "setup_finish_error": "تعذّر إكمال الإعداد: {error}", + "setup_finish_feature_diveComputer": "نزّل الغوصات من كمبيوتر الغوص", + "setup_finish_feature_gear": "تتبّع المعدات ومواعيد الصيانة", + "setup_finish_feature_import": "استورد السجلات من الملفات والتطبيقات الأخرى", + "setup_finish_feature_sites": "اعرض مواقع الغوص على الخريطة", + "setup_finish_feature_statistics": "استكشف إحصاءات الغوص", + "setup_finish_start": "ابدأ التسجيل", + "setup_finish_subtitle": "يمكن لـ Submersion أيضًا...", + "setup_finish_title": "كل شيء جاهز", + "setup_folder_notFound_message": "المجلد المحدد لا يحتوي على قاعدة بيانات Submersion.", + "setup_folder_notFound_title": "لا توجد مكتبة في هذا المجلد", + "setup_folder_pick": "اختيار مجلد", + "setup_folder_switching": "جارٍ فتح المكتبة...", + "setup_folder_title": "فتح مجلد موجود", + "setup_profile_nameHint": "أدخل اسمك", + "setup_profile_nameLabel": "اسمك", + "setup_profile_nameValidation": "الرجاء إدخال اسمك", + "setup_profile_subtitle": "أدخل اسمك للبدء. يمكنك إضافة المزيد من التفاصيل لاحقاً.", + "setup_profile_title": "إنشاء ملفك الشخصي", + "setup_restore_inProgress": "جارٍ الاستعادة...", + "setup_restore_pick": "اختيار ملف النسخ الاحتياطي", + "setup_restore_title": "استعادة النسخة الاحتياطية", + "setup_step_backup": "نسخ احتياطي", + "setup_step_finish": "تم", + "setup_step_profile": "الملف الشخصي", + "setup_step_units": "الوحدات", + "setup_syncPull_continue": "متابعة", + "setup_syncPull_noLibrary_message": "لم يتم العثور على مكتبة Submersion في هذا الحساب. هل تبدأ من جديد؟ سيتم الاحتفاظ باتصالك.", + "setup_syncPull_noLibrary_title": "لم يتم العثور على مكتبة", + "setup_syncPull_success": "تم اعتماد المكتبة", + "setup_syncPull_syncing": "جارٍ سحب مكتبتك...", + "setup_syncPull_title": "الاتصال والسحب", + "setup_sync_changeProvider": "تغيير المزوّد", + "setup_sync_connectedTo": "متصل بـ {provider}", + "setup_sync_error": "تعذّر الاتصال: {error}", + "setup_sync_header": "مزامنة السحابة", + "setup_sync_icloudUnavailable": "iCloud غير متاح على هذا الجهاز", + "setup_sync_libraryFound_adopt": "اعتماد المكتبة الحالية", + "setup_sync_libraryFound_keepFresh": "البدء من جديد", + "setup_sync_libraryFound_message": "يحتوي هذا الحساب بالفعل على مكتبة Submersion. هل تريد اعتمادها بدلًا من البدء من جديد؟", + "setup_sync_libraryFound_title": "تم العثور على مكتبة موجودة", + "setup_sync_manageInSettings": "الإدارة في الإعدادات", + "setup_sync_notConnected": "غير متصل", + "setup_sync_subtitle": "زامن سجلك عبر الأجهزة", + "setup_units_advanced": "ضبط الوحدات", + "setup_units_altitude": "الارتفاع", + "setup_units_dateFormat": "تنسيق التاريخ", + "setup_units_depth": "العمق", + "setup_units_imperial": "إمبراطوري", + "setup_units_metric": "متري", + "setup_units_pressure": "الضغط", + "setup_units_sac": "معدل SAC", + "setup_units_subtitle": "اختر كيفية عرض القياسات. يمكنك ضبط كل وحدة.", + "setup_units_temperature": "درجة الحرارة", + "setup_units_timeFormat": "تنسيق الوقت", + "setup_units_title": "الوحدات", + "setup_units_volume": "الحجم", + "setup_units_weight": "الوزن", + "setup_welcome_existingData_subtitle": "استعد نسخة احتياطية، أو صِل مزامنة السحابة، أو افتح مجلدًا موجودًا", + "setup_welcome_existingData_title": "لديّ بيانات Submersion موجودة", + "setup_welcome_skipSetup": "تخطي الإعداد", + "setup_welcome_startFresh_subtitle": "أنشئ ملف الغواص الخاص بك واضبط التطبيق", + "setup_welcome_startFresh_title": "إعداد سجل جديد", + "setup_welcome_subtitle": "تسجيل وتحليل متقدم للغوص", + "setup_welcome_title": "مرحباً بك في Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3359,16 +3447,6 @@ "nav_tooltip_expandMenu": "توسيع القائمة", "nav_transfer": "نقل البيانات", "nav_trips": "الرحلات", - "onboarding_welcome_createProfile": "إنشاء ملفك الشخصي", - "onboarding_welcome_createProfileSubtitle": "أدخل اسمك للبدء. يمكنك إضافة المزيد من التفاصيل لاحقاً.", - "onboarding_welcome_creating": "جارٍ الإنشاء...", - "onboarding_welcome_errorCreatingProfile": "خطأ في إنشاء الملف الشخصي: {error}", - "onboarding_welcome_getStarted": "البدء", - "onboarding_welcome_nameHint": "أدخل اسمك", - "onboarding_welcome_nameLabel": "اسمك", - "onboarding_welcome_nameValidation": "الرجاء إدخال اسمك", - "onboarding_welcome_subtitle": "تسجيل وتحليل متقدم للغوص", - "onboarding_welcome_title": "مرحباً بك في Submersion", "plannerCanvas_bailout_available": "المتاح {liters}", "plannerCanvas_bailout_insufficient": "غاز الإنقاذ غير كافٍ لأسوأ حالة", "plannerCanvas_bailout_required": "المطلوب {liters}", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 84ba3da89..a22af7fc7 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "Wiederhergestellte Bibliothek wird mit der Cloud synchronisiert…", "settings_cloudSync_postRestore_synced": "Wiederhergestellte Bibliothek synchronisiert.", "settings_cloudSync_replace_reviewAction": "Überprüfen", + "settings_manage_setupAssistant": "Einrichtungsassistent", + "settings_manage_setupAssistant_subtitle": "Einheiten, Darstellung und Backup-Einstellungen erneut durchgehen", + "setup_backup_cloudCopy": "Eine Sicherungskopie in der Cloud aufbewahren", + "setup_backup_frequency": "Häufigkeit", + "setup_backup_frequency_daily": "Täglich", + "setup_backup_frequency_monthly": "Monatlich", + "setup_backup_frequency_weekly": "Wöchentlich", + "setup_backup_scheduleSubtitle": "Ihr Logbuch regelmäßig sichern", + "setup_backup_scheduleToggle": "Automatische Sicherungen", + "setup_backup_subtitle": "Schützen Sie Ihr Tauchlogbuch vom ersten Tag an.", + "setup_backup_title": "Sicherung & Synchronisierung", + "setup_common_back": "Zurück", + "setup_common_next": "Weiter", + "setup_common_skip": "Überspringen", + "setup_existing_folder_subtitle": "Submersion auf einen Ordner verweisen, der bereits eine Bibliothek enthält", + "setup_existing_folder_title": "Vorhandenen Ordner öffnen", + "setup_existing_restore_subtitle": "Eine aus Submersion exportierte Sicherungsdatei auswählen", + "setup_existing_restore_title": "Sicherungsdatei wiederherstellen", + "setup_existing_subtitle": "Wählen Sie, wie Ihre vorhandene Submersion-Bibliothek geladen werden soll", + "setup_existing_sync_subtitle": "Ihre Bibliothek aus iCloud, Dropbox oder S3 laden", + "setup_existing_sync_title": "Cloud-Synchronisierung verbinden", + "setup_existing_title": "Daten übernehmen", + "setup_finish_applying": "Wird eingerichtet...", + "setup_finish_error": "Einrichtung konnte nicht abgeschlossen werden: {error}", + "setup_finish_feature_diveComputer": "Tauchgänge von Ihrem Tauchcomputer herunterladen", + "setup_finish_feature_gear": "Ausrüstung und Wartungsintervalle verwalten", + "setup_finish_feature_import": "Logbücher aus Dateien und anderen Apps importieren", + "setup_finish_feature_sites": "Ihre Tauchplätze auf der Karte anzeigen", + "setup_finish_feature_statistics": "Statistiken zu Ihren Tauchgängen entdecken", + "setup_finish_start": "Loslegen", + "setup_finish_subtitle": "Submersion kann außerdem...", + "setup_finish_title": "Alles bereit", + "setup_folder_notFound_message": "Der ausgewählte Ordner enthält keine Submersion-Datenbank.", + "setup_folder_notFound_title": "Keine Bibliothek in diesem Ordner", + "setup_folder_pick": "Ordner auswählen", + "setup_folder_switching": "Bibliothek wird geöffnet...", + "setup_folder_title": "Vorhandenen Ordner öffnen", + "setup_profile_nameHint": "Geben Sie Ihren Namen ein", + "setup_profile_nameLabel": "Ihr Name", + "setup_profile_nameValidation": "Bitte geben Sie Ihren Namen ein", + "setup_profile_subtitle": "Geben Sie Ihren Namen ein, um zu beginnen. Sie können später weitere Details hinzufügen.", + "setup_profile_title": "Erstellen Sie Ihr Profil", + "setup_restore_inProgress": "Wird wiederhergestellt...", + "setup_restore_pick": "Sicherungsdatei auswählen", + "setup_restore_title": "Sicherung wiederherstellen", + "setup_step_backup": "Sicherung", + "setup_step_finish": "Fertig", + "setup_step_profile": "Profil", + "setup_step_units": "Einheiten", + "setup_syncPull_continue": "Weiter", + "setup_syncPull_noLibrary_message": "Auf diesem Konto wurde keine Submersion-Bibliothek gefunden. Stattdessen neu beginnen? Die Verbindung bleibt bestehen.", + "setup_syncPull_noLibrary_title": "Keine Bibliothek gefunden", + "setup_syncPull_success": "Bibliothek übernommen", + "setup_syncPull_syncing": "Bibliothek wird geladen...", + "setup_syncPull_title": "Verbinden und laden", + "setup_sync_changeProvider": "Anbieter wechseln", + "setup_sync_connectedTo": "Verbunden mit {provider}", + "setup_sync_error": "Verbindung fehlgeschlagen: {error}", + "setup_sync_header": "Cloud-Synchronisierung", + "setup_sync_icloudUnavailable": "iCloud ist auf diesem Gerät nicht verfügbar", + "setup_sync_libraryFound_adopt": "Vorhandene Bibliothek übernehmen", + "setup_sync_libraryFound_keepFresh": "Neu beginnen", + "setup_sync_libraryFound_message": "Dieses Konto enthält bereits eine Submersion-Bibliothek. Diese übernehmen, statt neu zu beginnen?", + "setup_sync_libraryFound_title": "Vorhandene Bibliothek gefunden", + "setup_sync_manageInSettings": "In den Einstellungen verwalten", + "setup_sync_notConnected": "Nicht verbunden", + "setup_sync_subtitle": "Ihr Logbuch auf allen Geräten synchronisieren", + "setup_units_advanced": "Einheiten anpassen", + "setup_units_altitude": "Höhe", + "setup_units_dateFormat": "Datumsformat", + "setup_units_depth": "Tiefe", + "setup_units_imperial": "Imperial", + "setup_units_metric": "Metrisch", + "setup_units_pressure": "Druck", + "setup_units_sac": "SAC-Rate", + "setup_units_subtitle": "Wählen Sie, wie Messwerte angezeigt werden. Jede Einheit lässt sich einzeln anpassen.", + "setup_units_temperature": "Temperatur", + "setup_units_timeFormat": "Zeitformat", + "setup_units_title": "Einheiten", + "setup_units_volume": "Volumen", + "setup_units_weight": "Gewicht", + "setup_welcome_existingData_subtitle": "Sicherung wiederherstellen, Cloud-Sync verbinden oder vorhandenen Ordner öffnen", + "setup_welcome_existingData_title": "Ich habe bereits Submersion-Daten", + "setup_welcome_skipSetup": "Einrichtung überspringen", + "setup_welcome_startFresh_subtitle": "Taucherprofil erstellen und die App einrichten", + "setup_welcome_startFresh_title": "Neues Logbuch einrichten", + "setup_welcome_subtitle": "Erweiterte Tauchprotokollierung und -analyse", + "setup_welcome_title": "Willkommen bei Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3359,16 +3447,6 @@ "nav_tooltip_expandMenu": "Menü ausklappen", "nav_transfer": "Übertragung", "nav_trips": "Reisen", - "onboarding_welcome_createProfile": "Erstellen Sie Ihr Profil", - "onboarding_welcome_createProfileSubtitle": "Geben Sie Ihren Namen ein, um zu beginnen. Sie können später weitere Details hinzufügen.", - "onboarding_welcome_creating": "Wird erstellt...", - "onboarding_welcome_errorCreatingProfile": "Fehler beim Erstellen des Profils: {error}", - "onboarding_welcome_getStarted": "Loslegen", - "onboarding_welcome_nameHint": "Geben Sie Ihren Namen ein", - "onboarding_welcome_nameLabel": "Ihr Name", - "onboarding_welcome_nameValidation": "Bitte geben Sie Ihren Namen ein", - "onboarding_welcome_subtitle": "Erweiterte Tauchprotokollierung und -analyse", - "onboarding_welcome_title": "Willkommen bei Submersion", "plannerCanvas_bailout_available": "Verfügbar {liters}", "plannerCanvas_bailout_insufficient": "Bailout-Gas reicht für den schlimmsten Fall nicht aus", "plannerCanvas_bailout_required": "Benötigt {liters}", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 284eb3032..204df7a81 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2595,6 +2595,115 @@ } } }, + "settings_manage_setupAssistant": "Setup assistant", + "settings_manage_setupAssistant_subtitle": "Revisit units, appearance, and backup choices", + "setup_backup_cloudCopy": "Keep a backup copy in the cloud", + "setup_backup_frequency": "Frequency", + "setup_backup_frequency_daily": "Daily", + "setup_backup_frequency_monthly": "Monthly", + "setup_backup_frequency_weekly": "Weekly", + "setup_backup_scheduleSubtitle": "Back up your logbook on a schedule", + "setup_backup_scheduleToggle": "Automatic backups", + "setup_backup_subtitle": "Protect your dive log from day one.", + "setup_backup_title": "Backups & Sync", + "setup_common_back": "Back", + "setup_common_next": "Next", + "setup_common_skip": "Skip", + "setup_existing_folder_subtitle": "Point Submersion at a folder that already contains a library", + "setup_existing_folder_title": "Open an existing folder", + "setup_existing_restore_subtitle": "Pick a backup file exported from Submersion", + "setup_existing_restore_title": "Restore a backup file", + "setup_existing_subtitle": "Choose how to load your existing Submersion library", + "setup_existing_sync_subtitle": "Pull your library from iCloud, Dropbox, or S3", + "setup_existing_sync_title": "Connect cloud sync", + "setup_existing_title": "Bring your data", + "setup_finish_applying": "Setting up...", + "setup_finish_error": "Could not complete setup: {error}", + "@setup_finish_error": { + "placeholders": { + "error": { + "type": "Object" + } + } + }, + "setup_finish_feature_diveComputer": "Download dives from your dive computer", + "setup_finish_feature_gear": "Track gear and service intervals", + "setup_finish_feature_import": "Import logs from files and other apps", + "setup_finish_feature_sites": "Map your dive sites", + "setup_finish_feature_statistics": "Explore statistics about your diving", + "setup_finish_start": "Start logging", + "setup_finish_subtitle": "Submersion can also...", + "setup_finish_title": "You're all set", + "setup_folder_notFound_message": "The selected folder does not contain a Submersion database.", + "setup_folder_notFound_title": "No library in that folder", + "setup_folder_pick": "Choose folder", + "setup_folder_switching": "Opening library...", + "setup_folder_title": "Open existing folder", + "setup_profile_nameHint": "Enter your name", + "setup_profile_nameLabel": "Your Name", + "setup_profile_nameValidation": "Please enter your name", + "setup_profile_subtitle": "Enter your name to get started. You can add more details later.", + "setup_profile_title": "Create Your Profile", + "setup_restore_inProgress": "Restoring...", + "setup_restore_pick": "Choose backup file", + "setup_restore_title": "Restore backup", + "setup_step_backup": "Backup", + "setup_step_finish": "Done", + "setup_step_profile": "Profile", + "setup_step_units": "Units", + "setup_syncPull_continue": "Continue", + "setup_syncPull_noLibrary_message": "No existing Submersion library was found on this account. Start fresh instead? Your connection will be kept.", + "setup_syncPull_noLibrary_title": "No library found", + "setup_syncPull_success": "Library adopted", + "setup_syncPull_syncing": "Pulling your library...", + "setup_syncPull_title": "Connect and pull", + "setup_sync_changeProvider": "Change provider", + "setup_sync_connectedTo": "Connected to {provider}", + "@setup_sync_connectedTo": { + "placeholders": { + "provider": { + "type": "String" + } + } + }, + "setup_sync_error": "Could not connect: {error}", + "@setup_sync_error": { + "placeholders": { + "error": { + "type": "Object" + } + } + }, + "setup_sync_header": "Cloud sync", + "setup_sync_icloudUnavailable": "iCloud is not available on this device", + "setup_sync_libraryFound_adopt": "Adopt existing library", + "setup_sync_libraryFound_keepFresh": "Start fresh", + "setup_sync_libraryFound_message": "This account already contains a Submersion library. Adopt it instead of starting fresh?", + "setup_sync_libraryFound_title": "Existing library found", + "setup_sync_manageInSettings": "Manage in Settings", + "setup_sync_notConnected": "Not connected", + "setup_sync_subtitle": "Sync your logbook across devices", + "setup_units_advanced": "Fine-tune units", + "setup_units_altitude": "Altitude", + "setup_units_dateFormat": "Date format", + "setup_units_depth": "Depth", + "setup_units_imperial": "Imperial", + "setup_units_metric": "Metric", + "setup_units_pressure": "Pressure", + "setup_units_sac": "SAC rate", + "setup_units_subtitle": "Choose how measurements are displayed. You can fine-tune each unit.", + "setup_units_temperature": "Temperature", + "setup_units_timeFormat": "Time format", + "setup_units_title": "Units", + "setup_units_volume": "Volume", + "setup_units_weight": "Weight", + "setup_welcome_existingData_subtitle": "Restore a backup, connect cloud sync, or open an existing folder", + "setup_welcome_existingData_title": "I have existing Submersion data", + "setup_welcome_skipSetup": "Skip setup", + "setup_welcome_startFresh_subtitle": "Create your diver profile and configure the app", + "setup_welcome_startFresh_title": "Set up a new logbook", + "setup_welcome_subtitle": "Advanced dive logging and analytics", + "setup_welcome_title": "Welcome to Submersion", "siteMatchReview_title": "Match Sites", "siteMatchReview_diveNumber": "Dive #{number}", "@siteMatchReview_diveNumber": { @@ -5987,23 +6096,6 @@ "@nav_trips": { "description": "Navigation label for trips section" }, - "onboarding_welcome_createProfile": "Create Your Profile", - "onboarding_welcome_createProfileSubtitle": "Enter your name to get started. You can add more details later.", - "onboarding_welcome_creating": "Creating...", - "onboarding_welcome_errorCreatingProfile": "Error creating profile: {error}", - "onboarding_welcome_getStarted": "Get Started", - "onboarding_welcome_nameHint": "Enter your name", - "onboarding_welcome_nameLabel": "Your Name", - "onboarding_welcome_nameValidation": "Please enter your name", - "onboarding_welcome_subtitle": "Advanced dive logging and analytics", - "onboarding_welcome_title": "Welcome to Submersion", - "@onboarding_welcome_errorCreatingProfile": { - "placeholders": { - "error": { - "type": "Object" - } - } - }, "plannerCanvas_bailout_available": "Available {liters}", "@plannerCanvas_bailout_available": { "placeholders": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index fae8c1761..893885779 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "Sincronizando tu biblioteca restaurada con la nube…", "settings_cloudSync_postRestore_synced": "Biblioteca restaurada sincronizada.", "settings_cloudSync_replace_reviewAction": "Revisar", + "settings_manage_setupAssistant": "Asistente de configuración", + "settings_manage_setupAssistant_subtitle": "Revisa unidades, apariencia y opciones de copia de seguridad", + "setup_backup_cloudCopy": "Guardar una copia de seguridad en la nube", + "setup_backup_frequency": "Frecuencia", + "setup_backup_frequency_daily": "Diaria", + "setup_backup_frequency_monthly": "Mensual", + "setup_backup_frequency_weekly": "Semanal", + "setup_backup_scheduleSubtitle": "Haz copias de seguridad de tu diario según una programación", + "setup_backup_scheduleToggle": "Copias de seguridad automáticas", + "setup_backup_subtitle": "Protege tu registro de buceo desde el primer día.", + "setup_backup_title": "Copias de seguridad y sincronización", + "setup_common_back": "Atrás", + "setup_common_next": "Siguiente", + "setup_common_skip": "Omitir", + "setup_existing_folder_subtitle": "Apunta Submersion a una carpeta que ya contiene una biblioteca", + "setup_existing_folder_title": "Abrir una carpeta existente", + "setup_existing_restore_subtitle": "Elige un archivo de copia de seguridad exportado desde Submersion", + "setup_existing_restore_title": "Restaurar un archivo de copia de seguridad", + "setup_existing_subtitle": "Elige cómo cargar tu biblioteca existente de Submersion", + "setup_existing_sync_subtitle": "Descarga tu biblioteca desde iCloud, Dropbox o S3", + "setup_existing_sync_title": "Conectar sincronización en la nube", + "setup_existing_title": "Trae tus datos", + "setup_finish_applying": "Configurando...", + "setup_finish_error": "No se pudo completar la configuración: {error}", + "setup_finish_feature_diveComputer": "Descarga inmersiones desde tu ordenador de buceo", + "setup_finish_feature_gear": "Controla tu equipo y los intervalos de mantenimiento", + "setup_finish_feature_import": "Importa registros desde archivos y otras aplicaciones", + "setup_finish_feature_sites": "Visualiza tus puntos de buceo en el mapa", + "setup_finish_feature_statistics": "Explora estadísticas sobre tus inmersiones", + "setup_finish_start": "Empezar a registrar", + "setup_finish_subtitle": "Submersion también puede...", + "setup_finish_title": "Todo listo", + "setup_folder_notFound_message": "La carpeta seleccionada no contiene una base de datos de Submersion.", + "setup_folder_notFound_title": "No hay biblioteca en esa carpeta", + "setup_folder_pick": "Elegir carpeta", + "setup_folder_switching": "Abriendo biblioteca...", + "setup_folder_title": "Abrir carpeta existente", + "setup_profile_nameHint": "Ingresa tu nombre", + "setup_profile_nameLabel": "Tu Nombre", + "setup_profile_nameValidation": "Por favor ingresa tu nombre", + "setup_profile_subtitle": "Ingresa tu nombre para comenzar. Puedes agregar más detalles después.", + "setup_profile_title": "Crea Tu Perfil", + "setup_restore_inProgress": "Restaurando...", + "setup_restore_pick": "Elegir archivo de copia de seguridad", + "setup_restore_title": "Restaurar copia de seguridad", + "setup_step_backup": "Copia", + "setup_step_finish": "Listo", + "setup_step_profile": "Perfil", + "setup_step_units": "Unidades", + "setup_syncPull_continue": "Continuar", + "setup_syncPull_noLibrary_message": "No se encontró ninguna biblioteca de Submersion en esta cuenta. ¿Empezar de cero? Tu conexión se mantendrá.", + "setup_syncPull_noLibrary_title": "No se encontró biblioteca", + "setup_syncPull_success": "Biblioteca adoptada", + "setup_syncPull_syncing": "Descargando tu biblioteca...", + "setup_syncPull_title": "Conectar y descargar", + "setup_sync_changeProvider": "Cambiar proveedor", + "setup_sync_connectedTo": "Conectado a {provider}", + "setup_sync_error": "No se pudo conectar: {error}", + "setup_sync_header": "Sincronización en la nube", + "setup_sync_icloudUnavailable": "iCloud no está disponible en este dispositivo", + "setup_sync_libraryFound_adopt": "Adoptar biblioteca existente", + "setup_sync_libraryFound_keepFresh": "Empezar de cero", + "setup_sync_libraryFound_message": "Esta cuenta ya contiene una biblioteca de Submersion. ¿Adoptarla en lugar de empezar de cero?", + "setup_sync_libraryFound_title": "Se encontró una biblioteca existente", + "setup_sync_manageInSettings": "Gestionar en Ajustes", + "setup_sync_notConnected": "No conectado", + "setup_sync_subtitle": "Sincroniza tu diario entre dispositivos", + "setup_units_advanced": "Ajustar unidades", + "setup_units_altitude": "Altitud", + "setup_units_dateFormat": "Formato de fecha", + "setup_units_depth": "Profundidad", + "setup_units_imperial": "Imperial", + "setup_units_metric": "Métrico", + "setup_units_pressure": "Presión", + "setup_units_sac": "Tasa SAC", + "setup_units_subtitle": "Elige cómo se muestran las medidas. Puedes ajustar cada unidad.", + "setup_units_temperature": "Temperatura", + "setup_units_timeFormat": "Formato de hora", + "setup_units_title": "Unidades", + "setup_units_volume": "Volumen", + "setup_units_weight": "Peso", + "setup_welcome_existingData_subtitle": "Restaura una copia de seguridad, conecta la sincronización o abre una carpeta existente", + "setup_welcome_existingData_title": "Ya tengo datos de Submersion", + "setup_welcome_skipSetup": "Omitir configuración", + "setup_welcome_startFresh_subtitle": "Crea tu perfil de buceador y configura la aplicación", + "setup_welcome_startFresh_title": "Configurar un diario nuevo", + "setup_welcome_subtitle": "Registro y análisis avanzado de inmersiones", + "setup_welcome_title": "Bienvenido a Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3359,16 +3447,6 @@ "nav_tooltip_expandMenu": "Expandir menú", "nav_transfer": "Transferencia", "nav_trips": "Viajes", - "onboarding_welcome_createProfile": "Crea Tu Perfil", - "onboarding_welcome_createProfileSubtitle": "Ingresa tu nombre para comenzar. Puedes agregar más detalles después.", - "onboarding_welcome_creating": "Creando...", - "onboarding_welcome_errorCreatingProfile": "Error al crear perfil: {error}", - "onboarding_welcome_getStarted": "Comenzar", - "onboarding_welcome_nameHint": "Ingresa tu nombre", - "onboarding_welcome_nameLabel": "Tu Nombre", - "onboarding_welcome_nameValidation": "Por favor ingresa tu nombre", - "onboarding_welcome_subtitle": "Registro y análisis avanzado de inmersiones", - "onboarding_welcome_title": "Bienvenido a Submersion", "plannerCanvas_bailout_available": "Disponible {liters}", "plannerCanvas_bailout_insufficient": "Gas de bailout insuficiente para el peor caso", "plannerCanvas_bailout_required": "Necesario {liters}", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 8ae48641b..81d7a24d8 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "Synchronisation de votre bibliothèque restaurée avec le cloud…", "settings_cloudSync_postRestore_synced": "Bibliothèque restaurée synchronisée.", "settings_cloudSync_replace_reviewAction": "Examiner", + "settings_manage_setupAssistant": "Assistant de configuration", + "settings_manage_setupAssistant_subtitle": "Revoir les unités, l'apparence et les choix de sauvegarde", + "setup_backup_cloudCopy": "Conserver une copie de sauvegarde dans le cloud", + "setup_backup_frequency": "Fréquence", + "setup_backup_frequency_daily": "Quotidienne", + "setup_backup_frequency_monthly": "Mensuelle", + "setup_backup_frequency_weekly": "Hebdomadaire", + "setup_backup_scheduleSubtitle": "Sauvegardez votre carnet selon un calendrier", + "setup_backup_scheduleToggle": "Sauvegardes automatiques", + "setup_backup_subtitle": "Protégez votre carnet de plongée dès le premier jour.", + "setup_backup_title": "Sauvegardes et synchronisation", + "setup_common_back": "Retour", + "setup_common_next": "Suivant", + "setup_common_skip": "Ignorer", + "setup_existing_folder_subtitle": "Pointez Submersion vers un dossier contenant déjà une bibliothèque", + "setup_existing_folder_title": "Ouvrir un dossier existant", + "setup_existing_restore_subtitle": "Choisissez un fichier de sauvegarde exporté depuis Submersion", + "setup_existing_restore_title": "Restaurer un fichier de sauvegarde", + "setup_existing_subtitle": "Choisissez comment charger votre bibliothèque Submersion existante", + "setup_existing_sync_subtitle": "Récupérez votre bibliothèque depuis iCloud, Dropbox ou S3", + "setup_existing_sync_title": "Connecter la synchronisation cloud", + "setup_existing_title": "Récupérez vos données", + "setup_finish_applying": "Configuration...", + "setup_finish_error": "Impossible de terminer la configuration : {error}", + "setup_finish_feature_diveComputer": "Téléchargez vos plongées depuis votre ordinateur de plongée", + "setup_finish_feature_gear": "Suivez votre matériel et ses entretiens", + "setup_finish_feature_import": "Importez des carnets depuis des fichiers et d'autres applications", + "setup_finish_feature_sites": "Cartographiez vos sites de plongée", + "setup_finish_feature_statistics": "Explorez les statistiques de vos plongées", + "setup_finish_start": "Commencer", + "setup_finish_subtitle": "Submersion peut aussi...", + "setup_finish_title": "Tout est prêt", + "setup_folder_notFound_message": "Le dossier sélectionné ne contient pas de base de données Submersion.", + "setup_folder_notFound_title": "Aucune bibliothèque dans ce dossier", + "setup_folder_pick": "Choisir un dossier", + "setup_folder_switching": "Ouverture de la bibliothèque...", + "setup_folder_title": "Ouvrir un dossier existant", + "setup_profile_nameHint": "Entrez votre nom", + "setup_profile_nameLabel": "Votre nom", + "setup_profile_nameValidation": "Veuillez entrer votre nom", + "setup_profile_subtitle": "Entrez votre nom pour commencer. Vous pourrez ajouter plus de détails plus tard.", + "setup_profile_title": "Créer votre profil", + "setup_restore_inProgress": "Restauration...", + "setup_restore_pick": "Choisir un fichier de sauvegarde", + "setup_restore_title": "Restaurer la sauvegarde", + "setup_step_backup": "Sauvegarde", + "setup_step_finish": "Terminé", + "setup_step_profile": "Profil", + "setup_step_units": "Unités", + "setup_syncPull_continue": "Continuer", + "setup_syncPull_noLibrary_message": "Aucune bibliothèque Submersion n'a été trouvée sur ce compte. Repartir de zéro ? Votre connexion sera conservée.", + "setup_syncPull_noLibrary_title": "Aucune bibliothèque trouvée", + "setup_syncPull_success": "Bibliothèque adoptée", + "setup_syncPull_syncing": "Récupération de votre bibliothèque...", + "setup_syncPull_title": "Connecter et récupérer", + "setup_sync_changeProvider": "Changer de fournisseur", + "setup_sync_connectedTo": "Connecté à {provider}", + "setup_sync_error": "Connexion impossible : {error}", + "setup_sync_header": "Synchronisation cloud", + "setup_sync_icloudUnavailable": "iCloud n'est pas disponible sur cet appareil", + "setup_sync_libraryFound_adopt": "Adopter la bibliothèque existante", + "setup_sync_libraryFound_keepFresh": "Repartir de zéro", + "setup_sync_libraryFound_message": "Ce compte contient déjà une bibliothèque Submersion. L'adopter plutôt que de repartir de zéro ?", + "setup_sync_libraryFound_title": "Bibliothèque existante trouvée", + "setup_sync_manageInSettings": "Gérer dans les réglages", + "setup_sync_notConnected": "Non connecté", + "setup_sync_subtitle": "Synchronisez votre carnet entre vos appareils", + "setup_units_advanced": "Ajuster les unités", + "setup_units_altitude": "Altitude", + "setup_units_dateFormat": "Format de date", + "setup_units_depth": "Profondeur", + "setup_units_imperial": "Impérial", + "setup_units_metric": "Métrique", + "setup_units_pressure": "Pression", + "setup_units_sac": "Taux SAC", + "setup_units_subtitle": "Choisissez l'affichage des mesures. Chaque unité peut être ajustée.", + "setup_units_temperature": "Température", + "setup_units_timeFormat": "Format de l'heure", + "setup_units_title": "Unités", + "setup_units_volume": "Volume", + "setup_units_weight": "Poids", + "setup_welcome_existingData_subtitle": "Restaurez une sauvegarde, connectez la synchronisation ou ouvrez un dossier existant", + "setup_welcome_existingData_title": "J'ai déjà des données Submersion", + "setup_welcome_skipSetup": "Ignorer la configuration", + "setup_welcome_startFresh_subtitle": "Créez votre profil de plongeur et configurez l'application", + "setup_welcome_startFresh_title": "Créer un nouveau carnet", + "setup_welcome_subtitle": "Enregistrement et analyse avancés de plongée", + "setup_welcome_title": "Bienvenue dans Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3325,16 +3413,6 @@ "nav_tooltip_expandMenu": "Developper le menu", "nav_transfer": "Transfert", "nav_trips": "Voyages", - "onboarding_welcome_createProfile": "Créer votre profil", - "onboarding_welcome_createProfileSubtitle": "Entrez votre nom pour commencer. Vous pourrez ajouter plus de détails plus tard.", - "onboarding_welcome_creating": "Création...", - "onboarding_welcome_errorCreatingProfile": "Erreur lors de la création du profil : {error}", - "onboarding_welcome_getStarted": "Commencer", - "onboarding_welcome_nameHint": "Entrez votre nom", - "onboarding_welcome_nameLabel": "Votre nom", - "onboarding_welcome_nameValidation": "Veuillez entrer votre nom", - "onboarding_welcome_subtitle": "Enregistrement et analyse avancés de plongée", - "onboarding_welcome_title": "Bienvenue dans Submersion", "plannerCanvas_bailout_available": "Disponible {liters}", "plannerCanvas_bailout_insufficient": "Gaz de bailout insuffisant pour le pire cas", "plannerCanvas_bailout_required": "Nécessaire {liters}", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index ef8a84478..4817cd0cc 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "מסנכרן את הספרייה המשוחזרת שלך עם הענן…", "settings_cloudSync_postRestore_synced": "הספרייה המשוחזרת סונכרנה.", "settings_cloudSync_replace_reviewAction": "סקירה", + "settings_manage_setupAssistant": "אשף ההגדרה", + "settings_manage_setupAssistant_subtitle": "עיון מחדש ביחידות, במראה ובאפשרויות הגיבוי", + "setup_backup_cloudCopy": "שמירת עותק גיבוי בענן", + "setup_backup_frequency": "תדירות", + "setup_backup_frequency_daily": "יומי", + "setup_backup_frequency_monthly": "חודשי", + "setup_backup_frequency_weekly": "שבועי", + "setup_backup_scheduleSubtitle": "גיבוי יומן הצלילה לפי לוח זמנים", + "setup_backup_scheduleToggle": "גיבויים אוטומטיים", + "setup_backup_subtitle": "הגנו על יומן הצלילה שלכם מהיום הראשון.", + "setup_backup_title": "גיבויים וסנכרון", + "setup_common_back": "חזרה", + "setup_common_next": "הבא", + "setup_common_skip": "דילוג", + "setup_existing_folder_subtitle": "הפנו את Submersion לתיקייה שכבר מכילה ספרייה", + "setup_existing_folder_title": "פתיחת תיקייה קיימת", + "setup_existing_restore_subtitle": "בחרו קובץ גיבוי שיוצא מ-Submersion", + "setup_existing_restore_title": "שחזור קובץ גיבוי", + "setup_existing_subtitle": "בחרו כיצד לטעון את ספריית Submersion הקיימת שלכם", + "setup_existing_sync_subtitle": "משכו את הספרייה שלכם מ-iCloud, מ-Dropbox או מ-S3", + "setup_existing_sync_title": "חיבור סנכרון ענן", + "setup_existing_title": "הביאו את הנתונים שלכם", + "setup_finish_applying": "מגדיר...", + "setup_finish_error": "לא ניתן להשלים את ההגדרה: {error}", + "setup_finish_feature_diveComputer": "הורדת צלילות ממחשב הצלילה", + "setup_finish_feature_gear": "מעקב אחר ציוד ומועדי טיפולים", + "setup_finish_feature_import": "ייבוא יומנים מקבצים ומאפליקציות אחרות", + "setup_finish_feature_sites": "מיפוי אתרי הצלילה שלכם", + "setup_finish_feature_statistics": "חקר סטטיסטיקות על הצלילות שלכם", + "setup_finish_start": "התחלת רישום", + "setup_finish_subtitle": "Submersion יכול גם...", + "setup_finish_title": "הכול מוכן", + "setup_folder_notFound_message": "התיקייה שנבחרה אינה מכילה מסד נתונים של Submersion.", + "setup_folder_notFound_title": "אין ספרייה בתיקייה זו", + "setup_folder_pick": "בחירת תיקייה", + "setup_folder_switching": "פותח ספרייה...", + "setup_folder_title": "פתיחת תיקייה קיימת", + "setup_profile_nameHint": "הזן את שמך", + "setup_profile_nameLabel": "השם שלך", + "setup_profile_nameValidation": "נא להזין את שמך", + "setup_profile_subtitle": "הזן את שמך כדי להתחיל. תוכל להוסיף פרטים נוספים מאוחר יותר.", + "setup_profile_title": "צור את הפרופיל שלך", + "setup_restore_inProgress": "משחזר...", + "setup_restore_pick": "בחירת קובץ גיבוי", + "setup_restore_title": "שחזור גיבוי", + "setup_step_backup": "גיבוי", + "setup_step_finish": "סיום", + "setup_step_profile": "פרופיל", + "setup_step_units": "יחידות", + "setup_syncPull_continue": "המשך", + "setup_syncPull_noLibrary_message": "לא נמצאה ספריית Submersion בחשבון זה. להתחיל מחדש? החיבור יישמר.", + "setup_syncPull_noLibrary_title": "לא נמצאה ספרייה", + "setup_syncPull_success": "הספרייה אומצה", + "setup_syncPull_syncing": "מושך את הספרייה...", + "setup_syncPull_title": "חיבור ומשיכה", + "setup_sync_changeProvider": "החלפת ספק", + "setup_sync_connectedTo": "מחובר אל {provider}", + "setup_sync_error": "החיבור נכשל: {error}", + "setup_sync_header": "סנכרון ענן", + "setup_sync_icloudUnavailable": "iCloud אינו זמין במכשיר זה", + "setup_sync_libraryFound_adopt": "אימוץ הספרייה הקיימת", + "setup_sync_libraryFound_keepFresh": "התחלה חדשה", + "setup_sync_libraryFound_message": "חשבון זה כבר מכיל ספריית Submersion. לאמץ אותה במקום להתחיל מחדש?", + "setup_sync_libraryFound_title": "נמצאה ספרייה קיימת", + "setup_sync_manageInSettings": "ניהול בהגדרות", + "setup_sync_notConnected": "לא מחובר", + "setup_sync_subtitle": "סנכרון היומן בין מכשירים", + "setup_units_advanced": "כוונון יחידות", + "setup_units_altitude": "גובה", + "setup_units_dateFormat": "תבנית תאריך", + "setup_units_depth": "עומק", + "setup_units_imperial": "אימפריאלי", + "setup_units_metric": "מטרי", + "setup_units_pressure": "לחץ", + "setup_units_sac": "קצב SAC", + "setup_units_subtitle": "בחרו כיצד יוצגו המדידות. ניתן לכוונן כל יחידה.", + "setup_units_temperature": "טמפרטורה", + "setup_units_timeFormat": "תבנית שעה", + "setup_units_title": "יחידות", + "setup_units_volume": "נפח", + "setup_units_weight": "משקל", + "setup_welcome_existingData_subtitle": "שחזרו גיבוי, חברו סנכרון ענן או פתחו תיקייה קיימת", + "setup_welcome_existingData_title": "יש לי כבר נתוני Submersion", + "setup_welcome_skipSetup": "דילוג על ההגדרה", + "setup_welcome_startFresh_subtitle": "צרו פרופיל צולל והגדירו את האפליקציה", + "setup_welcome_startFresh_title": "הגדרת יומן חדש", + "setup_welcome_subtitle": "רישום וניתוח צלילה מתקדם", + "setup_welcome_title": "ברוכים הבאים ל-Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3325,16 +3413,6 @@ "nav_tooltip_expandMenu": "הרחבת תפריט", "nav_transfer": "העברה", "nav_trips": "טיולים", - "onboarding_welcome_createProfile": "צור את הפרופיל שלך", - "onboarding_welcome_createProfileSubtitle": "הזן את שמך כדי להתחיל. תוכל להוסיף פרטים נוספים מאוחר יותר.", - "onboarding_welcome_creating": "יוצר...", - "onboarding_welcome_errorCreatingProfile": "שגיאה ביצירת פרופיל: {error}", - "onboarding_welcome_getStarted": "התחל", - "onboarding_welcome_nameHint": "הזן את שמך", - "onboarding_welcome_nameLabel": "השם שלך", - "onboarding_welcome_nameValidation": "נא להזין את שמך", - "onboarding_welcome_subtitle": "רישום וניתוח צלילה מתקדם", - "onboarding_welcome_title": "ברוכים הבאים ל-Submersion", "plannerCanvas_bailout_available": "זמין {liters}", "plannerCanvas_bailout_insufficient": "גז חילוץ אינו מספיק למקרה הגרוע ביותר", "plannerCanvas_bailout_required": "נדרש {liters}", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index a3bfb4716..575c6f728 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "A visszaállított könyvtár szinkronizálása a felhővel…", "settings_cloudSync_postRestore_synced": "A visszaállított könyvtár szinkronizálva.", "settings_cloudSync_replace_reviewAction": "Áttekintés", + "settings_manage_setupAssistant": "Beállítási varázsló", + "settings_manage_setupAssistant_subtitle": "Mértékegységek, megjelenés és biztonsági mentés áttekintése", + "setup_backup_cloudCopy": "Biztonsági másolat megőrzése a felhőben", + "setup_backup_frequency": "Gyakoriság", + "setup_backup_frequency_daily": "Naponta", + "setup_backup_frequency_monthly": "Havonta", + "setup_backup_frequency_weekly": "Hetente", + "setup_backup_scheduleSubtitle": "Naplója rendszeres biztonsági mentése", + "setup_backup_scheduleToggle": "Automatikus biztonsági mentés", + "setup_backup_subtitle": "Védje merülési naplóját az első naptól kezdve.", + "setup_backup_title": "Biztonsági mentés és szinkronizálás", + "setup_common_back": "Vissza", + "setup_common_next": "Tovább", + "setup_common_skip": "Kihagyás", + "setup_existing_folder_subtitle": "Irányítsa a Submersiont egy olyan mappára, amely már tartalmaz könyvtárat", + "setup_existing_folder_title": "Meglévő mappa megnyitása", + "setup_existing_restore_subtitle": "Válasszon egy Submersionból exportált biztonságimentés-fájlt", + "setup_existing_restore_title": "Biztonsági mentés visszaállítása", + "setup_existing_subtitle": "Válassza ki, hogyan töltse be meglévő Submersion-könyvtárát", + "setup_existing_sync_subtitle": "Könyvtár letöltése iCloudból, Dropboxból vagy S3-ból", + "setup_existing_sync_title": "Felhőszinkronizálás csatlakoztatása", + "setup_existing_title": "Adatok áthozása", + "setup_finish_applying": "Beállítás...", + "setup_finish_error": "A beállítás nem fejeződött be: {error}", + "setup_finish_feature_diveComputer": "Merülések letöltése a búvárkomputerről", + "setup_finish_feature_gear": "Felszerelés és szervizintervallumok nyomon követése", + "setup_finish_feature_import": "Naplók importálása fájlokból és más alkalmazásokból", + "setup_finish_feature_sites": "Merülőhelyek megjelenítése térképen", + "setup_finish_feature_statistics": "Merülési statisztikák felfedezése", + "setup_finish_start": "Naplózás indítása", + "setup_finish_subtitle": "A Submersion ezenkívül...", + "setup_finish_title": "Minden készen áll", + "setup_folder_notFound_message": "A kiválasztott mappa nem tartalmaz Submersion-adatbázist.", + "setup_folder_notFound_title": "Nincs könyvtár ebben a mappában", + "setup_folder_pick": "Mappa kiválasztása", + "setup_folder_switching": "Könyvtár megnyitása...", + "setup_folder_title": "Meglévő mappa megnyitása", + "setup_profile_nameHint": "Add meg a neved", + "setup_profile_nameLabel": "Neved", + "setup_profile_nameValidation": "Add meg a neved", + "setup_profile_subtitle": "Add meg a neved a kezdéshez. Később további részleteket adhatsz hozzá.", + "setup_profile_title": "Profil létrehozása", + "setup_restore_inProgress": "Visszaállítás...", + "setup_restore_pick": "Biztonságimentés-fájl kiválasztása", + "setup_restore_title": "Biztonsági mentés visszaállítása", + "setup_step_backup": "Mentés", + "setup_step_finish": "Kész", + "setup_step_profile": "Profil", + "setup_step_units": "Egységek", + "setup_syncPull_continue": "Folytatás", + "setup_syncPull_noLibrary_message": "Ezen a fiókon nem található Submersion-könyvtár. Új kezdés? A kapcsolat megmarad.", + "setup_syncPull_noLibrary_title": "Nem található könyvtár", + "setup_syncPull_success": "Könyvtár átvéve", + "setup_syncPull_syncing": "Könyvtár letöltése...", + "setup_syncPull_title": "Csatlakozás és letöltés", + "setup_sync_changeProvider": "Szolgáltató módosítása", + "setup_sync_connectedTo": "Csatlakoztatva: {provider}", + "setup_sync_error": "Nem sikerült csatlakozni: {error}", + "setup_sync_header": "Felhőszinkronizálás", + "setup_sync_icloudUnavailable": "Az iCloud nem érhető el ezen az eszközön", + "setup_sync_libraryFound_adopt": "Meglévő könyvtár átvétele", + "setup_sync_libraryFound_keepFresh": "Új kezdés", + "setup_sync_libraryFound_message": "Ez a fiók már tartalmaz Submersion-könyvtárat. Átveszi ahelyett, hogy újat kezdene?", + "setup_sync_libraryFound_title": "Meglévő könyvtár található", + "setup_sync_manageInSettings": "Kezelés a beállításokban", + "setup_sync_notConnected": "Nincs csatlakoztatva", + "setup_sync_subtitle": "Napló szinkronizálása az eszközök között", + "setup_units_advanced": "Egységek finomhangolása", + "setup_units_altitude": "Magasság", + "setup_units_dateFormat": "Dátumformátum", + "setup_units_depth": "Mélység", + "setup_units_imperial": "Angolszász", + "setup_units_metric": "Metrikus", + "setup_units_pressure": "Nyomás", + "setup_units_sac": "SAC-érték", + "setup_units_subtitle": "Válassza ki a mértékegységek megjelenítését. Minden egység külön beállítható.", + "setup_units_temperature": "Hőmérséklet", + "setup_units_timeFormat": "Időformátum", + "setup_units_title": "Egységek", + "setup_units_volume": "Térfogat", + "setup_units_weight": "Súly", + "setup_welcome_existingData_subtitle": "Biztonsági mentés visszaállítása, felhőszinkronizálás vagy meglévő mappa megnyitása", + "setup_welcome_existingData_title": "Már vannak Submersion-adataim", + "setup_welcome_skipSetup": "Beállítás kihagyása", + "setup_welcome_startFresh_subtitle": "Búvárprofil létrehozása és az alkalmazás beállítása", + "setup_welcome_startFresh_title": "Új napló beállítása", + "setup_welcome_subtitle": "Fejlett merülés napló és elemzés", + "setup_welcome_title": "Üdvözöl a Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3325,16 +3413,6 @@ "nav_tooltip_expandMenu": "Menu kinyitasa", "nav_transfer": "Atvitel", "nav_trips": "Utak", - "onboarding_welcome_createProfile": "Profil létrehozása", - "onboarding_welcome_createProfileSubtitle": "Add meg a neved a kezdéshez. Később további részleteket adhatsz hozzá.", - "onboarding_welcome_creating": "Létrehozás...", - "onboarding_welcome_errorCreatingProfile": "Hiba a profil létrehozásakor: {error}", - "onboarding_welcome_getStarted": "Kezdés", - "onboarding_welcome_nameHint": "Add meg a neved", - "onboarding_welcome_nameLabel": "Neved", - "onboarding_welcome_nameValidation": "Add meg a neved", - "onboarding_welcome_subtitle": "Fejlett merülés napló és elemzés", - "onboarding_welcome_title": "Üdvözöl a Submersion", "plannerCanvas_bailout_available": "Elérhető {liters}", "plannerCanvas_bailout_insufficient": "A bailout gáz nem elegendő a legrosszabb esethez", "plannerCanvas_bailout_required": "Szükséges {liters}", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 0798d31f3..c7aeae0e2 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "Sincronizzazione della libreria ripristinata con il cloud…", "settings_cloudSync_postRestore_synced": "Libreria ripristinata sincronizzata.", "settings_cloudSync_replace_reviewAction": "Rivedi", + "settings_manage_setupAssistant": "Assistente di configurazione", + "settings_manage_setupAssistant_subtitle": "Rivedi unità, aspetto e opzioni di backup", + "setup_backup_cloudCopy": "Conserva una copia di backup nel cloud", + "setup_backup_frequency": "Frequenza", + "setup_backup_frequency_daily": "Giornaliera", + "setup_backup_frequency_monthly": "Mensile", + "setup_backup_frequency_weekly": "Settimanale", + "setup_backup_scheduleSubtitle": "Esegui il backup del tuo logbook a intervalli regolari", + "setup_backup_scheduleToggle": "Backup automatici", + "setup_backup_subtitle": "Proteggi il tuo registro immersioni fin dal primo giorno.", + "setup_backup_title": "Backup e sincronizzazione", + "setup_common_back": "Indietro", + "setup_common_next": "Avanti", + "setup_common_skip": "Salta", + "setup_existing_folder_subtitle": "Indica a Submersion una cartella che contiene già una libreria", + "setup_existing_folder_title": "Apri una cartella esistente", + "setup_existing_restore_subtitle": "Scegli un file di backup esportato da Submersion", + "setup_existing_restore_title": "Ripristina un file di backup", + "setup_existing_subtitle": "Scegli come caricare la tua libreria Submersion esistente", + "setup_existing_sync_subtitle": "Scarica la tua libreria da iCloud, Dropbox o S3", + "setup_existing_sync_title": "Collega la sincronizzazione cloud", + "setup_existing_title": "Porta i tuoi dati", + "setup_finish_applying": "Configurazione...", + "setup_finish_error": "Impossibile completare la configurazione: {error}", + "setup_finish_feature_diveComputer": "Scarica le immersioni dal tuo computer subacqueo", + "setup_finish_feature_gear": "Tieni traccia dell'attrezzatura e delle scadenze di manutenzione", + "setup_finish_feature_import": "Importa registri da file e altre app", + "setup_finish_feature_sites": "Visualizza i tuoi punti di immersione sulla mappa", + "setup_finish_feature_statistics": "Esplora le statistiche delle tue immersioni", + "setup_finish_start": "Inizia a registrare", + "setup_finish_subtitle": "Submersion può anche...", + "setup_finish_title": "Tutto pronto", + "setup_folder_notFound_message": "La cartella selezionata non contiene un database Submersion.", + "setup_folder_notFound_title": "Nessuna libreria in quella cartella", + "setup_folder_pick": "Scegli cartella", + "setup_folder_switching": "Apertura della libreria...", + "setup_folder_title": "Apri cartella esistente", + "setup_profile_nameHint": "Inserisci il tuo nome", + "setup_profile_nameLabel": "Il Tuo Nome", + "setup_profile_nameValidation": "Inserisci il tuo nome", + "setup_profile_subtitle": "Inserisci il tuo nome per iniziare. Potrai aggiungere altri dettagli in seguito.", + "setup_profile_title": "Crea il Tuo Profilo", + "setup_restore_inProgress": "Ripristino...", + "setup_restore_pick": "Scegli file di backup", + "setup_restore_title": "Ripristina backup", + "setup_step_backup": "Backup", + "setup_step_finish": "Fine", + "setup_step_profile": "Profilo", + "setup_step_units": "Unità", + "setup_syncPull_continue": "Continua", + "setup_syncPull_noLibrary_message": "Nessuna libreria Submersion trovata su questo account. Iniziare da zero? La connessione verrà mantenuta.", + "setup_syncPull_noLibrary_title": "Nessuna libreria trovata", + "setup_syncPull_success": "Libreria adottata", + "setup_syncPull_syncing": "Download della libreria...", + "setup_syncPull_title": "Connetti e scarica", + "setup_sync_changeProvider": "Cambia provider", + "setup_sync_connectedTo": "Connesso a {provider}", + "setup_sync_error": "Impossibile connettersi: {error}", + "setup_sync_header": "Sincronizzazione cloud", + "setup_sync_icloudUnavailable": "iCloud non è disponibile su questo dispositivo", + "setup_sync_libraryFound_adopt": "Adotta la libreria esistente", + "setup_sync_libraryFound_keepFresh": "Inizia da zero", + "setup_sync_libraryFound_message": "Questo account contiene già una libreria Submersion. Adottarla invece di iniziare da zero?", + "setup_sync_libraryFound_title": "Trovata libreria esistente", + "setup_sync_manageInSettings": "Gestisci nelle impostazioni", + "setup_sync_notConnected": "Non connesso", + "setup_sync_subtitle": "Sincronizza il tuo logbook su tutti i dispositivi", + "setup_units_advanced": "Regola le unità", + "setup_units_altitude": "Altitudine", + "setup_units_dateFormat": "Formato data", + "setup_units_depth": "Profondità", + "setup_units_imperial": "Imperiale", + "setup_units_metric": "Metrico", + "setup_units_pressure": "Pressione", + "setup_units_sac": "Tasso SAC", + "setup_units_subtitle": "Scegli come visualizzare le misure. Puoi regolare ogni unità.", + "setup_units_temperature": "Temperatura", + "setup_units_timeFormat": "Formato ora", + "setup_units_title": "Unità", + "setup_units_volume": "Volume", + "setup_units_weight": "Peso", + "setup_welcome_existingData_subtitle": "Ripristina un backup, collega la sincronizzazione o apri una cartella esistente", + "setup_welcome_existingData_title": "Ho già dati di Submersion", + "setup_welcome_skipSetup": "Salta configurazione", + "setup_welcome_startFresh_subtitle": "Crea il tuo profilo subacqueo e configura l'app", + "setup_welcome_startFresh_title": "Configura un nuovo logbook", + "setup_welcome_subtitle": "Registrazione e analisi avanzata delle immersioni", + "setup_welcome_title": "Benvenuto in Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3325,16 +3413,6 @@ "nav_tooltip_expandMenu": "Espandi menu", "nav_transfer": "Trasferimento", "nav_trips": "Viaggi", - "onboarding_welcome_createProfile": "Crea il Tuo Profilo", - "onboarding_welcome_createProfileSubtitle": "Inserisci il tuo nome per iniziare. Potrai aggiungere altri dettagli in seguito.", - "onboarding_welcome_creating": "Creazione...", - "onboarding_welcome_errorCreatingProfile": "Errore durante la creazione del profilo: {error}", - "onboarding_welcome_getStarted": "Inizia", - "onboarding_welcome_nameHint": "Inserisci il tuo nome", - "onboarding_welcome_nameLabel": "Il Tuo Nome", - "onboarding_welcome_nameValidation": "Inserisci il tuo nome", - "onboarding_welcome_subtitle": "Registrazione e analisi avanzata delle immersioni", - "onboarding_welcome_title": "Benvenuto in Submersion", "plannerCanvas_bailout_available": "Disponibile {liters}", "plannerCanvas_bailout_insufficient": "Gas di bailout insufficiente per il caso peggiore", "plannerCanvas_bailout_required": "Necessario {liters}", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index e8d6fb7a3..5eb4b76ba 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -8450,6 +8450,534 @@ abstract class AppLocalizations { /// **'{count, plural, one{1 issue} other{{count} issues}}'** String forms_section_issues(int count); + /// No description provided for @settings_manage_setupAssistant. + /// + /// In en, this message translates to: + /// **'Setup assistant'** + String get settings_manage_setupAssistant; + + /// No description provided for @settings_manage_setupAssistant_subtitle. + /// + /// In en, this message translates to: + /// **'Revisit units, appearance, and backup choices'** + String get settings_manage_setupAssistant_subtitle; + + /// No description provided for @setup_backup_cloudCopy. + /// + /// In en, this message translates to: + /// **'Keep a backup copy in the cloud'** + String get setup_backup_cloudCopy; + + /// No description provided for @setup_backup_frequency. + /// + /// In en, this message translates to: + /// **'Frequency'** + String get setup_backup_frequency; + + /// No description provided for @setup_backup_frequency_daily. + /// + /// In en, this message translates to: + /// **'Daily'** + String get setup_backup_frequency_daily; + + /// No description provided for @setup_backup_frequency_monthly. + /// + /// In en, this message translates to: + /// **'Monthly'** + String get setup_backup_frequency_monthly; + + /// No description provided for @setup_backup_frequency_weekly. + /// + /// In en, this message translates to: + /// **'Weekly'** + String get setup_backup_frequency_weekly; + + /// No description provided for @setup_backup_scheduleSubtitle. + /// + /// In en, this message translates to: + /// **'Back up your logbook on a schedule'** + String get setup_backup_scheduleSubtitle; + + /// No description provided for @setup_backup_scheduleToggle. + /// + /// In en, this message translates to: + /// **'Automatic backups'** + String get setup_backup_scheduleToggle; + + /// No description provided for @setup_backup_subtitle. + /// + /// In en, this message translates to: + /// **'Protect your dive log from day one.'** + String get setup_backup_subtitle; + + /// No description provided for @setup_backup_title. + /// + /// In en, this message translates to: + /// **'Backups & Sync'** + String get setup_backup_title; + + /// No description provided for @setup_common_back. + /// + /// In en, this message translates to: + /// **'Back'** + String get setup_common_back; + + /// No description provided for @setup_common_next. + /// + /// In en, this message translates to: + /// **'Next'** + String get setup_common_next; + + /// No description provided for @setup_common_skip. + /// + /// In en, this message translates to: + /// **'Skip'** + String get setup_common_skip; + + /// No description provided for @setup_existing_folder_subtitle. + /// + /// In en, this message translates to: + /// **'Point Submersion at a folder that already contains a library'** + String get setup_existing_folder_subtitle; + + /// No description provided for @setup_existing_folder_title. + /// + /// In en, this message translates to: + /// **'Open an existing folder'** + String get setup_existing_folder_title; + + /// No description provided for @setup_existing_restore_subtitle. + /// + /// In en, this message translates to: + /// **'Pick a backup file exported from Submersion'** + String get setup_existing_restore_subtitle; + + /// No description provided for @setup_existing_restore_title. + /// + /// In en, this message translates to: + /// **'Restore a backup file'** + String get setup_existing_restore_title; + + /// No description provided for @setup_existing_subtitle. + /// + /// In en, this message translates to: + /// **'Choose how to load your existing Submersion library'** + String get setup_existing_subtitle; + + /// No description provided for @setup_existing_sync_subtitle. + /// + /// In en, this message translates to: + /// **'Pull your library from iCloud, Dropbox, or S3'** + String get setup_existing_sync_subtitle; + + /// No description provided for @setup_existing_sync_title. + /// + /// In en, this message translates to: + /// **'Connect cloud sync'** + String get setup_existing_sync_title; + + /// No description provided for @setup_existing_title. + /// + /// In en, this message translates to: + /// **'Bring your data'** + String get setup_existing_title; + + /// No description provided for @setup_finish_applying. + /// + /// In en, this message translates to: + /// **'Setting up...'** + String get setup_finish_applying; + + /// No description provided for @setup_finish_error. + /// + /// In en, this message translates to: + /// **'Could not complete setup: {error}'** + String setup_finish_error(Object error); + + /// No description provided for @setup_finish_feature_diveComputer. + /// + /// In en, this message translates to: + /// **'Download dives from your dive computer'** + String get setup_finish_feature_diveComputer; + + /// No description provided for @setup_finish_feature_gear. + /// + /// In en, this message translates to: + /// **'Track gear and service intervals'** + String get setup_finish_feature_gear; + + /// No description provided for @setup_finish_feature_import. + /// + /// In en, this message translates to: + /// **'Import logs from files and other apps'** + String get setup_finish_feature_import; + + /// No description provided for @setup_finish_feature_sites. + /// + /// In en, this message translates to: + /// **'Map your dive sites'** + String get setup_finish_feature_sites; + + /// No description provided for @setup_finish_feature_statistics. + /// + /// In en, this message translates to: + /// **'Explore statistics about your diving'** + String get setup_finish_feature_statistics; + + /// No description provided for @setup_finish_start. + /// + /// In en, this message translates to: + /// **'Start logging'** + String get setup_finish_start; + + /// No description provided for @setup_finish_subtitle. + /// + /// In en, this message translates to: + /// **'Submersion can also...'** + String get setup_finish_subtitle; + + /// No description provided for @setup_finish_title. + /// + /// In en, this message translates to: + /// **'You\'re all set'** + String get setup_finish_title; + + /// No description provided for @setup_folder_notFound_message. + /// + /// In en, this message translates to: + /// **'The selected folder does not contain a Submersion database.'** + String get setup_folder_notFound_message; + + /// No description provided for @setup_folder_notFound_title. + /// + /// In en, this message translates to: + /// **'No library in that folder'** + String get setup_folder_notFound_title; + + /// No description provided for @setup_folder_pick. + /// + /// In en, this message translates to: + /// **'Choose folder'** + String get setup_folder_pick; + + /// No description provided for @setup_folder_switching. + /// + /// In en, this message translates to: + /// **'Opening library...'** + String get setup_folder_switching; + + /// No description provided for @setup_folder_title. + /// + /// In en, this message translates to: + /// **'Open existing folder'** + String get setup_folder_title; + + /// No description provided for @setup_profile_nameHint. + /// + /// In en, this message translates to: + /// **'Enter your name'** + String get setup_profile_nameHint; + + /// No description provided for @setup_profile_nameLabel. + /// + /// In en, this message translates to: + /// **'Your Name'** + String get setup_profile_nameLabel; + + /// No description provided for @setup_profile_nameValidation. + /// + /// In en, this message translates to: + /// **'Please enter your name'** + String get setup_profile_nameValidation; + + /// No description provided for @setup_profile_subtitle. + /// + /// In en, this message translates to: + /// **'Enter your name to get started. You can add more details later.'** + String get setup_profile_subtitle; + + /// No description provided for @setup_profile_title. + /// + /// In en, this message translates to: + /// **'Create Your Profile'** + String get setup_profile_title; + + /// No description provided for @setup_restore_inProgress. + /// + /// In en, this message translates to: + /// **'Restoring...'** + String get setup_restore_inProgress; + + /// No description provided for @setup_restore_pick. + /// + /// In en, this message translates to: + /// **'Choose backup file'** + String get setup_restore_pick; + + /// No description provided for @setup_restore_title. + /// + /// In en, this message translates to: + /// **'Restore backup'** + String get setup_restore_title; + + /// No description provided for @setup_step_backup. + /// + /// In en, this message translates to: + /// **'Backup'** + String get setup_step_backup; + + /// No description provided for @setup_step_finish. + /// + /// In en, this message translates to: + /// **'Done'** + String get setup_step_finish; + + /// No description provided for @setup_step_profile. + /// + /// In en, this message translates to: + /// **'Profile'** + String get setup_step_profile; + + /// No description provided for @setup_step_units. + /// + /// In en, this message translates to: + /// **'Units'** + String get setup_step_units; + + /// No description provided for @setup_syncPull_continue. + /// + /// In en, this message translates to: + /// **'Continue'** + String get setup_syncPull_continue; + + /// No description provided for @setup_syncPull_noLibrary_message. + /// + /// In en, this message translates to: + /// **'No existing Submersion library was found on this account. Start fresh instead? Your connection will be kept.'** + String get setup_syncPull_noLibrary_message; + + /// No description provided for @setup_syncPull_noLibrary_title. + /// + /// In en, this message translates to: + /// **'No library found'** + String get setup_syncPull_noLibrary_title; + + /// No description provided for @setup_syncPull_success. + /// + /// In en, this message translates to: + /// **'Library adopted'** + String get setup_syncPull_success; + + /// No description provided for @setup_syncPull_syncing. + /// + /// In en, this message translates to: + /// **'Pulling your library...'** + String get setup_syncPull_syncing; + + /// No description provided for @setup_syncPull_title. + /// + /// In en, this message translates to: + /// **'Connect and pull'** + String get setup_syncPull_title; + + /// No description provided for @setup_sync_changeProvider. + /// + /// In en, this message translates to: + /// **'Change provider'** + String get setup_sync_changeProvider; + + /// No description provided for @setup_sync_connectedTo. + /// + /// In en, this message translates to: + /// **'Connected to {provider}'** + String setup_sync_connectedTo(String provider); + + /// No description provided for @setup_sync_error. + /// + /// In en, this message translates to: + /// **'Could not connect: {error}'** + String setup_sync_error(Object error); + + /// No description provided for @setup_sync_header. + /// + /// In en, this message translates to: + /// **'Cloud sync'** + String get setup_sync_header; + + /// No description provided for @setup_sync_icloudUnavailable. + /// + /// In en, this message translates to: + /// **'iCloud is not available on this device'** + String get setup_sync_icloudUnavailable; + + /// No description provided for @setup_sync_libraryFound_adopt. + /// + /// In en, this message translates to: + /// **'Adopt existing library'** + String get setup_sync_libraryFound_adopt; + + /// No description provided for @setup_sync_libraryFound_keepFresh. + /// + /// In en, this message translates to: + /// **'Start fresh'** + String get setup_sync_libraryFound_keepFresh; + + /// No description provided for @setup_sync_libraryFound_message. + /// + /// In en, this message translates to: + /// **'This account already contains a Submersion library. Adopt it instead of starting fresh?'** + String get setup_sync_libraryFound_message; + + /// No description provided for @setup_sync_libraryFound_title. + /// + /// In en, this message translates to: + /// **'Existing library found'** + String get setup_sync_libraryFound_title; + + /// No description provided for @setup_sync_manageInSettings. + /// + /// In en, this message translates to: + /// **'Manage in Settings'** + String get setup_sync_manageInSettings; + + /// No description provided for @setup_sync_notConnected. + /// + /// In en, this message translates to: + /// **'Not connected'** + String get setup_sync_notConnected; + + /// No description provided for @setup_sync_subtitle. + /// + /// In en, this message translates to: + /// **'Sync your logbook across devices'** + String get setup_sync_subtitle; + + /// No description provided for @setup_units_advanced. + /// + /// In en, this message translates to: + /// **'Fine-tune units'** + String get setup_units_advanced; + + /// No description provided for @setup_units_altitude. + /// + /// In en, this message translates to: + /// **'Altitude'** + String get setup_units_altitude; + + /// No description provided for @setup_units_dateFormat. + /// + /// In en, this message translates to: + /// **'Date format'** + String get setup_units_dateFormat; + + /// No description provided for @setup_units_depth. + /// + /// In en, this message translates to: + /// **'Depth'** + String get setup_units_depth; + + /// No description provided for @setup_units_imperial. + /// + /// In en, this message translates to: + /// **'Imperial'** + String get setup_units_imperial; + + /// No description provided for @setup_units_metric. + /// + /// In en, this message translates to: + /// **'Metric'** + String get setup_units_metric; + + /// No description provided for @setup_units_pressure. + /// + /// In en, this message translates to: + /// **'Pressure'** + String get setup_units_pressure; + + /// No description provided for @setup_units_sac. + /// + /// In en, this message translates to: + /// **'SAC rate'** + String get setup_units_sac; + + /// No description provided for @setup_units_subtitle. + /// + /// In en, this message translates to: + /// **'Choose how measurements are displayed. You can fine-tune each unit.'** + String get setup_units_subtitle; + + /// No description provided for @setup_units_temperature. + /// + /// In en, this message translates to: + /// **'Temperature'** + String get setup_units_temperature; + + /// No description provided for @setup_units_timeFormat. + /// + /// In en, this message translates to: + /// **'Time format'** + String get setup_units_timeFormat; + + /// No description provided for @setup_units_title. + /// + /// In en, this message translates to: + /// **'Units'** + String get setup_units_title; + + /// No description provided for @setup_units_volume. + /// + /// In en, this message translates to: + /// **'Volume'** + String get setup_units_volume; + + /// No description provided for @setup_units_weight. + /// + /// In en, this message translates to: + /// **'Weight'** + String get setup_units_weight; + + /// No description provided for @setup_welcome_existingData_subtitle. + /// + /// In en, this message translates to: + /// **'Restore a backup, connect cloud sync, or open an existing folder'** + String get setup_welcome_existingData_subtitle; + + /// No description provided for @setup_welcome_existingData_title. + /// + /// In en, this message translates to: + /// **'I have existing Submersion data'** + String get setup_welcome_existingData_title; + + /// No description provided for @setup_welcome_skipSetup. + /// + /// In en, this message translates to: + /// **'Skip setup'** + String get setup_welcome_skipSetup; + + /// No description provided for @setup_welcome_startFresh_subtitle. + /// + /// In en, this message translates to: + /// **'Create your diver profile and configure the app'** + String get setup_welcome_startFresh_subtitle; + + /// No description provided for @setup_welcome_startFresh_title. + /// + /// In en, this message translates to: + /// **'Set up a new logbook'** + String get setup_welcome_startFresh_title; + + /// No description provided for @setup_welcome_subtitle. + /// + /// In en, this message translates to: + /// **'Advanced dive logging and analytics'** + String get setup_welcome_subtitle; + + /// No description provided for @setup_welcome_title. + /// + /// In en, this message translates to: + /// **'Welcome to Submersion'** + String get setup_welcome_title; + /// No description provided for @siteMatchReview_title. /// /// In en, this message translates to: @@ -18068,66 +18596,6 @@ abstract class AppLocalizations { /// **'Trips'** String get nav_trips; - /// No description provided for @onboarding_welcome_createProfile. - /// - /// In en, this message translates to: - /// **'Create Your Profile'** - String get onboarding_welcome_createProfile; - - /// No description provided for @onboarding_welcome_createProfileSubtitle. - /// - /// In en, this message translates to: - /// **'Enter your name to get started. You can add more details later.'** - String get onboarding_welcome_createProfileSubtitle; - - /// No description provided for @onboarding_welcome_creating. - /// - /// In en, this message translates to: - /// **'Creating...'** - String get onboarding_welcome_creating; - - /// No description provided for @onboarding_welcome_errorCreatingProfile. - /// - /// In en, this message translates to: - /// **'Error creating profile: {error}'** - String onboarding_welcome_errorCreatingProfile(Object error); - - /// No description provided for @onboarding_welcome_getStarted. - /// - /// In en, this message translates to: - /// **'Get Started'** - String get onboarding_welcome_getStarted; - - /// No description provided for @onboarding_welcome_nameHint. - /// - /// In en, this message translates to: - /// **'Enter your name'** - String get onboarding_welcome_nameHint; - - /// No description provided for @onboarding_welcome_nameLabel. - /// - /// In en, this message translates to: - /// **'Your Name'** - String get onboarding_welcome_nameLabel; - - /// No description provided for @onboarding_welcome_nameValidation. - /// - /// In en, this message translates to: - /// **'Please enter your name'** - String get onboarding_welcome_nameValidation; - - /// No description provided for @onboarding_welcome_subtitle. - /// - /// In en, this message translates to: - /// **'Advanced dive logging and analytics'** - String get onboarding_welcome_subtitle; - - /// No description provided for @onboarding_welcome_title. - /// - /// In en, this message translates to: - /// **'Welcome to Submersion'** - String get onboarding_welcome_title; - /// No description provided for @plannerCanvas_bailout_available. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index c7a0e4fc4..654360611 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -4902,6 +4902,292 @@ class AppLocalizationsAr extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'مساعد الإعداد'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'مراجعة الوحدات والمظهر وخيارات النسخ الاحتياطي'; + + @override + String get setup_backup_cloudCopy => 'الاحتفاظ بنسخة احتياطية في السحابة'; + + @override + String get setup_backup_frequency => 'التكرار'; + + @override + String get setup_backup_frequency_daily => 'يوميًا'; + + @override + String get setup_backup_frequency_monthly => 'شهريًا'; + + @override + String get setup_backup_frequency_weekly => 'أسبوعيًا'; + + @override + String get setup_backup_scheduleSubtitle => + 'انسخ سجل غوصك احتياطيًا وفق جدول زمني'; + + @override + String get setup_backup_scheduleToggle => 'نسخ احتياطي تلقائي'; + + @override + String get setup_backup_subtitle => 'احمِ سجل غوصك من اليوم الأول.'; + + @override + String get setup_backup_title => 'النسخ الاحتياطي والمزامنة'; + + @override + String get setup_common_back => 'رجوع'; + + @override + String get setup_common_next => 'التالي'; + + @override + String get setup_common_skip => 'تخطي'; + + @override + String get setup_existing_folder_subtitle => + 'وجّه Submersion إلى مجلد يحتوي بالفعل على مكتبة'; + + @override + String get setup_existing_folder_title => 'فتح مجلد موجود'; + + @override + String get setup_existing_restore_subtitle => + 'اختر ملف نسخ احتياطي تم تصديره من Submersion'; + + @override + String get setup_existing_restore_title => 'استعادة ملف نسخ احتياطي'; + + @override + String get setup_existing_subtitle => + 'اختر طريقة تحميل مكتبة Submersion الحالية'; + + @override + String get setup_existing_sync_subtitle => + 'اسحب مكتبتك من iCloud أو Dropbox أو S3'; + + @override + String get setup_existing_sync_title => 'توصيل مزامنة السحابة'; + + @override + String get setup_existing_title => 'أحضر بياناتك'; + + @override + String get setup_finish_applying => 'جارٍ الإعداد...'; + + @override + String setup_finish_error(Object error) { + return 'تعذّر إكمال الإعداد: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'نزّل الغوصات من كمبيوتر الغوص'; + + @override + String get setup_finish_feature_gear => 'تتبّع المعدات ومواعيد الصيانة'; + + @override + String get setup_finish_feature_import => + 'استورد السجلات من الملفات والتطبيقات الأخرى'; + + @override + String get setup_finish_feature_sites => 'اعرض مواقع الغوص على الخريطة'; + + @override + String get setup_finish_feature_statistics => 'استكشف إحصاءات الغوص'; + + @override + String get setup_finish_start => 'ابدأ التسجيل'; + + @override + String get setup_finish_subtitle => 'يمكن لـ Submersion أيضًا...'; + + @override + String get setup_finish_title => 'كل شيء جاهز'; + + @override + String get setup_folder_notFound_message => + 'المجلد المحدد لا يحتوي على قاعدة بيانات Submersion.'; + + @override + String get setup_folder_notFound_title => 'لا توجد مكتبة في هذا المجلد'; + + @override + String get setup_folder_pick => 'اختيار مجلد'; + + @override + String get setup_folder_switching => 'جارٍ فتح المكتبة...'; + + @override + String get setup_folder_title => 'فتح مجلد موجود'; + + @override + String get setup_profile_nameHint => 'أدخل اسمك'; + + @override + String get setup_profile_nameLabel => 'اسمك'; + + @override + String get setup_profile_nameValidation => 'الرجاء إدخال اسمك'; + + @override + String get setup_profile_subtitle => + 'أدخل اسمك للبدء. يمكنك إضافة المزيد من التفاصيل لاحقاً.'; + + @override + String get setup_profile_title => 'إنشاء ملفك الشخصي'; + + @override + String get setup_restore_inProgress => 'جارٍ الاستعادة...'; + + @override + String get setup_restore_pick => 'اختيار ملف النسخ الاحتياطي'; + + @override + String get setup_restore_title => 'استعادة النسخة الاحتياطية'; + + @override + String get setup_step_backup => 'نسخ احتياطي'; + + @override + String get setup_step_finish => 'تم'; + + @override + String get setup_step_profile => 'الملف الشخصي'; + + @override + String get setup_step_units => 'الوحدات'; + + @override + String get setup_syncPull_continue => 'متابعة'; + + @override + String get setup_syncPull_noLibrary_message => + 'لم يتم العثور على مكتبة Submersion في هذا الحساب. هل تبدأ من جديد؟ سيتم الاحتفاظ باتصالك.'; + + @override + String get setup_syncPull_noLibrary_title => 'لم يتم العثور على مكتبة'; + + @override + String get setup_syncPull_success => 'تم اعتماد المكتبة'; + + @override + String get setup_syncPull_syncing => 'جارٍ سحب مكتبتك...'; + + @override + String get setup_syncPull_title => 'الاتصال والسحب'; + + @override + String get setup_sync_changeProvider => 'تغيير المزوّد'; + + @override + String setup_sync_connectedTo(String provider) { + return 'متصل بـ $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'تعذّر الاتصال: $error'; + } + + @override + String get setup_sync_header => 'مزامنة السحابة'; + + @override + String get setup_sync_icloudUnavailable => 'iCloud غير متاح على هذا الجهاز'; + + @override + String get setup_sync_libraryFound_adopt => 'اعتماد المكتبة الحالية'; + + @override + String get setup_sync_libraryFound_keepFresh => 'البدء من جديد'; + + @override + String get setup_sync_libraryFound_message => + 'يحتوي هذا الحساب بالفعل على مكتبة Submersion. هل تريد اعتمادها بدلًا من البدء من جديد؟'; + + @override + String get setup_sync_libraryFound_title => 'تم العثور على مكتبة موجودة'; + + @override + String get setup_sync_manageInSettings => 'الإدارة في الإعدادات'; + + @override + String get setup_sync_notConnected => 'غير متصل'; + + @override + String get setup_sync_subtitle => 'زامن سجلك عبر الأجهزة'; + + @override + String get setup_units_advanced => 'ضبط الوحدات'; + + @override + String get setup_units_altitude => 'الارتفاع'; + + @override + String get setup_units_dateFormat => 'تنسيق التاريخ'; + + @override + String get setup_units_depth => 'العمق'; + + @override + String get setup_units_imperial => 'إمبراطوري'; + + @override + String get setup_units_metric => 'متري'; + + @override + String get setup_units_pressure => 'الضغط'; + + @override + String get setup_units_sac => 'معدل SAC'; + + @override + String get setup_units_subtitle => + 'اختر كيفية عرض القياسات. يمكنك ضبط كل وحدة.'; + + @override + String get setup_units_temperature => 'درجة الحرارة'; + + @override + String get setup_units_timeFormat => 'تنسيق الوقت'; + + @override + String get setup_units_title => 'الوحدات'; + + @override + String get setup_units_volume => 'الحجم'; + + @override + String get setup_units_weight => 'الوزن'; + + @override + String get setup_welcome_existingData_subtitle => + 'استعد نسخة احتياطية، أو صِل مزامنة السحابة، أو افتح مجلدًا موجودًا'; + + @override + String get setup_welcome_existingData_title => + 'لديّ بيانات Submersion موجودة'; + + @override + String get setup_welcome_skipSetup => 'تخطي الإعداد'; + + @override + String get setup_welcome_startFresh_subtitle => + 'أنشئ ملف الغواص الخاص بك واضبط التطبيق'; + + @override + String get setup_welcome_startFresh_title => 'إعداد سجل جديد'; + + @override + String get setup_welcome_subtitle => 'تسجيل وتحليل متقدم للغوص'; + + @override + String get setup_welcome_title => 'مرحباً بك في Submersion'; + @override String get siteMatchReview_title => 'مطابقة المواقع'; @@ -10402,39 +10688,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get nav_trips => 'الرحلات'; - @override - String get onboarding_welcome_createProfile => 'إنشاء ملفك الشخصي'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'أدخل اسمك للبدء. يمكنك إضافة المزيد من التفاصيل لاحقاً.'; - - @override - String get onboarding_welcome_creating => 'جارٍ الإنشاء...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'خطأ في إنشاء الملف الشخصي: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'البدء'; - - @override - String get onboarding_welcome_nameHint => 'أدخل اسمك'; - - @override - String get onboarding_welcome_nameLabel => 'اسمك'; - - @override - String get onboarding_welcome_nameValidation => 'الرجاء إدخال اسمك'; - - @override - String get onboarding_welcome_subtitle => 'تسجيل وتحليل متقدم للغوص'; - - @override - String get onboarding_welcome_title => 'مرحباً بك في Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'المتاح $liters'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 4a407713e..74da145de 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -5016,6 +5016,300 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Einrichtungsassistent'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Einheiten, Darstellung und Backup-Einstellungen erneut durchgehen'; + + @override + String get setup_backup_cloudCopy => + 'Eine Sicherungskopie in der Cloud aufbewahren'; + + @override + String get setup_backup_frequency => 'Häufigkeit'; + + @override + String get setup_backup_frequency_daily => 'Täglich'; + + @override + String get setup_backup_frequency_monthly => 'Monatlich'; + + @override + String get setup_backup_frequency_weekly => 'Wöchentlich'; + + @override + String get setup_backup_scheduleSubtitle => 'Ihr Logbuch regelmäßig sichern'; + + @override + String get setup_backup_scheduleToggle => 'Automatische Sicherungen'; + + @override + String get setup_backup_subtitle => + 'Schützen Sie Ihr Tauchlogbuch vom ersten Tag an.'; + + @override + String get setup_backup_title => 'Sicherung & Synchronisierung'; + + @override + String get setup_common_back => 'Zurück'; + + @override + String get setup_common_next => 'Weiter'; + + @override + String get setup_common_skip => 'Überspringen'; + + @override + String get setup_existing_folder_subtitle => + 'Submersion auf einen Ordner verweisen, der bereits eine Bibliothek enthält'; + + @override + String get setup_existing_folder_title => 'Vorhandenen Ordner öffnen'; + + @override + String get setup_existing_restore_subtitle => + 'Eine aus Submersion exportierte Sicherungsdatei auswählen'; + + @override + String get setup_existing_restore_title => 'Sicherungsdatei wiederherstellen'; + + @override + String get setup_existing_subtitle => + 'Wählen Sie, wie Ihre vorhandene Submersion-Bibliothek geladen werden soll'; + + @override + String get setup_existing_sync_subtitle => + 'Ihre Bibliothek aus iCloud, Dropbox oder S3 laden'; + + @override + String get setup_existing_sync_title => 'Cloud-Synchronisierung verbinden'; + + @override + String get setup_existing_title => 'Daten übernehmen'; + + @override + String get setup_finish_applying => 'Wird eingerichtet...'; + + @override + String setup_finish_error(Object error) { + return 'Einrichtung konnte nicht abgeschlossen werden: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Tauchgänge von Ihrem Tauchcomputer herunterladen'; + + @override + String get setup_finish_feature_gear => + 'Ausrüstung und Wartungsintervalle verwalten'; + + @override + String get setup_finish_feature_import => + 'Logbücher aus Dateien und anderen Apps importieren'; + + @override + String get setup_finish_feature_sites => + 'Ihre Tauchplätze auf der Karte anzeigen'; + + @override + String get setup_finish_feature_statistics => + 'Statistiken zu Ihren Tauchgängen entdecken'; + + @override + String get setup_finish_start => 'Loslegen'; + + @override + String get setup_finish_subtitle => 'Submersion kann außerdem...'; + + @override + String get setup_finish_title => 'Alles bereit'; + + @override + String get setup_folder_notFound_message => + 'Der ausgewählte Ordner enthält keine Submersion-Datenbank.'; + + @override + String get setup_folder_notFound_title => 'Keine Bibliothek in diesem Ordner'; + + @override + String get setup_folder_pick => 'Ordner auswählen'; + + @override + String get setup_folder_switching => 'Bibliothek wird geöffnet...'; + + @override + String get setup_folder_title => 'Vorhandenen Ordner öffnen'; + + @override + String get setup_profile_nameHint => 'Geben Sie Ihren Namen ein'; + + @override + String get setup_profile_nameLabel => 'Ihr Name'; + + @override + String get setup_profile_nameValidation => 'Bitte geben Sie Ihren Namen ein'; + + @override + String get setup_profile_subtitle => + 'Geben Sie Ihren Namen ein, um zu beginnen. Sie können später weitere Details hinzufügen.'; + + @override + String get setup_profile_title => 'Erstellen Sie Ihr Profil'; + + @override + String get setup_restore_inProgress => 'Wird wiederhergestellt...'; + + @override + String get setup_restore_pick => 'Sicherungsdatei auswählen'; + + @override + String get setup_restore_title => 'Sicherung wiederherstellen'; + + @override + String get setup_step_backup => 'Sicherung'; + + @override + String get setup_step_finish => 'Fertig'; + + @override + String get setup_step_profile => 'Profil'; + + @override + String get setup_step_units => 'Einheiten'; + + @override + String get setup_syncPull_continue => 'Weiter'; + + @override + String get setup_syncPull_noLibrary_message => + 'Auf diesem Konto wurde keine Submersion-Bibliothek gefunden. Stattdessen neu beginnen? Die Verbindung bleibt bestehen.'; + + @override + String get setup_syncPull_noLibrary_title => 'Keine Bibliothek gefunden'; + + @override + String get setup_syncPull_success => 'Bibliothek übernommen'; + + @override + String get setup_syncPull_syncing => 'Bibliothek wird geladen...'; + + @override + String get setup_syncPull_title => 'Verbinden und laden'; + + @override + String get setup_sync_changeProvider => 'Anbieter wechseln'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Verbunden mit $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Verbindung fehlgeschlagen: $error'; + } + + @override + String get setup_sync_header => 'Cloud-Synchronisierung'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud ist auf diesem Gerät nicht verfügbar'; + + @override + String get setup_sync_libraryFound_adopt => + 'Vorhandene Bibliothek übernehmen'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Neu beginnen'; + + @override + String get setup_sync_libraryFound_message => + 'Dieses Konto enthält bereits eine Submersion-Bibliothek. Diese übernehmen, statt neu zu beginnen?'; + + @override + String get setup_sync_libraryFound_title => 'Vorhandene Bibliothek gefunden'; + + @override + String get setup_sync_manageInSettings => 'In den Einstellungen verwalten'; + + @override + String get setup_sync_notConnected => 'Nicht verbunden'; + + @override + String get setup_sync_subtitle => + 'Ihr Logbuch auf allen Geräten synchronisieren'; + + @override + String get setup_units_advanced => 'Einheiten anpassen'; + + @override + String get setup_units_altitude => 'Höhe'; + + @override + String get setup_units_dateFormat => 'Datumsformat'; + + @override + String get setup_units_depth => 'Tiefe'; + + @override + String get setup_units_imperial => 'Imperial'; + + @override + String get setup_units_metric => 'Metrisch'; + + @override + String get setup_units_pressure => 'Druck'; + + @override + String get setup_units_sac => 'SAC-Rate'; + + @override + String get setup_units_subtitle => + 'Wählen Sie, wie Messwerte angezeigt werden. Jede Einheit lässt sich einzeln anpassen.'; + + @override + String get setup_units_temperature => 'Temperatur'; + + @override + String get setup_units_timeFormat => 'Zeitformat'; + + @override + String get setup_units_title => 'Einheiten'; + + @override + String get setup_units_volume => 'Volumen'; + + @override + String get setup_units_weight => 'Gewicht'; + + @override + String get setup_welcome_existingData_subtitle => + 'Sicherung wiederherstellen, Cloud-Sync verbinden oder vorhandenen Ordner öffnen'; + + @override + String get setup_welcome_existingData_title => + 'Ich habe bereits Submersion-Daten'; + + @override + String get setup_welcome_skipSetup => 'Einrichtung überspringen'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Taucherprofil erstellen und die App einrichten'; + + @override + String get setup_welcome_startFresh_title => 'Neues Logbuch einrichten'; + + @override + String get setup_welcome_subtitle => + 'Erweiterte Tauchprotokollierung und -analyse'; + + @override + String get setup_welcome_title => 'Willkommen bei Submersion'; + @override String get siteMatchReview_title => 'Tauchplätze zuordnen'; @@ -10602,41 +10896,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get nav_trips => 'Reisen'; - @override - String get onboarding_welcome_createProfile => 'Erstellen Sie Ihr Profil'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Geben Sie Ihren Namen ein, um zu beginnen. Sie können später weitere Details hinzufügen.'; - - @override - String get onboarding_welcome_creating => 'Wird erstellt...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Fehler beim Erstellen des Profils: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Loslegen'; - - @override - String get onboarding_welcome_nameHint => 'Geben Sie Ihren Namen ein'; - - @override - String get onboarding_welcome_nameLabel => 'Ihr Name'; - - @override - String get onboarding_welcome_nameValidation => - 'Bitte geben Sie Ihren Namen ein'; - - @override - String get onboarding_welcome_subtitle => - 'Erweiterte Tauchprotokollierung und -analyse'; - - @override - String get onboarding_welcome_title => 'Willkommen bei Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Verfügbar $liters'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 1cd6f7dd2..a8685d329 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -4923,6 +4923,294 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Setup assistant'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Revisit units, appearance, and backup choices'; + + @override + String get setup_backup_cloudCopy => 'Keep a backup copy in the cloud'; + + @override + String get setup_backup_frequency => 'Frequency'; + + @override + String get setup_backup_frequency_daily => 'Daily'; + + @override + String get setup_backup_frequency_monthly => 'Monthly'; + + @override + String get setup_backup_frequency_weekly => 'Weekly'; + + @override + String get setup_backup_scheduleSubtitle => + 'Back up your logbook on a schedule'; + + @override + String get setup_backup_scheduleToggle => 'Automatic backups'; + + @override + String get setup_backup_subtitle => 'Protect your dive log from day one.'; + + @override + String get setup_backup_title => 'Backups & Sync'; + + @override + String get setup_common_back => 'Back'; + + @override + String get setup_common_next => 'Next'; + + @override + String get setup_common_skip => 'Skip'; + + @override + String get setup_existing_folder_subtitle => + 'Point Submersion at a folder that already contains a library'; + + @override + String get setup_existing_folder_title => 'Open an existing folder'; + + @override + String get setup_existing_restore_subtitle => + 'Pick a backup file exported from Submersion'; + + @override + String get setup_existing_restore_title => 'Restore a backup file'; + + @override + String get setup_existing_subtitle => + 'Choose how to load your existing Submersion library'; + + @override + String get setup_existing_sync_subtitle => + 'Pull your library from iCloud, Dropbox, or S3'; + + @override + String get setup_existing_sync_title => 'Connect cloud sync'; + + @override + String get setup_existing_title => 'Bring your data'; + + @override + String get setup_finish_applying => 'Setting up...'; + + @override + String setup_finish_error(Object error) { + return 'Could not complete setup: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Download dives from your dive computer'; + + @override + String get setup_finish_feature_gear => 'Track gear and service intervals'; + + @override + String get setup_finish_feature_import => + 'Import logs from files and other apps'; + + @override + String get setup_finish_feature_sites => 'Map your dive sites'; + + @override + String get setup_finish_feature_statistics => + 'Explore statistics about your diving'; + + @override + String get setup_finish_start => 'Start logging'; + + @override + String get setup_finish_subtitle => 'Submersion can also...'; + + @override + String get setup_finish_title => 'You\'re all set'; + + @override + String get setup_folder_notFound_message => + 'The selected folder does not contain a Submersion database.'; + + @override + String get setup_folder_notFound_title => 'No library in that folder'; + + @override + String get setup_folder_pick => 'Choose folder'; + + @override + String get setup_folder_switching => 'Opening library...'; + + @override + String get setup_folder_title => 'Open existing folder'; + + @override + String get setup_profile_nameHint => 'Enter your name'; + + @override + String get setup_profile_nameLabel => 'Your Name'; + + @override + String get setup_profile_nameValidation => 'Please enter your name'; + + @override + String get setup_profile_subtitle => + 'Enter your name to get started. You can add more details later.'; + + @override + String get setup_profile_title => 'Create Your Profile'; + + @override + String get setup_restore_inProgress => 'Restoring...'; + + @override + String get setup_restore_pick => 'Choose backup file'; + + @override + String get setup_restore_title => 'Restore backup'; + + @override + String get setup_step_backup => 'Backup'; + + @override + String get setup_step_finish => 'Done'; + + @override + String get setup_step_profile => 'Profile'; + + @override + String get setup_step_units => 'Units'; + + @override + String get setup_syncPull_continue => 'Continue'; + + @override + String get setup_syncPull_noLibrary_message => + 'No existing Submersion library was found on this account. Start fresh instead? Your connection will be kept.'; + + @override + String get setup_syncPull_noLibrary_title => 'No library found'; + + @override + String get setup_syncPull_success => 'Library adopted'; + + @override + String get setup_syncPull_syncing => 'Pulling your library...'; + + @override + String get setup_syncPull_title => 'Connect and pull'; + + @override + String get setup_sync_changeProvider => 'Change provider'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Connected to $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Could not connect: $error'; + } + + @override + String get setup_sync_header => 'Cloud sync'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud is not available on this device'; + + @override + String get setup_sync_libraryFound_adopt => 'Adopt existing library'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Start fresh'; + + @override + String get setup_sync_libraryFound_message => + 'This account already contains a Submersion library. Adopt it instead of starting fresh?'; + + @override + String get setup_sync_libraryFound_title => 'Existing library found'; + + @override + String get setup_sync_manageInSettings => 'Manage in Settings'; + + @override + String get setup_sync_notConnected => 'Not connected'; + + @override + String get setup_sync_subtitle => 'Sync your logbook across devices'; + + @override + String get setup_units_advanced => 'Fine-tune units'; + + @override + String get setup_units_altitude => 'Altitude'; + + @override + String get setup_units_dateFormat => 'Date format'; + + @override + String get setup_units_depth => 'Depth'; + + @override + String get setup_units_imperial => 'Imperial'; + + @override + String get setup_units_metric => 'Metric'; + + @override + String get setup_units_pressure => 'Pressure'; + + @override + String get setup_units_sac => 'SAC rate'; + + @override + String get setup_units_subtitle => + 'Choose how measurements are displayed. You can fine-tune each unit.'; + + @override + String get setup_units_temperature => 'Temperature'; + + @override + String get setup_units_timeFormat => 'Time format'; + + @override + String get setup_units_title => 'Units'; + + @override + String get setup_units_volume => 'Volume'; + + @override + String get setup_units_weight => 'Weight'; + + @override + String get setup_welcome_existingData_subtitle => + 'Restore a backup, connect cloud sync, or open an existing folder'; + + @override + String get setup_welcome_existingData_title => + 'I have existing Submersion data'; + + @override + String get setup_welcome_skipSetup => 'Skip setup'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Create your diver profile and configure the app'; + + @override + String get setup_welcome_startFresh_title => 'Set up a new logbook'; + + @override + String get setup_welcome_subtitle => 'Advanced dive logging and analytics'; + + @override + String get setup_welcome_title => 'Welcome to Submersion'; + @override String get siteMatchReview_title => 'Match Sites'; @@ -10432,40 +10720,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nav_trips => 'Trips'; - @override - String get onboarding_welcome_createProfile => 'Create Your Profile'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Enter your name to get started. You can add more details later.'; - - @override - String get onboarding_welcome_creating => 'Creating...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Error creating profile: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Get Started'; - - @override - String get onboarding_welcome_nameHint => 'Enter your name'; - - @override - String get onboarding_welcome_nameLabel => 'Your Name'; - - @override - String get onboarding_welcome_nameValidation => 'Please enter your name'; - - @override - String get onboarding_welcome_subtitle => - 'Advanced dive logging and analytics'; - - @override - String get onboarding_welcome_title => 'Welcome to Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Available $liters'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 12b9d217e..fe4673985 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -5014,6 +5014,300 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Asistente de configuración'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Revisa unidades, apariencia y opciones de copia de seguridad'; + + @override + String get setup_backup_cloudCopy => + 'Guardar una copia de seguridad en la nube'; + + @override + String get setup_backup_frequency => 'Frecuencia'; + + @override + String get setup_backup_frequency_daily => 'Diaria'; + + @override + String get setup_backup_frequency_monthly => 'Mensual'; + + @override + String get setup_backup_frequency_weekly => 'Semanal'; + + @override + String get setup_backup_scheduleSubtitle => + 'Haz copias de seguridad de tu diario según una programación'; + + @override + String get setup_backup_scheduleToggle => 'Copias de seguridad automáticas'; + + @override + String get setup_backup_subtitle => + 'Protege tu registro de buceo desde el primer día.'; + + @override + String get setup_backup_title => 'Copias de seguridad y sincronización'; + + @override + String get setup_common_back => 'Atrás'; + + @override + String get setup_common_next => 'Siguiente'; + + @override + String get setup_common_skip => 'Omitir'; + + @override + String get setup_existing_folder_subtitle => + 'Apunta Submersion a una carpeta que ya contiene una biblioteca'; + + @override + String get setup_existing_folder_title => 'Abrir una carpeta existente'; + + @override + String get setup_existing_restore_subtitle => + 'Elige un archivo de copia de seguridad exportado desde Submersion'; + + @override + String get setup_existing_restore_title => + 'Restaurar un archivo de copia de seguridad'; + + @override + String get setup_existing_subtitle => + 'Elige cómo cargar tu biblioteca existente de Submersion'; + + @override + String get setup_existing_sync_subtitle => + 'Descarga tu biblioteca desde iCloud, Dropbox o S3'; + + @override + String get setup_existing_sync_title => 'Conectar sincronización en la nube'; + + @override + String get setup_existing_title => 'Trae tus datos'; + + @override + String get setup_finish_applying => 'Configurando...'; + + @override + String setup_finish_error(Object error) { + return 'No se pudo completar la configuración: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Descarga inmersiones desde tu ordenador de buceo'; + + @override + String get setup_finish_feature_gear => + 'Controla tu equipo y los intervalos de mantenimiento'; + + @override + String get setup_finish_feature_import => + 'Importa registros desde archivos y otras aplicaciones'; + + @override + String get setup_finish_feature_sites => + 'Visualiza tus puntos de buceo en el mapa'; + + @override + String get setup_finish_feature_statistics => + 'Explora estadísticas sobre tus inmersiones'; + + @override + String get setup_finish_start => 'Empezar a registrar'; + + @override + String get setup_finish_subtitle => 'Submersion también puede...'; + + @override + String get setup_finish_title => 'Todo listo'; + + @override + String get setup_folder_notFound_message => + 'La carpeta seleccionada no contiene una base de datos de Submersion.'; + + @override + String get setup_folder_notFound_title => 'No hay biblioteca en esa carpeta'; + + @override + String get setup_folder_pick => 'Elegir carpeta'; + + @override + String get setup_folder_switching => 'Abriendo biblioteca...'; + + @override + String get setup_folder_title => 'Abrir carpeta existente'; + + @override + String get setup_profile_nameHint => 'Ingresa tu nombre'; + + @override + String get setup_profile_nameLabel => 'Tu Nombre'; + + @override + String get setup_profile_nameValidation => 'Por favor ingresa tu nombre'; + + @override + String get setup_profile_subtitle => + 'Ingresa tu nombre para comenzar. Puedes agregar más detalles después.'; + + @override + String get setup_profile_title => 'Crea Tu Perfil'; + + @override + String get setup_restore_inProgress => 'Restaurando...'; + + @override + String get setup_restore_pick => 'Elegir archivo de copia de seguridad'; + + @override + String get setup_restore_title => 'Restaurar copia de seguridad'; + + @override + String get setup_step_backup => 'Copia'; + + @override + String get setup_step_finish => 'Listo'; + + @override + String get setup_step_profile => 'Perfil'; + + @override + String get setup_step_units => 'Unidades'; + + @override + String get setup_syncPull_continue => 'Continuar'; + + @override + String get setup_syncPull_noLibrary_message => + 'No se encontró ninguna biblioteca de Submersion en esta cuenta. ¿Empezar de cero? Tu conexión se mantendrá.'; + + @override + String get setup_syncPull_noLibrary_title => 'No se encontró biblioteca'; + + @override + String get setup_syncPull_success => 'Biblioteca adoptada'; + + @override + String get setup_syncPull_syncing => 'Descargando tu biblioteca...'; + + @override + String get setup_syncPull_title => 'Conectar y descargar'; + + @override + String get setup_sync_changeProvider => 'Cambiar proveedor'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Conectado a $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'No se pudo conectar: $error'; + } + + @override + String get setup_sync_header => 'Sincronización en la nube'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud no está disponible en este dispositivo'; + + @override + String get setup_sync_libraryFound_adopt => 'Adoptar biblioteca existente'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Empezar de cero'; + + @override + String get setup_sync_libraryFound_message => + 'Esta cuenta ya contiene una biblioteca de Submersion. ¿Adoptarla en lugar de empezar de cero?'; + + @override + String get setup_sync_libraryFound_title => + 'Se encontró una biblioteca existente'; + + @override + String get setup_sync_manageInSettings => 'Gestionar en Ajustes'; + + @override + String get setup_sync_notConnected => 'No conectado'; + + @override + String get setup_sync_subtitle => 'Sincroniza tu diario entre dispositivos'; + + @override + String get setup_units_advanced => 'Ajustar unidades'; + + @override + String get setup_units_altitude => 'Altitud'; + + @override + String get setup_units_dateFormat => 'Formato de fecha'; + + @override + String get setup_units_depth => 'Profundidad'; + + @override + String get setup_units_imperial => 'Imperial'; + + @override + String get setup_units_metric => 'Métrico'; + + @override + String get setup_units_pressure => 'Presión'; + + @override + String get setup_units_sac => 'Tasa SAC'; + + @override + String get setup_units_subtitle => + 'Elige cómo se muestran las medidas. Puedes ajustar cada unidad.'; + + @override + String get setup_units_temperature => 'Temperatura'; + + @override + String get setup_units_timeFormat => 'Formato de hora'; + + @override + String get setup_units_title => 'Unidades'; + + @override + String get setup_units_volume => 'Volumen'; + + @override + String get setup_units_weight => 'Peso'; + + @override + String get setup_welcome_existingData_subtitle => + 'Restaura una copia de seguridad, conecta la sincronización o abre una carpeta existente'; + + @override + String get setup_welcome_existingData_title => 'Ya tengo datos de Submersion'; + + @override + String get setup_welcome_skipSetup => 'Omitir configuración'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Crea tu perfil de buceador y configura la aplicación'; + + @override + String get setup_welcome_startFresh_title => 'Configurar un diario nuevo'; + + @override + String get setup_welcome_subtitle => + 'Registro y análisis avanzado de inmersiones'; + + @override + String get setup_welcome_title => 'Bienvenido a Submersion'; + @override String get siteMatchReview_title => 'Asociar puntos'; @@ -10588,40 +10882,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get nav_trips => 'Viajes'; - @override - String get onboarding_welcome_createProfile => 'Crea Tu Perfil'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Ingresa tu nombre para comenzar. Puedes agregar más detalles después.'; - - @override - String get onboarding_welcome_creating => 'Creando...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Error al crear perfil: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Comenzar'; - - @override - String get onboarding_welcome_nameHint => 'Ingresa tu nombre'; - - @override - String get onboarding_welcome_nameLabel => 'Tu Nombre'; - - @override - String get onboarding_welcome_nameValidation => 'Por favor ingresa tu nombre'; - - @override - String get onboarding_welcome_subtitle => - 'Registro y análisis avanzado de inmersiones'; - - @override - String get onboarding_welcome_title => 'Bienvenido a Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Disponible $liters'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 58559c1f7..44fdfd05f 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -5038,6 +5038,302 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Assistant de configuration'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Revoir les unités, l\'apparence et les choix de sauvegarde'; + + @override + String get setup_backup_cloudCopy => + 'Conserver une copie de sauvegarde dans le cloud'; + + @override + String get setup_backup_frequency => 'Fréquence'; + + @override + String get setup_backup_frequency_daily => 'Quotidienne'; + + @override + String get setup_backup_frequency_monthly => 'Mensuelle'; + + @override + String get setup_backup_frequency_weekly => 'Hebdomadaire'; + + @override + String get setup_backup_scheduleSubtitle => + 'Sauvegardez votre carnet selon un calendrier'; + + @override + String get setup_backup_scheduleToggle => 'Sauvegardes automatiques'; + + @override + String get setup_backup_subtitle => + 'Protégez votre carnet de plongée dès le premier jour.'; + + @override + String get setup_backup_title => 'Sauvegardes et synchronisation'; + + @override + String get setup_common_back => 'Retour'; + + @override + String get setup_common_next => 'Suivant'; + + @override + String get setup_common_skip => 'Ignorer'; + + @override + String get setup_existing_folder_subtitle => + 'Pointez Submersion vers un dossier contenant déjà une bibliothèque'; + + @override + String get setup_existing_folder_title => 'Ouvrir un dossier existant'; + + @override + String get setup_existing_restore_subtitle => + 'Choisissez un fichier de sauvegarde exporté depuis Submersion'; + + @override + String get setup_existing_restore_title => + 'Restaurer un fichier de sauvegarde'; + + @override + String get setup_existing_subtitle => + 'Choisissez comment charger votre bibliothèque Submersion existante'; + + @override + String get setup_existing_sync_subtitle => + 'Récupérez votre bibliothèque depuis iCloud, Dropbox ou S3'; + + @override + String get setup_existing_sync_title => 'Connecter la synchronisation cloud'; + + @override + String get setup_existing_title => 'Récupérez vos données'; + + @override + String get setup_finish_applying => 'Configuration...'; + + @override + String setup_finish_error(Object error) { + return 'Impossible de terminer la configuration : $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Téléchargez vos plongées depuis votre ordinateur de plongée'; + + @override + String get setup_finish_feature_gear => + 'Suivez votre matériel et ses entretiens'; + + @override + String get setup_finish_feature_import => + 'Importez des carnets depuis des fichiers et d\'autres applications'; + + @override + String get setup_finish_feature_sites => 'Cartographiez vos sites de plongée'; + + @override + String get setup_finish_feature_statistics => + 'Explorez les statistiques de vos plongées'; + + @override + String get setup_finish_start => 'Commencer'; + + @override + String get setup_finish_subtitle => 'Submersion peut aussi...'; + + @override + String get setup_finish_title => 'Tout est prêt'; + + @override + String get setup_folder_notFound_message => + 'Le dossier sélectionné ne contient pas de base de données Submersion.'; + + @override + String get setup_folder_notFound_title => + 'Aucune bibliothèque dans ce dossier'; + + @override + String get setup_folder_pick => 'Choisir un dossier'; + + @override + String get setup_folder_switching => 'Ouverture de la bibliothèque...'; + + @override + String get setup_folder_title => 'Ouvrir un dossier existant'; + + @override + String get setup_profile_nameHint => 'Entrez votre nom'; + + @override + String get setup_profile_nameLabel => 'Votre nom'; + + @override + String get setup_profile_nameValidation => 'Veuillez entrer votre nom'; + + @override + String get setup_profile_subtitle => + 'Entrez votre nom pour commencer. Vous pourrez ajouter plus de détails plus tard.'; + + @override + String get setup_profile_title => 'Créer votre profil'; + + @override + String get setup_restore_inProgress => 'Restauration...'; + + @override + String get setup_restore_pick => 'Choisir un fichier de sauvegarde'; + + @override + String get setup_restore_title => 'Restaurer la sauvegarde'; + + @override + String get setup_step_backup => 'Sauvegarde'; + + @override + String get setup_step_finish => 'Terminé'; + + @override + String get setup_step_profile => 'Profil'; + + @override + String get setup_step_units => 'Unités'; + + @override + String get setup_syncPull_continue => 'Continuer'; + + @override + String get setup_syncPull_noLibrary_message => + 'Aucune bibliothèque Submersion n\'a été trouvée sur ce compte. Repartir de zéro ? Votre connexion sera conservée.'; + + @override + String get setup_syncPull_noLibrary_title => 'Aucune bibliothèque trouvée'; + + @override + String get setup_syncPull_success => 'Bibliothèque adoptée'; + + @override + String get setup_syncPull_syncing => 'Récupération de votre bibliothèque...'; + + @override + String get setup_syncPull_title => 'Connecter et récupérer'; + + @override + String get setup_sync_changeProvider => 'Changer de fournisseur'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Connecté à $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Connexion impossible : $error'; + } + + @override + String get setup_sync_header => 'Synchronisation cloud'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud n\'est pas disponible sur cet appareil'; + + @override + String get setup_sync_libraryFound_adopt => + 'Adopter la bibliothèque existante'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Repartir de zéro'; + + @override + String get setup_sync_libraryFound_message => + 'Ce compte contient déjà une bibliothèque Submersion. L\'adopter plutôt que de repartir de zéro ?'; + + @override + String get setup_sync_libraryFound_title => 'Bibliothèque existante trouvée'; + + @override + String get setup_sync_manageInSettings => 'Gérer dans les réglages'; + + @override + String get setup_sync_notConnected => 'Non connecté'; + + @override + String get setup_sync_subtitle => + 'Synchronisez votre carnet entre vos appareils'; + + @override + String get setup_units_advanced => 'Ajuster les unités'; + + @override + String get setup_units_altitude => 'Altitude'; + + @override + String get setup_units_dateFormat => 'Format de date'; + + @override + String get setup_units_depth => 'Profondeur'; + + @override + String get setup_units_imperial => 'Impérial'; + + @override + String get setup_units_metric => 'Métrique'; + + @override + String get setup_units_pressure => 'Pression'; + + @override + String get setup_units_sac => 'Taux SAC'; + + @override + String get setup_units_subtitle => + 'Choisissez l\'affichage des mesures. Chaque unité peut être ajustée.'; + + @override + String get setup_units_temperature => 'Température'; + + @override + String get setup_units_timeFormat => 'Format de l\'heure'; + + @override + String get setup_units_title => 'Unités'; + + @override + String get setup_units_volume => 'Volume'; + + @override + String get setup_units_weight => 'Poids'; + + @override + String get setup_welcome_existingData_subtitle => + 'Restaurez une sauvegarde, connectez la synchronisation ou ouvrez un dossier existant'; + + @override + String get setup_welcome_existingData_title => + 'J\'ai déjà des données Submersion'; + + @override + String get setup_welcome_skipSetup => 'Ignorer la configuration'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Créez votre profil de plongeur et configurez l\'application'; + + @override + String get setup_welcome_startFresh_title => 'Créer un nouveau carnet'; + + @override + String get setup_welcome_subtitle => + 'Enregistrement et analyse avancés de plongée'; + + @override + String get setup_welcome_title => 'Bienvenue dans Submersion'; + @override String get siteMatchReview_title => 'Associer les sites'; @@ -10639,40 +10935,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get nav_trips => 'Voyages'; - @override - String get onboarding_welcome_createProfile => 'Créer votre profil'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Entrez votre nom pour commencer. Vous pourrez ajouter plus de détails plus tard.'; - - @override - String get onboarding_welcome_creating => 'Création...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Erreur lors de la création du profil : $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Commencer'; - - @override - String get onboarding_welcome_nameHint => 'Entrez votre nom'; - - @override - String get onboarding_welcome_nameLabel => 'Votre nom'; - - @override - String get onboarding_welcome_nameValidation => 'Veuillez entrer votre nom'; - - @override - String get onboarding_welcome_subtitle => - 'Enregistrement et analyse avancés de plongée'; - - @override - String get onboarding_welcome_title => 'Bienvenue dans Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Disponible $liters'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 893bf8ee5..5bb4c6fe0 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -4878,6 +4878,290 @@ class AppLocalizationsHe extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'אשף ההגדרה'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'עיון מחדש ביחידות, במראה ובאפשרויות הגיבוי'; + + @override + String get setup_backup_cloudCopy => 'שמירת עותק גיבוי בענן'; + + @override + String get setup_backup_frequency => 'תדירות'; + + @override + String get setup_backup_frequency_daily => 'יומי'; + + @override + String get setup_backup_frequency_monthly => 'חודשי'; + + @override + String get setup_backup_frequency_weekly => 'שבועי'; + + @override + String get setup_backup_scheduleSubtitle => 'גיבוי יומן הצלילה לפי לוח זמנים'; + + @override + String get setup_backup_scheduleToggle => 'גיבויים אוטומטיים'; + + @override + String get setup_backup_subtitle => 'הגנו על יומן הצלילה שלכם מהיום הראשון.'; + + @override + String get setup_backup_title => 'גיבויים וסנכרון'; + + @override + String get setup_common_back => 'חזרה'; + + @override + String get setup_common_next => 'הבא'; + + @override + String get setup_common_skip => 'דילוג'; + + @override + String get setup_existing_folder_subtitle => + 'הפנו את Submersion לתיקייה שכבר מכילה ספרייה'; + + @override + String get setup_existing_folder_title => 'פתיחת תיקייה קיימת'; + + @override + String get setup_existing_restore_subtitle => + 'בחרו קובץ גיבוי שיוצא מ-Submersion'; + + @override + String get setup_existing_restore_title => 'שחזור קובץ גיבוי'; + + @override + String get setup_existing_subtitle => + 'בחרו כיצד לטעון את ספריית Submersion הקיימת שלכם'; + + @override + String get setup_existing_sync_subtitle => + 'משכו את הספרייה שלכם מ-iCloud, מ-Dropbox או מ-S3'; + + @override + String get setup_existing_sync_title => 'חיבור סנכרון ענן'; + + @override + String get setup_existing_title => 'הביאו את הנתונים שלכם'; + + @override + String get setup_finish_applying => 'מגדיר...'; + + @override + String setup_finish_error(Object error) { + return 'לא ניתן להשלים את ההגדרה: $error'; + } + + @override + String get setup_finish_feature_diveComputer => 'הורדת צלילות ממחשב הצלילה'; + + @override + String get setup_finish_feature_gear => 'מעקב אחר ציוד ומועדי טיפולים'; + + @override + String get setup_finish_feature_import => + 'ייבוא יומנים מקבצים ומאפליקציות אחרות'; + + @override + String get setup_finish_feature_sites => 'מיפוי אתרי הצלילה שלכם'; + + @override + String get setup_finish_feature_statistics => + 'חקר סטטיסטיקות על הצלילות שלכם'; + + @override + String get setup_finish_start => 'התחלת רישום'; + + @override + String get setup_finish_subtitle => 'Submersion יכול גם...'; + + @override + String get setup_finish_title => 'הכול מוכן'; + + @override + String get setup_folder_notFound_message => + 'התיקייה שנבחרה אינה מכילה מסד נתונים של Submersion.'; + + @override + String get setup_folder_notFound_title => 'אין ספרייה בתיקייה זו'; + + @override + String get setup_folder_pick => 'בחירת תיקייה'; + + @override + String get setup_folder_switching => 'פותח ספרייה...'; + + @override + String get setup_folder_title => 'פתיחת תיקייה קיימת'; + + @override + String get setup_profile_nameHint => 'הזן את שמך'; + + @override + String get setup_profile_nameLabel => 'השם שלך'; + + @override + String get setup_profile_nameValidation => 'נא להזין את שמך'; + + @override + String get setup_profile_subtitle => + 'הזן את שמך כדי להתחיל. תוכל להוסיף פרטים נוספים מאוחר יותר.'; + + @override + String get setup_profile_title => 'צור את הפרופיל שלך'; + + @override + String get setup_restore_inProgress => 'משחזר...'; + + @override + String get setup_restore_pick => 'בחירת קובץ גיבוי'; + + @override + String get setup_restore_title => 'שחזור גיבוי'; + + @override + String get setup_step_backup => 'גיבוי'; + + @override + String get setup_step_finish => 'סיום'; + + @override + String get setup_step_profile => 'פרופיל'; + + @override + String get setup_step_units => 'יחידות'; + + @override + String get setup_syncPull_continue => 'המשך'; + + @override + String get setup_syncPull_noLibrary_message => + 'לא נמצאה ספריית Submersion בחשבון זה. להתחיל מחדש? החיבור יישמר.'; + + @override + String get setup_syncPull_noLibrary_title => 'לא נמצאה ספרייה'; + + @override + String get setup_syncPull_success => 'הספרייה אומצה'; + + @override + String get setup_syncPull_syncing => 'מושך את הספרייה...'; + + @override + String get setup_syncPull_title => 'חיבור ומשיכה'; + + @override + String get setup_sync_changeProvider => 'החלפת ספק'; + + @override + String setup_sync_connectedTo(String provider) { + return 'מחובר אל $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'החיבור נכשל: $error'; + } + + @override + String get setup_sync_header => 'סנכרון ענן'; + + @override + String get setup_sync_icloudUnavailable => 'iCloud אינו זמין במכשיר זה'; + + @override + String get setup_sync_libraryFound_adopt => 'אימוץ הספרייה הקיימת'; + + @override + String get setup_sync_libraryFound_keepFresh => 'התחלה חדשה'; + + @override + String get setup_sync_libraryFound_message => + 'חשבון זה כבר מכיל ספריית Submersion. לאמץ אותה במקום להתחיל מחדש?'; + + @override + String get setup_sync_libraryFound_title => 'נמצאה ספרייה קיימת'; + + @override + String get setup_sync_manageInSettings => 'ניהול בהגדרות'; + + @override + String get setup_sync_notConnected => 'לא מחובר'; + + @override + String get setup_sync_subtitle => 'סנכרון היומן בין מכשירים'; + + @override + String get setup_units_advanced => 'כוונון יחידות'; + + @override + String get setup_units_altitude => 'גובה'; + + @override + String get setup_units_dateFormat => 'תבנית תאריך'; + + @override + String get setup_units_depth => 'עומק'; + + @override + String get setup_units_imperial => 'אימפריאלי'; + + @override + String get setup_units_metric => 'מטרי'; + + @override + String get setup_units_pressure => 'לחץ'; + + @override + String get setup_units_sac => 'קצב SAC'; + + @override + String get setup_units_subtitle => + 'בחרו כיצד יוצגו המדידות. ניתן לכוונן כל יחידה.'; + + @override + String get setup_units_temperature => 'טמפרטורה'; + + @override + String get setup_units_timeFormat => 'תבנית שעה'; + + @override + String get setup_units_title => 'יחידות'; + + @override + String get setup_units_volume => 'נפח'; + + @override + String get setup_units_weight => 'משקל'; + + @override + String get setup_welcome_existingData_subtitle => + 'שחזרו גיבוי, חברו סנכרון ענן או פתחו תיקייה קיימת'; + + @override + String get setup_welcome_existingData_title => 'יש לי כבר נתוני Submersion'; + + @override + String get setup_welcome_skipSetup => 'דילוג על ההגדרה'; + + @override + String get setup_welcome_startFresh_subtitle => + 'צרו פרופיל צולל והגדירו את האפליקציה'; + + @override + String get setup_welcome_startFresh_title => 'הגדרת יומן חדש'; + + @override + String get setup_welcome_subtitle => 'רישום וניתוח צלילה מתקדם'; + + @override + String get setup_welcome_title => 'ברוכים הבאים ל-Submersion'; + @override String get siteMatchReview_title => 'התאמת אתרים'; @@ -10332,39 +10616,6 @@ class AppLocalizationsHe extends AppLocalizations { @override String get nav_trips => 'טיולים'; - @override - String get onboarding_welcome_createProfile => 'צור את הפרופיל שלך'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'הזן את שמך כדי להתחיל. תוכל להוסיף פרטים נוספים מאוחר יותר.'; - - @override - String get onboarding_welcome_creating => 'יוצר...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'שגיאה ביצירת פרופיל: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'התחל'; - - @override - String get onboarding_welcome_nameHint => 'הזן את שמך'; - - @override - String get onboarding_welcome_nameLabel => 'השם שלך'; - - @override - String get onboarding_welcome_nameValidation => 'נא להזין את שמך'; - - @override - String get onboarding_welcome_subtitle => 'רישום וניתוח צלילה מתקדם'; - - @override - String get onboarding_welcome_title => 'ברוכים הבאים ל-Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'זמין $liters'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index f7b737f3d..e273dad82 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -4997,6 +4997,298 @@ class AppLocalizationsHu extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Beállítási varázsló'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Mértékegységek, megjelenés és biztonsági mentés áttekintése'; + + @override + String get setup_backup_cloudCopy => + 'Biztonsági másolat megőrzése a felhőben'; + + @override + String get setup_backup_frequency => 'Gyakoriság'; + + @override + String get setup_backup_frequency_daily => 'Naponta'; + + @override + String get setup_backup_frequency_monthly => 'Havonta'; + + @override + String get setup_backup_frequency_weekly => 'Hetente'; + + @override + String get setup_backup_scheduleSubtitle => + 'Naplója rendszeres biztonsági mentése'; + + @override + String get setup_backup_scheduleToggle => 'Automatikus biztonsági mentés'; + + @override + String get setup_backup_subtitle => + 'Védje merülési naplóját az első naptól kezdve.'; + + @override + String get setup_backup_title => 'Biztonsági mentés és szinkronizálás'; + + @override + String get setup_common_back => 'Vissza'; + + @override + String get setup_common_next => 'Tovább'; + + @override + String get setup_common_skip => 'Kihagyás'; + + @override + String get setup_existing_folder_subtitle => + 'Irányítsa a Submersiont egy olyan mappára, amely már tartalmaz könyvtárat'; + + @override + String get setup_existing_folder_title => 'Meglévő mappa megnyitása'; + + @override + String get setup_existing_restore_subtitle => + 'Válasszon egy Submersionból exportált biztonságimentés-fájlt'; + + @override + String get setup_existing_restore_title => 'Biztonsági mentés visszaállítása'; + + @override + String get setup_existing_subtitle => + 'Válassza ki, hogyan töltse be meglévő Submersion-könyvtárát'; + + @override + String get setup_existing_sync_subtitle => + 'Könyvtár letöltése iCloudból, Dropboxból vagy S3-ból'; + + @override + String get setup_existing_sync_title => 'Felhőszinkronizálás csatlakoztatása'; + + @override + String get setup_existing_title => 'Adatok áthozása'; + + @override + String get setup_finish_applying => 'Beállítás...'; + + @override + String setup_finish_error(Object error) { + return 'A beállítás nem fejeződött be: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Merülések letöltése a búvárkomputerről'; + + @override + String get setup_finish_feature_gear => + 'Felszerelés és szervizintervallumok nyomon követése'; + + @override + String get setup_finish_feature_import => + 'Naplók importálása fájlokból és más alkalmazásokból'; + + @override + String get setup_finish_feature_sites => + 'Merülőhelyek megjelenítése térképen'; + + @override + String get setup_finish_feature_statistics => + 'Merülési statisztikák felfedezése'; + + @override + String get setup_finish_start => 'Naplózás indítása'; + + @override + String get setup_finish_subtitle => 'A Submersion ezenkívül...'; + + @override + String get setup_finish_title => 'Minden készen áll'; + + @override + String get setup_folder_notFound_message => + 'A kiválasztott mappa nem tartalmaz Submersion-adatbázist.'; + + @override + String get setup_folder_notFound_title => 'Nincs könyvtár ebben a mappában'; + + @override + String get setup_folder_pick => 'Mappa kiválasztása'; + + @override + String get setup_folder_switching => 'Könyvtár megnyitása...'; + + @override + String get setup_folder_title => 'Meglévő mappa megnyitása'; + + @override + String get setup_profile_nameHint => 'Add meg a neved'; + + @override + String get setup_profile_nameLabel => 'Neved'; + + @override + String get setup_profile_nameValidation => 'Add meg a neved'; + + @override + String get setup_profile_subtitle => + 'Add meg a neved a kezdéshez. Később további részleteket adhatsz hozzá.'; + + @override + String get setup_profile_title => 'Profil létrehozása'; + + @override + String get setup_restore_inProgress => 'Visszaállítás...'; + + @override + String get setup_restore_pick => 'Biztonságimentés-fájl kiválasztása'; + + @override + String get setup_restore_title => 'Biztonsági mentés visszaállítása'; + + @override + String get setup_step_backup => 'Mentés'; + + @override + String get setup_step_finish => 'Kész'; + + @override + String get setup_step_profile => 'Profil'; + + @override + String get setup_step_units => 'Egységek'; + + @override + String get setup_syncPull_continue => 'Folytatás'; + + @override + String get setup_syncPull_noLibrary_message => + 'Ezen a fiókon nem található Submersion-könyvtár. Új kezdés? A kapcsolat megmarad.'; + + @override + String get setup_syncPull_noLibrary_title => 'Nem található könyvtár'; + + @override + String get setup_syncPull_success => 'Könyvtár átvéve'; + + @override + String get setup_syncPull_syncing => 'Könyvtár letöltése...'; + + @override + String get setup_syncPull_title => 'Csatlakozás és letöltés'; + + @override + String get setup_sync_changeProvider => 'Szolgáltató módosítása'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Csatlakoztatva: $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Nem sikerült csatlakozni: $error'; + } + + @override + String get setup_sync_header => 'Felhőszinkronizálás'; + + @override + String get setup_sync_icloudUnavailable => + 'Az iCloud nem érhető el ezen az eszközön'; + + @override + String get setup_sync_libraryFound_adopt => 'Meglévő könyvtár átvétele'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Új kezdés'; + + @override + String get setup_sync_libraryFound_message => + 'Ez a fiók már tartalmaz Submersion-könyvtárat. Átveszi ahelyett, hogy újat kezdene?'; + + @override + String get setup_sync_libraryFound_title => 'Meglévő könyvtár található'; + + @override + String get setup_sync_manageInSettings => 'Kezelés a beállításokban'; + + @override + String get setup_sync_notConnected => 'Nincs csatlakoztatva'; + + @override + String get setup_sync_subtitle => 'Napló szinkronizálása az eszközök között'; + + @override + String get setup_units_advanced => 'Egységek finomhangolása'; + + @override + String get setup_units_altitude => 'Magasság'; + + @override + String get setup_units_dateFormat => 'Dátumformátum'; + + @override + String get setup_units_depth => 'Mélység'; + + @override + String get setup_units_imperial => 'Angolszász'; + + @override + String get setup_units_metric => 'Metrikus'; + + @override + String get setup_units_pressure => 'Nyomás'; + + @override + String get setup_units_sac => 'SAC-érték'; + + @override + String get setup_units_subtitle => + 'Válassza ki a mértékegységek megjelenítését. Minden egység külön beállítható.'; + + @override + String get setup_units_temperature => 'Hőmérséklet'; + + @override + String get setup_units_timeFormat => 'Időformátum'; + + @override + String get setup_units_title => 'Egységek'; + + @override + String get setup_units_volume => 'Térfogat'; + + @override + String get setup_units_weight => 'Súly'; + + @override + String get setup_welcome_existingData_subtitle => + 'Biztonsági mentés visszaállítása, felhőszinkronizálás vagy meglévő mappa megnyitása'; + + @override + String get setup_welcome_existingData_title => + 'Már vannak Submersion-adataim'; + + @override + String get setup_welcome_skipSetup => 'Beállítás kihagyása'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Búvárprofil létrehozása és az alkalmazás beállítása'; + + @override + String get setup_welcome_startFresh_title => 'Új napló beállítása'; + + @override + String get setup_welcome_subtitle => 'Fejlett merülés napló és elemzés'; + + @override + String get setup_welcome_title => 'Üdvözöl a Submersion'; + @override String get siteMatchReview_title => 'Helyek hozzárendelése'; @@ -10575,39 +10867,6 @@ class AppLocalizationsHu extends AppLocalizations { @override String get nav_trips => 'Utak'; - @override - String get onboarding_welcome_createProfile => 'Profil létrehozása'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Add meg a neved a kezdéshez. Később további részleteket adhatsz hozzá.'; - - @override - String get onboarding_welcome_creating => 'Létrehozás...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Hiba a profil létrehozásakor: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Kezdés'; - - @override - String get onboarding_welcome_nameHint => 'Add meg a neved'; - - @override - String get onboarding_welcome_nameLabel => 'Neved'; - - @override - String get onboarding_welcome_nameValidation => 'Add meg a neved'; - - @override - String get onboarding_welcome_subtitle => 'Fejlett merülés napló és elemzés'; - - @override - String get onboarding_welcome_title => 'Üdvözöl a Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Elérhető $liters'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 5471039b6..1e4c71eac 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -5017,6 +5017,299 @@ class AppLocalizationsIt extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Assistente di configurazione'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Rivedi unità, aspetto e opzioni di backup'; + + @override + String get setup_backup_cloudCopy => 'Conserva una copia di backup nel cloud'; + + @override + String get setup_backup_frequency => 'Frequenza'; + + @override + String get setup_backup_frequency_daily => 'Giornaliera'; + + @override + String get setup_backup_frequency_monthly => 'Mensile'; + + @override + String get setup_backup_frequency_weekly => 'Settimanale'; + + @override + String get setup_backup_scheduleSubtitle => + 'Esegui il backup del tuo logbook a intervalli regolari'; + + @override + String get setup_backup_scheduleToggle => 'Backup automatici'; + + @override + String get setup_backup_subtitle => + 'Proteggi il tuo registro immersioni fin dal primo giorno.'; + + @override + String get setup_backup_title => 'Backup e sincronizzazione'; + + @override + String get setup_common_back => 'Indietro'; + + @override + String get setup_common_next => 'Avanti'; + + @override + String get setup_common_skip => 'Salta'; + + @override + String get setup_existing_folder_subtitle => + 'Indica a Submersion una cartella che contiene già una libreria'; + + @override + String get setup_existing_folder_title => 'Apri una cartella esistente'; + + @override + String get setup_existing_restore_subtitle => + 'Scegli un file di backup esportato da Submersion'; + + @override + String get setup_existing_restore_title => 'Ripristina un file di backup'; + + @override + String get setup_existing_subtitle => + 'Scegli come caricare la tua libreria Submersion esistente'; + + @override + String get setup_existing_sync_subtitle => + 'Scarica la tua libreria da iCloud, Dropbox o S3'; + + @override + String get setup_existing_sync_title => 'Collega la sincronizzazione cloud'; + + @override + String get setup_existing_title => 'Porta i tuoi dati'; + + @override + String get setup_finish_applying => 'Configurazione...'; + + @override + String setup_finish_error(Object error) { + return 'Impossibile completare la configurazione: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Scarica le immersioni dal tuo computer subacqueo'; + + @override + String get setup_finish_feature_gear => + 'Tieni traccia dell\'attrezzatura e delle scadenze di manutenzione'; + + @override + String get setup_finish_feature_import => + 'Importa registri da file e altre app'; + + @override + String get setup_finish_feature_sites => + 'Visualizza i tuoi punti di immersione sulla mappa'; + + @override + String get setup_finish_feature_statistics => + 'Esplora le statistiche delle tue immersioni'; + + @override + String get setup_finish_start => 'Inizia a registrare'; + + @override + String get setup_finish_subtitle => 'Submersion può anche...'; + + @override + String get setup_finish_title => 'Tutto pronto'; + + @override + String get setup_folder_notFound_message => + 'La cartella selezionata non contiene un database Submersion.'; + + @override + String get setup_folder_notFound_title => + 'Nessuna libreria in quella cartella'; + + @override + String get setup_folder_pick => 'Scegli cartella'; + + @override + String get setup_folder_switching => 'Apertura della libreria...'; + + @override + String get setup_folder_title => 'Apri cartella esistente'; + + @override + String get setup_profile_nameHint => 'Inserisci il tuo nome'; + + @override + String get setup_profile_nameLabel => 'Il Tuo Nome'; + + @override + String get setup_profile_nameValidation => 'Inserisci il tuo nome'; + + @override + String get setup_profile_subtitle => + 'Inserisci il tuo nome per iniziare. Potrai aggiungere altri dettagli in seguito.'; + + @override + String get setup_profile_title => 'Crea il Tuo Profilo'; + + @override + String get setup_restore_inProgress => 'Ripristino...'; + + @override + String get setup_restore_pick => 'Scegli file di backup'; + + @override + String get setup_restore_title => 'Ripristina backup'; + + @override + String get setup_step_backup => 'Backup'; + + @override + String get setup_step_finish => 'Fine'; + + @override + String get setup_step_profile => 'Profilo'; + + @override + String get setup_step_units => 'Unità'; + + @override + String get setup_syncPull_continue => 'Continua'; + + @override + String get setup_syncPull_noLibrary_message => + 'Nessuna libreria Submersion trovata su questo account. Iniziare da zero? La connessione verrà mantenuta.'; + + @override + String get setup_syncPull_noLibrary_title => 'Nessuna libreria trovata'; + + @override + String get setup_syncPull_success => 'Libreria adottata'; + + @override + String get setup_syncPull_syncing => 'Download della libreria...'; + + @override + String get setup_syncPull_title => 'Connetti e scarica'; + + @override + String get setup_sync_changeProvider => 'Cambia provider'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Connesso a $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Impossibile connettersi: $error'; + } + + @override + String get setup_sync_header => 'Sincronizzazione cloud'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud non è disponibile su questo dispositivo'; + + @override + String get setup_sync_libraryFound_adopt => 'Adotta la libreria esistente'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Inizia da zero'; + + @override + String get setup_sync_libraryFound_message => + 'Questo account contiene già una libreria Submersion. Adottarla invece di iniziare da zero?'; + + @override + String get setup_sync_libraryFound_title => 'Trovata libreria esistente'; + + @override + String get setup_sync_manageInSettings => 'Gestisci nelle impostazioni'; + + @override + String get setup_sync_notConnected => 'Non connesso'; + + @override + String get setup_sync_subtitle => + 'Sincronizza il tuo logbook su tutti i dispositivi'; + + @override + String get setup_units_advanced => 'Regola le unità'; + + @override + String get setup_units_altitude => 'Altitudine'; + + @override + String get setup_units_dateFormat => 'Formato data'; + + @override + String get setup_units_depth => 'Profondità'; + + @override + String get setup_units_imperial => 'Imperiale'; + + @override + String get setup_units_metric => 'Metrico'; + + @override + String get setup_units_pressure => 'Pressione'; + + @override + String get setup_units_sac => 'Tasso SAC'; + + @override + String get setup_units_subtitle => + 'Scegli come visualizzare le misure. Puoi regolare ogni unità.'; + + @override + String get setup_units_temperature => 'Temperatura'; + + @override + String get setup_units_timeFormat => 'Formato ora'; + + @override + String get setup_units_title => 'Unità'; + + @override + String get setup_units_volume => 'Volume'; + + @override + String get setup_units_weight => 'Peso'; + + @override + String get setup_welcome_existingData_subtitle => + 'Ripristina un backup, collega la sincronizzazione o apri una cartella esistente'; + + @override + String get setup_welcome_existingData_title => 'Ho già dati di Submersion'; + + @override + String get setup_welcome_skipSetup => 'Salta configurazione'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Crea il tuo profilo subacqueo e configura l\'app'; + + @override + String get setup_welcome_startFresh_title => 'Configura un nuovo logbook'; + + @override + String get setup_welcome_subtitle => + 'Registrazione e analisi avanzata delle immersioni'; + + @override + String get setup_welcome_title => 'Benvenuto in Submersion'; + @override String get siteMatchReview_title => 'Associa siti'; @@ -10603,40 +10896,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get nav_trips => 'Viaggi'; - @override - String get onboarding_welcome_createProfile => 'Crea il Tuo Profilo'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Inserisci il tuo nome per iniziare. Potrai aggiungere altri dettagli in seguito.'; - - @override - String get onboarding_welcome_creating => 'Creazione...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Errore durante la creazione del profilo: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Inizia'; - - @override - String get onboarding_welcome_nameHint => 'Inserisci il tuo nome'; - - @override - String get onboarding_welcome_nameLabel => 'Il Tuo Nome'; - - @override - String get onboarding_welcome_nameValidation => 'Inserisci il tuo nome'; - - @override - String get onboarding_welcome_subtitle => - 'Registrazione e analisi avanzata delle immersioni'; - - @override - String get onboarding_welcome_title => 'Benvenuto in Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Disponibile $liters'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 3c584e72d..8209a14b8 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -4979,6 +4979,295 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Configuratie-assistent'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Eenheden, weergave en back-upkeuzes opnieuw bekijken'; + + @override + String get setup_backup_cloudCopy => 'Een back-upkopie in de cloud bewaren'; + + @override + String get setup_backup_frequency => 'Frequentie'; + + @override + String get setup_backup_frequency_daily => 'Dagelijks'; + + @override + String get setup_backup_frequency_monthly => 'Maandelijks'; + + @override + String get setup_backup_frequency_weekly => 'Wekelijks'; + + @override + String get setup_backup_scheduleSubtitle => + 'Maak volgens schema een back-up van je logboek'; + + @override + String get setup_backup_scheduleToggle => 'Automatische back-ups'; + + @override + String get setup_backup_subtitle => 'Bescherm je duiklogboek vanaf dag één.'; + + @override + String get setup_backup_title => 'Back-ups en synchronisatie'; + + @override + String get setup_common_back => 'Terug'; + + @override + String get setup_common_next => 'Volgende'; + + @override + String get setup_common_skip => 'Overslaan'; + + @override + String get setup_existing_folder_subtitle => + 'Wijs Submersion naar een map die al een bibliotheek bevat'; + + @override + String get setup_existing_folder_title => 'Een bestaande map openen'; + + @override + String get setup_existing_restore_subtitle => + 'Kies een back-upbestand dat uit Submersion is geëxporteerd'; + + @override + String get setup_existing_restore_title => 'Een back-upbestand terugzetten'; + + @override + String get setup_existing_subtitle => + 'Kies hoe je bestaande Submersion-bibliotheek wordt geladen'; + + @override + String get setup_existing_sync_subtitle => + 'Haal je bibliotheek op uit iCloud, Dropbox of S3'; + + @override + String get setup_existing_sync_title => 'Cloudsynchronisatie verbinden'; + + @override + String get setup_existing_title => 'Breng je gegevens mee'; + + @override + String get setup_finish_applying => 'Instellen...'; + + @override + String setup_finish_error(Object error) { + return 'Kon de installatie niet voltooien: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Duiken downloaden vanaf je duikcomputer'; + + @override + String get setup_finish_feature_gear => + 'Uitrusting en onderhoudsintervallen bijhouden'; + + @override + String get setup_finish_feature_import => + 'Logboeken importeren uit bestanden en andere apps'; + + @override + String get setup_finish_feature_sites => 'Je duikstekken op de kaart zetten'; + + @override + String get setup_finish_feature_statistics => + 'Statistieken over je duiken verkennen'; + + @override + String get setup_finish_start => 'Beginnen met loggen'; + + @override + String get setup_finish_subtitle => 'Submersion kan ook...'; + + @override + String get setup_finish_title => 'Je bent klaar'; + + @override + String get setup_folder_notFound_message => + 'De geselecteerde map bevat geen Submersion-database.'; + + @override + String get setup_folder_notFound_title => 'Geen bibliotheek in die map'; + + @override + String get setup_folder_pick => 'Map kiezen'; + + @override + String get setup_folder_switching => 'Bibliotheek openen...'; + + @override + String get setup_folder_title => 'Bestaande map openen'; + + @override + String get setup_profile_nameHint => 'Voer je naam in'; + + @override + String get setup_profile_nameLabel => 'Je naam'; + + @override + String get setup_profile_nameValidation => 'Voer je naam in'; + + @override + String get setup_profile_subtitle => + 'Voer je naam in om te beginnen. Je kunt later meer details toevoegen.'; + + @override + String get setup_profile_title => 'Maak je profiel aan'; + + @override + String get setup_restore_inProgress => 'Terugzetten...'; + + @override + String get setup_restore_pick => 'Back-upbestand kiezen'; + + @override + String get setup_restore_title => 'Back-up terugzetten'; + + @override + String get setup_step_backup => 'Back-up'; + + @override + String get setup_step_finish => 'Klaar'; + + @override + String get setup_step_profile => 'Profiel'; + + @override + String get setup_step_units => 'Eenheden'; + + @override + String get setup_syncPull_continue => 'Doorgaan'; + + @override + String get setup_syncPull_noLibrary_message => + 'Er is geen Submersion-bibliotheek gevonden op dit account. Opnieuw beginnen? Je verbinding blijft behouden.'; + + @override + String get setup_syncPull_noLibrary_title => 'Geen bibliotheek gevonden'; + + @override + String get setup_syncPull_success => 'Bibliotheek overgenomen'; + + @override + String get setup_syncPull_syncing => 'Bibliotheek ophalen...'; + + @override + String get setup_syncPull_title => 'Verbinden en ophalen'; + + @override + String get setup_sync_changeProvider => 'Provider wijzigen'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Verbonden met $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Kon geen verbinding maken: $error'; + } + + @override + String get setup_sync_header => 'Cloudsynchronisatie'; + + @override + String get setup_sync_icloudUnavailable => + 'iCloud is niet beschikbaar op dit apparaat'; + + @override + String get setup_sync_libraryFound_adopt => 'Bestaande bibliotheek overnemen'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Opnieuw beginnen'; + + @override + String get setup_sync_libraryFound_message => + 'Dit account bevat al een Submersion-bibliotheek. Deze overnemen in plaats van opnieuw te beginnen?'; + + @override + String get setup_sync_libraryFound_title => 'Bestaande bibliotheek gevonden'; + + @override + String get setup_sync_manageInSettings => 'Beheren in Instellingen'; + + @override + String get setup_sync_notConnected => 'Niet verbonden'; + + @override + String get setup_sync_subtitle => 'Synchroniseer je logboek tussen apparaten'; + + @override + String get setup_units_advanced => 'Eenheden verfijnen'; + + @override + String get setup_units_altitude => 'Hoogte'; + + @override + String get setup_units_dateFormat => 'Datumnotatie'; + + @override + String get setup_units_depth => 'Diepte'; + + @override + String get setup_units_imperial => 'Imperiaal'; + + @override + String get setup_units_metric => 'Metrisch'; + + @override + String get setup_units_pressure => 'Druk'; + + @override + String get setup_units_sac => 'SAC-waarde'; + + @override + String get setup_units_subtitle => + 'Kies hoe metingen worden weergegeven. Je kunt elke eenheid apart instellen.'; + + @override + String get setup_units_temperature => 'Temperatuur'; + + @override + String get setup_units_timeFormat => 'Tijdnotatie'; + + @override + String get setup_units_title => 'Eenheden'; + + @override + String get setup_units_volume => 'Volume'; + + @override + String get setup_units_weight => 'Gewicht'; + + @override + String get setup_welcome_existingData_subtitle => + 'Zet een back-up terug, verbind cloudsynchronisatie of open een bestaande map'; + + @override + String get setup_welcome_existingData_title => + 'Ik heb al Submersion-gegevens'; + + @override + String get setup_welcome_skipSetup => 'Installatie overslaan'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Maak je duikersprofiel en stel de app in'; + + @override + String get setup_welcome_startFresh_title => 'Een nieuw logboek instellen'; + + @override + String get setup_welcome_subtitle => 'Geavanceerd duiklogboek en analyse'; + + @override + String get setup_welcome_title => 'Welkom bij Submersion'; + @override String get siteMatchReview_title => 'Stekken koppelen'; @@ -10526,40 +10815,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get nav_trips => 'Reizen'; - @override - String get onboarding_welcome_createProfile => 'Maak je profiel aan'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Voer je naam in om te beginnen. Je kunt later meer details toevoegen.'; - - @override - String get onboarding_welcome_creating => 'Aanmaken...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Fout bij aanmaken profiel: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Aan de slag'; - - @override - String get onboarding_welcome_nameHint => 'Voer je naam in'; - - @override - String get onboarding_welcome_nameLabel => 'Je naam'; - - @override - String get onboarding_welcome_nameValidation => 'Voer je naam in'; - - @override - String get onboarding_welcome_subtitle => - 'Geavanceerd duiklogboek en analyse'; - - @override - String get onboarding_welcome_title => 'Welkom bij Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Beschikbaar $liters'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index d9fd5cbf4..865a3d813 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -5018,6 +5018,298 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => 'Assistente de configuração'; + + @override + String get settings_manage_setupAssistant_subtitle => + 'Reveja unidades, aparência e opções de backup'; + + @override + String get setup_backup_cloudCopy => 'Manter uma cópia de backup na nuvem'; + + @override + String get setup_backup_frequency => 'Frequência'; + + @override + String get setup_backup_frequency_daily => 'Diária'; + + @override + String get setup_backup_frequency_monthly => 'Mensal'; + + @override + String get setup_backup_frequency_weekly => 'Semanal'; + + @override + String get setup_backup_scheduleSubtitle => + 'Faça backup do seu registro periodicamente'; + + @override + String get setup_backup_scheduleToggle => 'Backups automáticos'; + + @override + String get setup_backup_subtitle => + 'Proteja seu registro de mergulho desde o primeiro dia.'; + + @override + String get setup_backup_title => 'Backups e sincronização'; + + @override + String get setup_common_back => 'Voltar'; + + @override + String get setup_common_next => 'Avançar'; + + @override + String get setup_common_skip => 'Ignorar'; + + @override + String get setup_existing_folder_subtitle => + 'Aponte o Submersion para uma pasta que já contém uma biblioteca'; + + @override + String get setup_existing_folder_title => 'Abrir uma pasta existente'; + + @override + String get setup_existing_restore_subtitle => + 'Escolha um arquivo de backup exportado do Submersion'; + + @override + String get setup_existing_restore_title => 'Restaurar um arquivo de backup'; + + @override + String get setup_existing_subtitle => + 'Escolha como carregar sua biblioteca existente do Submersion'; + + @override + String get setup_existing_sync_subtitle => + 'Baixe sua biblioteca do iCloud, Dropbox ou S3'; + + @override + String get setup_existing_sync_title => 'Conectar sincronização na nuvem'; + + @override + String get setup_existing_title => 'Traga seus dados'; + + @override + String get setup_finish_applying => 'Configurando...'; + + @override + String setup_finish_error(Object error) { + return 'Não foi possível concluir a configuração: $error'; + } + + @override + String get setup_finish_feature_diveComputer => + 'Baixe mergulhos do seu computador de mergulho'; + + @override + String get setup_finish_feature_gear => + 'Acompanhe equipamentos e intervalos de manutenção'; + + @override + String get setup_finish_feature_import => + 'Importe registros de arquivos e outros aplicativos'; + + @override + String get setup_finish_feature_sites => + 'Veja seus pontos de mergulho no mapa'; + + @override + String get setup_finish_feature_statistics => + 'Explore estatísticas sobre seus mergulhos'; + + @override + String get setup_finish_start => 'Começar a registrar'; + + @override + String get setup_finish_subtitle => 'O Submersion também pode...'; + + @override + String get setup_finish_title => 'Tudo pronto'; + + @override + String get setup_folder_notFound_message => + 'A pasta selecionada não contém um banco de dados do Submersion.'; + + @override + String get setup_folder_notFound_title => 'Nenhuma biblioteca nessa pasta'; + + @override + String get setup_folder_pick => 'Escolher pasta'; + + @override + String get setup_folder_switching => 'Abrindo biblioteca...'; + + @override + String get setup_folder_title => 'Abrir pasta existente'; + + @override + String get setup_profile_nameHint => 'Digite seu nome'; + + @override + String get setup_profile_nameLabel => 'Seu Nome'; + + @override + String get setup_profile_nameValidation => 'Digite seu nome'; + + @override + String get setup_profile_subtitle => + 'Digite seu nome para começar. Você pode adicionar mais detalhes depois.'; + + @override + String get setup_profile_title => 'Crie Seu Perfil'; + + @override + String get setup_restore_inProgress => 'Restaurando...'; + + @override + String get setup_restore_pick => 'Escolher arquivo de backup'; + + @override + String get setup_restore_title => 'Restaurar backup'; + + @override + String get setup_step_backup => 'Backup'; + + @override + String get setup_step_finish => 'Concluído'; + + @override + String get setup_step_profile => 'Perfil'; + + @override + String get setup_step_units => 'Unidades'; + + @override + String get setup_syncPull_continue => 'Continuar'; + + @override + String get setup_syncPull_noLibrary_message => + 'Nenhuma biblioteca do Submersion foi encontrada nesta conta. Começar do zero? Sua conexão será mantida.'; + + @override + String get setup_syncPull_noLibrary_title => 'Nenhuma biblioteca encontrada'; + + @override + String get setup_syncPull_success => 'Biblioteca adotada'; + + @override + String get setup_syncPull_syncing => 'Baixando sua biblioteca...'; + + @override + String get setup_syncPull_title => 'Conectar e baixar'; + + @override + String get setup_sync_changeProvider => 'Alterar provedor'; + + @override + String setup_sync_connectedTo(String provider) { + return 'Conectado a $provider'; + } + + @override + String setup_sync_error(Object error) { + return 'Não foi possível conectar: $error'; + } + + @override + String get setup_sync_header => 'Sincronização na nuvem'; + + @override + String get setup_sync_icloudUnavailable => + 'O iCloud não está disponível neste dispositivo'; + + @override + String get setup_sync_libraryFound_adopt => 'Adotar biblioteca existente'; + + @override + String get setup_sync_libraryFound_keepFresh => 'Começar do zero'; + + @override + String get setup_sync_libraryFound_message => + 'Esta conta já contém uma biblioteca do Submersion. Adotá-la em vez de começar do zero?'; + + @override + String get setup_sync_libraryFound_title => 'Biblioteca existente encontrada'; + + @override + String get setup_sync_manageInSettings => 'Gerenciar nas Configurações'; + + @override + String get setup_sync_notConnected => 'Não conectado'; + + @override + String get setup_sync_subtitle => + 'Sincronize seu registro entre dispositivos'; + + @override + String get setup_units_advanced => 'Ajustar unidades'; + + @override + String get setup_units_altitude => 'Altitude'; + + @override + String get setup_units_dateFormat => 'Formato de data'; + + @override + String get setup_units_depth => 'Profundidade'; + + @override + String get setup_units_imperial => 'Imperial'; + + @override + String get setup_units_metric => 'Métrico'; + + @override + String get setup_units_pressure => 'Pressão'; + + @override + String get setup_units_sac => 'Taxa SAC'; + + @override + String get setup_units_subtitle => + 'Escolha como as medidas são exibidas. Você pode ajustar cada unidade.'; + + @override + String get setup_units_temperature => 'Temperatura'; + + @override + String get setup_units_timeFormat => 'Formato de hora'; + + @override + String get setup_units_title => 'Unidades'; + + @override + String get setup_units_volume => 'Volume'; + + @override + String get setup_units_weight => 'Peso'; + + @override + String get setup_welcome_existingData_subtitle => + 'Restaure um backup, conecte a sincronização ou abra uma pasta existente'; + + @override + String get setup_welcome_existingData_title => 'Já tenho dados do Submersion'; + + @override + String get setup_welcome_skipSetup => 'Ignorar configuração'; + + @override + String get setup_welcome_startFresh_subtitle => + 'Crie seu perfil de mergulhador e configure o aplicativo'; + + @override + String get setup_welcome_startFresh_title => 'Configurar um novo registro'; + + @override + String get setup_welcome_subtitle => + 'Registro e análise avançada de mergulhos'; + + @override + String get setup_welcome_title => 'Bem-vindo ao Submersion'; + @override String get siteMatchReview_title => 'Associar pontos'; @@ -10609,40 +10901,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get nav_trips => 'Viagens'; - @override - String get onboarding_welcome_createProfile => 'Crie Seu Perfil'; - - @override - String get onboarding_welcome_createProfileSubtitle => - 'Digite seu nome para começar. Você pode adicionar mais detalhes depois.'; - - @override - String get onboarding_welcome_creating => 'Criando...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return 'Erro ao criar perfil: $error'; - } - - @override - String get onboarding_welcome_getStarted => 'Começar'; - - @override - String get onboarding_welcome_nameHint => 'Digite seu nome'; - - @override - String get onboarding_welcome_nameLabel => 'Seu Nome'; - - @override - String get onboarding_welcome_nameValidation => 'Digite seu nome'; - - @override - String get onboarding_welcome_subtitle => - 'Registro e análise avançada de mergulhos'; - - @override - String get onboarding_welcome_title => 'Bem-vindo ao Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return 'Disponível $liters'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index afcae4989..994d8b182 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -4762,6 +4762,278 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String get settings_manage_setupAssistant => '设置向导'; + + @override + String get settings_manage_setupAssistant_subtitle => '重新设置单位、外观和备份选项'; + + @override + String get setup_backup_cloudCopy => '在云端保留备份副本'; + + @override + String get setup_backup_frequency => '频率'; + + @override + String get setup_backup_frequency_daily => '每天'; + + @override + String get setup_backup_frequency_monthly => '每月'; + + @override + String get setup_backup_frequency_weekly => '每周'; + + @override + String get setup_backup_scheduleSubtitle => '按计划备份你的潜水日志'; + + @override + String get setup_backup_scheduleToggle => '自动备份'; + + @override + String get setup_backup_subtitle => '从第一天起保护你的潜水日志。'; + + @override + String get setup_backup_title => '备份与同步'; + + @override + String get setup_common_back => '返回'; + + @override + String get setup_common_next => '下一步'; + + @override + String get setup_common_skip => '跳过'; + + @override + String get setup_existing_folder_subtitle => '让 Submersion 使用已包含资料库的文件夹'; + + @override + String get setup_existing_folder_title => '打开现有文件夹'; + + @override + String get setup_existing_restore_subtitle => '选择从 Submersion 导出的备份文件'; + + @override + String get setup_existing_restore_title => '恢复备份文件'; + + @override + String get setup_existing_subtitle => '选择如何加载现有的 Submersion 资料库'; + + @override + String get setup_existing_sync_subtitle => '从 iCloud、Dropbox 或 S3 拉取资料库'; + + @override + String get setup_existing_sync_title => '连接云同步'; + + @override + String get setup_existing_title => '导入你的数据'; + + @override + String get setup_finish_applying => '正在设置...'; + + @override + String setup_finish_error(Object error) { + return '无法完成设置:$error'; + } + + @override + String get setup_finish_feature_diveComputer => '从潜水电脑下载潜水记录'; + + @override + String get setup_finish_feature_gear => '跟踪装备和保养周期'; + + @override + String get setup_finish_feature_import => '从文件和其他应用导入日志'; + + @override + String get setup_finish_feature_sites => '在地图上标记潜点'; + + @override + String get setup_finish_feature_statistics => '探索你的潜水统计数据'; + + @override + String get setup_finish_start => '开始记录'; + + @override + String get setup_finish_subtitle => 'Submersion 还可以...'; + + @override + String get setup_finish_title => '一切就绪'; + + @override + String get setup_folder_notFound_message => '所选文件夹不包含 Submersion 数据库。'; + + @override + String get setup_folder_notFound_title => '该文件夹中没有资料库'; + + @override + String get setup_folder_pick => '选择文件夹'; + + @override + String get setup_folder_switching => '正在打开资料库...'; + + @override + String get setup_folder_title => '打开现有文件夹'; + + @override + String get setup_profile_nameHint => '输入您的名字'; + + @override + String get setup_profile_nameLabel => '您的名字'; + + @override + String get setup_profile_nameValidation => '请输入您的名字'; + + @override + String get setup_profile_subtitle => '输入您的名字以开始使用。稍后可以添加更多详细信息。'; + + @override + String get setup_profile_title => '创建您的档案'; + + @override + String get setup_restore_inProgress => '正在恢复...'; + + @override + String get setup_restore_pick => '选择备份文件'; + + @override + String get setup_restore_title => '恢复备份'; + + @override + String get setup_step_backup => '备份'; + + @override + String get setup_step_finish => '完成'; + + @override + String get setup_step_profile => '个人资料'; + + @override + String get setup_step_units => '单位'; + + @override + String get setup_syncPull_continue => '继续'; + + @override + String get setup_syncPull_noLibrary_message => + '此账户中未找到 Submersion 资料库。要重新开始吗?你的连接将被保留。'; + + @override + String get setup_syncPull_noLibrary_title => '未找到资料库'; + + @override + String get setup_syncPull_success => '已采用资料库'; + + @override + String get setup_syncPull_syncing => '正在拉取资料库...'; + + @override + String get setup_syncPull_title => '连接并拉取'; + + @override + String get setup_sync_changeProvider => '更换服务商'; + + @override + String setup_sync_connectedTo(String provider) { + return '已连接到 $provider'; + } + + @override + String setup_sync_error(Object error) { + return '无法连接:$error'; + } + + @override + String get setup_sync_header => '云同步'; + + @override + String get setup_sync_icloudUnavailable => '此设备不支持 iCloud'; + + @override + String get setup_sync_libraryFound_adopt => '采用现有资料库'; + + @override + String get setup_sync_libraryFound_keepFresh => '重新开始'; + + @override + String get setup_sync_libraryFound_message => + '此账户已包含 Submersion 资料库。要采用它而不是重新开始吗?'; + + @override + String get setup_sync_libraryFound_title => '发现现有资料库'; + + @override + String get setup_sync_manageInSettings => '在设置中管理'; + + @override + String get setup_sync_notConnected => '未连接'; + + @override + String get setup_sync_subtitle => '在多设备间同步日志'; + + @override + String get setup_units_advanced => '微调单位'; + + @override + String get setup_units_altitude => '海拔'; + + @override + String get setup_units_dateFormat => '日期格式'; + + @override + String get setup_units_depth => '深度'; + + @override + String get setup_units_imperial => '英制'; + + @override + String get setup_units_metric => '公制'; + + @override + String get setup_units_pressure => '压力'; + + @override + String get setup_units_sac => 'SAC 耗气率'; + + @override + String get setup_units_subtitle => '选择测量值的显示方式。每个单位都可以单独微调。'; + + @override + String get setup_units_temperature => '温度'; + + @override + String get setup_units_timeFormat => '时间格式'; + + @override + String get setup_units_title => '单位'; + + @override + String get setup_units_volume => '容积'; + + @override + String get setup_units_weight => '重量'; + + @override + String get setup_welcome_existingData_subtitle => '恢复备份、连接云同步或打开现有文件夹'; + + @override + String get setup_welcome_existingData_title => '我已有 Submersion 数据'; + + @override + String get setup_welcome_skipSetup => '跳过设置'; + + @override + String get setup_welcome_startFresh_subtitle => '创建潜水员档案并配置应用'; + + @override + String get setup_welcome_startFresh_title => '创建新日志'; + + @override + String get setup_welcome_subtitle => '高级潜水日志与分析'; + + @override + String get setup_welcome_title => '欢迎使用 Submersion'; + @override String get siteMatchReview_title => '匹配潜水点'; @@ -10131,39 +10403,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nav_trips => '旅行'; - @override - String get onboarding_welcome_createProfile => '创建您的档案'; - - @override - String get onboarding_welcome_createProfileSubtitle => - '输入您的名字以开始使用。稍后可以添加更多详细信息。'; - - @override - String get onboarding_welcome_creating => '正在创建...'; - - @override - String onboarding_welcome_errorCreatingProfile(Object error) { - return '创建档案时出错:$error'; - } - - @override - String get onboarding_welcome_getStarted => '开始使用'; - - @override - String get onboarding_welcome_nameHint => '输入您的名字'; - - @override - String get onboarding_welcome_nameLabel => '您的名字'; - - @override - String get onboarding_welcome_nameValidation => '请输入您的名字'; - - @override - String get onboarding_welcome_subtitle => '高级潜水日志与分析'; - - @override - String get onboarding_welcome_title => '欢迎使用 Submersion'; - @override String plannerCanvas_bailout_available(String liters) { return '可用 $liters'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index b7ab1f914..601235450 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "Je herstelde bibliotheek wordt met de cloud gesynchroniseerd…", "settings_cloudSync_postRestore_synced": "Herstelde bibliotheek gesynchroniseerd.", "settings_cloudSync_replace_reviewAction": "Controleren", + "settings_manage_setupAssistant": "Configuratie-assistent", + "settings_manage_setupAssistant_subtitle": "Eenheden, weergave en back-upkeuzes opnieuw bekijken", + "setup_backup_cloudCopy": "Een back-upkopie in de cloud bewaren", + "setup_backup_frequency": "Frequentie", + "setup_backup_frequency_daily": "Dagelijks", + "setup_backup_frequency_monthly": "Maandelijks", + "setup_backup_frequency_weekly": "Wekelijks", + "setup_backup_scheduleSubtitle": "Maak volgens schema een back-up van je logboek", + "setup_backup_scheduleToggle": "Automatische back-ups", + "setup_backup_subtitle": "Bescherm je duiklogboek vanaf dag één.", + "setup_backup_title": "Back-ups en synchronisatie", + "setup_common_back": "Terug", + "setup_common_next": "Volgende", + "setup_common_skip": "Overslaan", + "setup_existing_folder_subtitle": "Wijs Submersion naar een map die al een bibliotheek bevat", + "setup_existing_folder_title": "Een bestaande map openen", + "setup_existing_restore_subtitle": "Kies een back-upbestand dat uit Submersion is geëxporteerd", + "setup_existing_restore_title": "Een back-upbestand terugzetten", + "setup_existing_subtitle": "Kies hoe je bestaande Submersion-bibliotheek wordt geladen", + "setup_existing_sync_subtitle": "Haal je bibliotheek op uit iCloud, Dropbox of S3", + "setup_existing_sync_title": "Cloudsynchronisatie verbinden", + "setup_existing_title": "Breng je gegevens mee", + "setup_finish_applying": "Instellen...", + "setup_finish_error": "Kon de installatie niet voltooien: {error}", + "setup_finish_feature_diveComputer": "Duiken downloaden vanaf je duikcomputer", + "setup_finish_feature_gear": "Uitrusting en onderhoudsintervallen bijhouden", + "setup_finish_feature_import": "Logboeken importeren uit bestanden en andere apps", + "setup_finish_feature_sites": "Je duikstekken op de kaart zetten", + "setup_finish_feature_statistics": "Statistieken over je duiken verkennen", + "setup_finish_start": "Beginnen met loggen", + "setup_finish_subtitle": "Submersion kan ook...", + "setup_finish_title": "Je bent klaar", + "setup_folder_notFound_message": "De geselecteerde map bevat geen Submersion-database.", + "setup_folder_notFound_title": "Geen bibliotheek in die map", + "setup_folder_pick": "Map kiezen", + "setup_folder_switching": "Bibliotheek openen...", + "setup_folder_title": "Bestaande map openen", + "setup_profile_nameHint": "Voer je naam in", + "setup_profile_nameLabel": "Je naam", + "setup_profile_nameValidation": "Voer je naam in", + "setup_profile_subtitle": "Voer je naam in om te beginnen. Je kunt later meer details toevoegen.", + "setup_profile_title": "Maak je profiel aan", + "setup_restore_inProgress": "Terugzetten...", + "setup_restore_pick": "Back-upbestand kiezen", + "setup_restore_title": "Back-up terugzetten", + "setup_step_backup": "Back-up", + "setup_step_finish": "Klaar", + "setup_step_profile": "Profiel", + "setup_step_units": "Eenheden", + "setup_syncPull_continue": "Doorgaan", + "setup_syncPull_noLibrary_message": "Er is geen Submersion-bibliotheek gevonden op dit account. Opnieuw beginnen? Je verbinding blijft behouden.", + "setup_syncPull_noLibrary_title": "Geen bibliotheek gevonden", + "setup_syncPull_success": "Bibliotheek overgenomen", + "setup_syncPull_syncing": "Bibliotheek ophalen...", + "setup_syncPull_title": "Verbinden en ophalen", + "setup_sync_changeProvider": "Provider wijzigen", + "setup_sync_connectedTo": "Verbonden met {provider}", + "setup_sync_error": "Kon geen verbinding maken: {error}", + "setup_sync_header": "Cloudsynchronisatie", + "setup_sync_icloudUnavailable": "iCloud is niet beschikbaar op dit apparaat", + "setup_sync_libraryFound_adopt": "Bestaande bibliotheek overnemen", + "setup_sync_libraryFound_keepFresh": "Opnieuw beginnen", + "setup_sync_libraryFound_message": "Dit account bevat al een Submersion-bibliotheek. Deze overnemen in plaats van opnieuw te beginnen?", + "setup_sync_libraryFound_title": "Bestaande bibliotheek gevonden", + "setup_sync_manageInSettings": "Beheren in Instellingen", + "setup_sync_notConnected": "Niet verbonden", + "setup_sync_subtitle": "Synchroniseer je logboek tussen apparaten", + "setup_units_advanced": "Eenheden verfijnen", + "setup_units_altitude": "Hoogte", + "setup_units_dateFormat": "Datumnotatie", + "setup_units_depth": "Diepte", + "setup_units_imperial": "Imperiaal", + "setup_units_metric": "Metrisch", + "setup_units_pressure": "Druk", + "setup_units_sac": "SAC-waarde", + "setup_units_subtitle": "Kies hoe metingen worden weergegeven. Je kunt elke eenheid apart instellen.", + "setup_units_temperature": "Temperatuur", + "setup_units_timeFormat": "Tijdnotatie", + "setup_units_title": "Eenheden", + "setup_units_volume": "Volume", + "setup_units_weight": "Gewicht", + "setup_welcome_existingData_subtitle": "Zet een back-up terug, verbind cloudsynchronisatie of open een bestaande map", + "setup_welcome_existingData_title": "Ik heb al Submersion-gegevens", + "setup_welcome_skipSetup": "Installatie overslaan", + "setup_welcome_startFresh_subtitle": "Maak je duikersprofiel en stel de app in", + "setup_welcome_startFresh_title": "Een nieuw logboek instellen", + "setup_welcome_subtitle": "Geavanceerd duiklogboek en analyse", + "setup_welcome_title": "Welkom bij Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3359,16 +3447,6 @@ "nav_tooltip_expandMenu": "Menu uitklappen", "nav_transfer": "Overdracht", "nav_trips": "Reizen", - "onboarding_welcome_createProfile": "Maak je profiel aan", - "onboarding_welcome_createProfileSubtitle": "Voer je naam in om te beginnen. Je kunt later meer details toevoegen.", - "onboarding_welcome_creating": "Aanmaken...", - "onboarding_welcome_errorCreatingProfile": "Fout bij aanmaken profiel: {error}", - "onboarding_welcome_getStarted": "Aan de slag", - "onboarding_welcome_nameHint": "Voer je naam in", - "onboarding_welcome_nameLabel": "Je naam", - "onboarding_welcome_nameValidation": "Voer je naam in", - "onboarding_welcome_subtitle": "Geavanceerd duiklogboek en analyse", - "onboarding_welcome_title": "Welkom bij Submersion", "plannerCanvas_bailout_available": "Beschikbaar {liters}", "plannerCanvas_bailout_insufficient": "Bailoutgas onvoldoende voor het slechtste geval", "plannerCanvas_bailout_required": "Nodig {liters}", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index e31eb85f8..250a418f5 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -67,6 +67,94 @@ "settings_cloudSync_postRestore_syncing": "A sincronizar a sua biblioteca restaurada com a nuvem…", "settings_cloudSync_postRestore_synced": "Biblioteca restaurada sincronizada.", "settings_cloudSync_replace_reviewAction": "Rever", + "settings_manage_setupAssistant": "Assistente de configuração", + "settings_manage_setupAssistant_subtitle": "Reveja unidades, aparência e opções de backup", + "setup_backup_cloudCopy": "Manter uma cópia de backup na nuvem", + "setup_backup_frequency": "Frequência", + "setup_backup_frequency_daily": "Diária", + "setup_backup_frequency_monthly": "Mensal", + "setup_backup_frequency_weekly": "Semanal", + "setup_backup_scheduleSubtitle": "Faça backup do seu registro periodicamente", + "setup_backup_scheduleToggle": "Backups automáticos", + "setup_backup_subtitle": "Proteja seu registro de mergulho desde o primeiro dia.", + "setup_backup_title": "Backups e sincronização", + "setup_common_back": "Voltar", + "setup_common_next": "Avançar", + "setup_common_skip": "Ignorar", + "setup_existing_folder_subtitle": "Aponte o Submersion para uma pasta que já contém uma biblioteca", + "setup_existing_folder_title": "Abrir uma pasta existente", + "setup_existing_restore_subtitle": "Escolha um arquivo de backup exportado do Submersion", + "setup_existing_restore_title": "Restaurar um arquivo de backup", + "setup_existing_subtitle": "Escolha como carregar sua biblioteca existente do Submersion", + "setup_existing_sync_subtitle": "Baixe sua biblioteca do iCloud, Dropbox ou S3", + "setup_existing_sync_title": "Conectar sincronização na nuvem", + "setup_existing_title": "Traga seus dados", + "setup_finish_applying": "Configurando...", + "setup_finish_error": "Não foi possível concluir a configuração: {error}", + "setup_finish_feature_diveComputer": "Baixe mergulhos do seu computador de mergulho", + "setup_finish_feature_gear": "Acompanhe equipamentos e intervalos de manutenção", + "setup_finish_feature_import": "Importe registros de arquivos e outros aplicativos", + "setup_finish_feature_sites": "Veja seus pontos de mergulho no mapa", + "setup_finish_feature_statistics": "Explore estatísticas sobre seus mergulhos", + "setup_finish_start": "Começar a registrar", + "setup_finish_subtitle": "O Submersion também pode...", + "setup_finish_title": "Tudo pronto", + "setup_folder_notFound_message": "A pasta selecionada não contém um banco de dados do Submersion.", + "setup_folder_notFound_title": "Nenhuma biblioteca nessa pasta", + "setup_folder_pick": "Escolher pasta", + "setup_folder_switching": "Abrindo biblioteca...", + "setup_folder_title": "Abrir pasta existente", + "setup_profile_nameHint": "Digite seu nome", + "setup_profile_nameLabel": "Seu Nome", + "setup_profile_nameValidation": "Digite seu nome", + "setup_profile_subtitle": "Digite seu nome para começar. Você pode adicionar mais detalhes depois.", + "setup_profile_title": "Crie Seu Perfil", + "setup_restore_inProgress": "Restaurando...", + "setup_restore_pick": "Escolher arquivo de backup", + "setup_restore_title": "Restaurar backup", + "setup_step_backup": "Backup", + "setup_step_finish": "Concluído", + "setup_step_profile": "Perfil", + "setup_step_units": "Unidades", + "setup_syncPull_continue": "Continuar", + "setup_syncPull_noLibrary_message": "Nenhuma biblioteca do Submersion foi encontrada nesta conta. Começar do zero? Sua conexão será mantida.", + "setup_syncPull_noLibrary_title": "Nenhuma biblioteca encontrada", + "setup_syncPull_success": "Biblioteca adotada", + "setup_syncPull_syncing": "Baixando sua biblioteca...", + "setup_syncPull_title": "Conectar e baixar", + "setup_sync_changeProvider": "Alterar provedor", + "setup_sync_connectedTo": "Conectado a {provider}", + "setup_sync_error": "Não foi possível conectar: {error}", + "setup_sync_header": "Sincronização na nuvem", + "setup_sync_icloudUnavailable": "O iCloud não está disponível neste dispositivo", + "setup_sync_libraryFound_adopt": "Adotar biblioteca existente", + "setup_sync_libraryFound_keepFresh": "Começar do zero", + "setup_sync_libraryFound_message": "Esta conta já contém uma biblioteca do Submersion. Adotá-la em vez de começar do zero?", + "setup_sync_libraryFound_title": "Biblioteca existente encontrada", + "setup_sync_manageInSettings": "Gerenciar nas Configurações", + "setup_sync_notConnected": "Não conectado", + "setup_sync_subtitle": "Sincronize seu registro entre dispositivos", + "setup_units_advanced": "Ajustar unidades", + "setup_units_altitude": "Altitude", + "setup_units_dateFormat": "Formato de data", + "setup_units_depth": "Profundidade", + "setup_units_imperial": "Imperial", + "setup_units_metric": "Métrico", + "setup_units_pressure": "Pressão", + "setup_units_sac": "Taxa SAC", + "setup_units_subtitle": "Escolha como as medidas são exibidas. Você pode ajustar cada unidade.", + "setup_units_temperature": "Temperatura", + "setup_units_timeFormat": "Formato de hora", + "setup_units_title": "Unidades", + "setup_units_volume": "Volume", + "setup_units_weight": "Peso", + "setup_welcome_existingData_subtitle": "Restaure um backup, conecte a sincronização ou abra uma pasta existente", + "setup_welcome_existingData_title": "Já tenho dados do Submersion", + "setup_welcome_skipSetup": "Ignorar configuração", + "setup_welcome_startFresh_subtitle": "Crie seu perfil de mergulhador e configure o aplicativo", + "setup_welcome_startFresh_title": "Configurar um novo registro", + "setup_welcome_subtitle": "Registro e análise avançada de mergulhos", + "setup_welcome_title": "Bem-vindo ao Submersion", "@trips_itinerary_dayLabel": { "placeholders": { "dayNumber": { @@ -3359,16 +3447,6 @@ "nav_tooltip_expandMenu": "Expandir menu", "nav_transfer": "Transferir", "nav_trips": "Viagens", - "onboarding_welcome_createProfile": "Crie Seu Perfil", - "onboarding_welcome_createProfileSubtitle": "Digite seu nome para começar. Você pode adicionar mais detalhes depois.", - "onboarding_welcome_creating": "Criando...", - "onboarding_welcome_errorCreatingProfile": "Erro ao criar perfil: {error}", - "onboarding_welcome_getStarted": "Começar", - "onboarding_welcome_nameHint": "Digite seu nome", - "onboarding_welcome_nameLabel": "Seu Nome", - "onboarding_welcome_nameValidation": "Digite seu nome", - "onboarding_welcome_subtitle": "Registro e análise avançada de mergulhos", - "onboarding_welcome_title": "Bem-vindo ao Submersion", "plannerCanvas_bailout_available": "Disponível {liters}", "plannerCanvas_bailout_insufficient": "Gás de bailout insuficiente para o pior caso", "plannerCanvas_bailout_required": "Necessário {liters}", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index a90a2bba2..2724a3236 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -105,6 +105,94 @@ } } }, + "settings_manage_setupAssistant": "设置向导", + "settings_manage_setupAssistant_subtitle": "重新设置单位、外观和备份选项", + "setup_backup_cloudCopy": "在云端保留备份副本", + "setup_backup_frequency": "频率", + "setup_backup_frequency_daily": "每天", + "setup_backup_frequency_monthly": "每月", + "setup_backup_frequency_weekly": "每周", + "setup_backup_scheduleSubtitle": "按计划备份你的潜水日志", + "setup_backup_scheduleToggle": "自动备份", + "setup_backup_subtitle": "从第一天起保护你的潜水日志。", + "setup_backup_title": "备份与同步", + "setup_common_back": "返回", + "setup_common_next": "下一步", + "setup_common_skip": "跳过", + "setup_existing_folder_subtitle": "让 Submersion 使用已包含资料库的文件夹", + "setup_existing_folder_title": "打开现有文件夹", + "setup_existing_restore_subtitle": "选择从 Submersion 导出的备份文件", + "setup_existing_restore_title": "恢复备份文件", + "setup_existing_subtitle": "选择如何加载现有的 Submersion 资料库", + "setup_existing_sync_subtitle": "从 iCloud、Dropbox 或 S3 拉取资料库", + "setup_existing_sync_title": "连接云同步", + "setup_existing_title": "导入你的数据", + "setup_finish_applying": "正在设置...", + "setup_finish_error": "无法完成设置:{error}", + "setup_finish_feature_diveComputer": "从潜水电脑下载潜水记录", + "setup_finish_feature_gear": "跟踪装备和保养周期", + "setup_finish_feature_import": "从文件和其他应用导入日志", + "setup_finish_feature_sites": "在地图上标记潜点", + "setup_finish_feature_statistics": "探索你的潜水统计数据", + "setup_finish_start": "开始记录", + "setup_finish_subtitle": "Submersion 还可以...", + "setup_finish_title": "一切就绪", + "setup_folder_notFound_message": "所选文件夹不包含 Submersion 数据库。", + "setup_folder_notFound_title": "该文件夹中没有资料库", + "setup_folder_pick": "选择文件夹", + "setup_folder_switching": "正在打开资料库...", + "setup_folder_title": "打开现有文件夹", + "setup_profile_nameHint": "输入您的名字", + "setup_profile_nameLabel": "您的名字", + "setup_profile_nameValidation": "请输入您的名字", + "setup_profile_subtitle": "输入您的名字以开始使用。稍后可以添加更多详细信息。", + "setup_profile_title": "创建您的档案", + "setup_restore_inProgress": "正在恢复...", + "setup_restore_pick": "选择备份文件", + "setup_restore_title": "恢复备份", + "setup_step_backup": "备份", + "setup_step_finish": "完成", + "setup_step_profile": "个人资料", + "setup_step_units": "单位", + "setup_syncPull_continue": "继续", + "setup_syncPull_noLibrary_message": "此账户中未找到 Submersion 资料库。要重新开始吗?你的连接将被保留。", + "setup_syncPull_noLibrary_title": "未找到资料库", + "setup_syncPull_success": "已采用资料库", + "setup_syncPull_syncing": "正在拉取资料库...", + "setup_syncPull_title": "连接并拉取", + "setup_sync_changeProvider": "更换服务商", + "setup_sync_connectedTo": "已连接到 {provider}", + "setup_sync_error": "无法连接:{error}", + "setup_sync_header": "云同步", + "setup_sync_icloudUnavailable": "此设备不支持 iCloud", + "setup_sync_libraryFound_adopt": "采用现有资料库", + "setup_sync_libraryFound_keepFresh": "重新开始", + "setup_sync_libraryFound_message": "此账户已包含 Submersion 资料库。要采用它而不是重新开始吗?", + "setup_sync_libraryFound_title": "发现现有资料库", + "setup_sync_manageInSettings": "在设置中管理", + "setup_sync_notConnected": "未连接", + "setup_sync_subtitle": "在多设备间同步日志", + "setup_units_advanced": "微调单位", + "setup_units_altitude": "海拔", + "setup_units_dateFormat": "日期格式", + "setup_units_depth": "深度", + "setup_units_imperial": "英制", + "setup_units_metric": "公制", + "setup_units_pressure": "压力", + "setup_units_sac": "SAC 耗气率", + "setup_units_subtitle": "选择测量值的显示方式。每个单位都可以单独微调。", + "setup_units_temperature": "温度", + "setup_units_timeFormat": "时间格式", + "setup_units_title": "单位", + "setup_units_volume": "容积", + "setup_units_weight": "重量", + "setup_welcome_existingData_subtitle": "恢复备份、连接云同步或打开现有文件夹", + "setup_welcome_existingData_title": "我已有 Submersion 数据", + "setup_welcome_skipSetup": "跳过设置", + "setup_welcome_startFresh_subtitle": "创建潜水员档案并配置应用", + "setup_welcome_startFresh_title": "创建新日志", + "setup_welcome_subtitle": "高级潜水日志与分析", + "setup_welcome_title": "欢迎使用 Submersion", "@tankPresets_deleteDefaultMessage": { "placeholders": { "name": { @@ -3510,16 +3598,6 @@ "nav_tooltip_expandMenu": "展开菜单", "nav_transfer": "传输", "nav_trips": "旅行", - "onboarding_welcome_createProfile": "创建您的档案", - "onboarding_welcome_createProfileSubtitle": "输入您的名字以开始使用。稍后可以添加更多详细信息。", - "onboarding_welcome_creating": "正在创建...", - "onboarding_welcome_errorCreatingProfile": "创建档案时出错:{error}", - "onboarding_welcome_getStarted": "开始使用", - "onboarding_welcome_nameHint": "输入您的名字", - "onboarding_welcome_nameLabel": "您的名字", - "onboarding_welcome_nameValidation": "请输入您的名字", - "onboarding_welcome_subtitle": "高级潜水日志与分析", - "onboarding_welcome_title": "欢迎使用 Submersion", "plannerCanvas_bailout_available": "可用 {liters}", "plannerCanvas_bailout_insufficient": "逃生气体不足以应对最坏情况", "plannerCanvas_bailout_required": "需要 {liters}", diff --git a/lib/features/import_wizard/domain/models/wizard_step_def.dart b/lib/shared/widgets/wizard/wizard_step_def.dart similarity index 100% rename from lib/features/import_wizard/domain/models/wizard_step_def.dart rename to lib/shared/widgets/wizard/wizard_step_def.dart diff --git a/lib/features/import_wizard/presentation/widgets/wizard_step_indicator.dart b/lib/shared/widgets/wizard/wizard_step_indicator.dart similarity index 100% rename from lib/features/import_wizard/presentation/widgets/wizard_step_indicator.dart rename to lib/shared/widgets/wizard/wizard_step_indicator.dart diff --git a/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart b/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart index 824b44e97..27ac412be 100644 --- a/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart +++ b/test/features/import_wizard/domain/adapters/import_source_adapter_test.dart @@ -5,7 +5,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; /// Minimal concrete implementation for testing the abstract class defaults. class _TestAdapter extends ImportSourceAdapter { diff --git a/test/features/import_wizard/domain/models/wizard_step_def_test.dart b/test/features/import_wizard/domain/models/wizard_step_def_test.dart index 309917887..e386c569c 100644 --- a/test/features/import_wizard/domain/models/wizard_step_def_test.dart +++ b/test/features/import_wizard/domain/models/wizard_step_def_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; // A simple provider that always resolves to true. final _alwaysTrueProvider = Provider((ref) => true); diff --git a/test/features/import_wizard/domain/services/step_skip_calculator_test.dart b/test/features/import_wizard/domain/services/step_skip_calculator_test.dart index 94f6de209..807642c0d 100644 --- a/test/features/import_wizard/domain/services/step_skip_calculator_test.dart +++ b/test/features/import_wizard/domain/services/step_skip_calculator_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show ProviderListenable; import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/domain/services/step_skip_calculator.dart'; /// Dummy provider for steps that should always show as "ready". diff --git a/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart b/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart index cc4555bb2..c8814a5e2 100644 --- a/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart +++ b/test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart @@ -12,7 +12,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/pages/unified_import_wizard.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; diff --git a/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart b/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart index d20fcf634..8d69e3226 100644 --- a/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/import_progress_step_test.dart @@ -8,7 +8,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/import_progress_step.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; diff --git a/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart b/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart index 642575b9b..eab379cfe 100644 --- a/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/import_summary_step_test.dart @@ -11,7 +11,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_cancellat import 'package:submersion/features/import_wizard/domain/models/import_file_outcome.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/import_summary_step.dart'; import 'package:submersion/features/dive_sites/presentation/providers/site_match_review_notifier.dart'; diff --git a/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart b/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart index 48921572d..7d034a97c 100644 --- a/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart +++ b/test/features/import_wizard/presentation/widgets/review_step_pending_test.dart @@ -9,7 +9,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/review_step.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; diff --git a/test/features/import_wizard/presentation/widgets/review_step_test.dart b/test/features/import_wizard/presentation/widgets/review_step_test.dart index 20288ad83..a832cadf0 100644 --- a/test/features/import_wizard/presentation/widgets/review_step_test.dart +++ b/test/features/import_wizard/presentation/widgets/review_step_test.dart @@ -11,7 +11,7 @@ import 'package:submersion/features/import_wizard/domain/models/import_bundle.da import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart'; import 'package:submersion/features/import_wizard/domain/models/import_phase.dart'; import 'package:submersion/features/import_wizard/domain/models/unified_import_result.dart'; -import 'package:submersion/features/import_wizard/domain/models/wizard_step_def.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_def.dart'; import 'package:submersion/features/import_wizard/presentation/providers/import_wizard_providers.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/entity_review_list.dart'; import 'package:submersion/features/import_wizard/presentation/widgets/review_step.dart'; diff --git a/test/features/import_wizard/presentation/widgets/wizard_step_indicator_test.dart b/test/features/import_wizard/presentation/widgets/wizard_step_indicator_test.dart index 572d0db70..9edf7322c 100644 --- a/test/features/import_wizard/presentation/widgets/wizard_step_indicator_test.dart +++ b/test/features/import_wizard/presentation/widgets/wizard_step_indicator_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/features/import_wizard/presentation/widgets/wizard_step_indicator.dart'; +import 'package:submersion/shared/widgets/wizard/wizard_step_indicator.dart'; void main() { const labels = ['Select', 'Review', 'Import', 'Done']; diff --git a/test/features/setup_wizard/data/setup_apply_service_test.dart b/test/features/setup_wizard/data/setup_apply_service_test.dart new file mode 100644 index 000000000..9edee8858 --- /dev/null +++ b/test/features/setup_wizard/data/setup_apply_service_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/data/repositories/diver_settings_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/data/setup_apply_service.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; + +import '../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + ProviderContainer makeContainer() { + final container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); + addTearDown(container.dispose); + return container; + } + + test( + 'applyFirstRun creates default diver, seeds settings, sets current', + () async { + final container = makeContainer(); + final service = container.read(setupApplyServiceProvider); + + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + name: 'Eric', + backupEnabled: true, + backupFrequency: BackupFrequency.daily, + ); + final imperialDraft = draft.applyingUnitPreset(UnitPreset.imperial); + + await service.applyFirstRun(imperialDraft); + + final divers = await DiverRepository().getAllDivers(); + expect(divers, hasLength(1)); + expect(divers.single.name, 'Eric'); + expect(divers.single.isDefault, isTrue); + + // Settings row was seeded with the draft BEFORE the diver switch. + final stored = await DiverSettingsRepository().getSettingsForDiver( + divers.single.id, + ); + expect(stored, isNotNull); + expect(stored!.depthUnit, DepthUnit.feet); + expect(stored.pressureUnit, PressureUnit.psi); + expect(stored.weightUnit, WeightUnit.pounds); + + // Current diver persisted to prefs. + expect(prefs.getString(currentDiverIdKey), divers.single.id); + + // Backup schedule applied. + final backup = container.read(backupSettingsProvider); + expect(backup.enabled, isTrue); + expect(backup.frequency, BackupFrequency.daily); + }, + ); + + test( + 'applyFirstRun with empty name throws ArgumentError and writes nothing', + () async { + final container = makeContainer(); + final service = container.read(setupApplyServiceProvider); + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + + await expectLater(service.applyFirstRun(draft), throwsArgumentError); + expect(await DiverRepository().getAllDivers(), isEmpty); + }, + ); + + test( + 'applySettingsMode updates the current diver via existing setters', + () async { + final container = makeContainer(); + + // Seed a diver + make it current, mirroring a real re-entry session. + final repo = DiverRepository(); + final now = DateTime.now(); + final diver = await repo.createDiver( + Diver( + id: '', + name: 'Existing', + isDefault: true, + createdAt: now, + updatedAt: now, + ), + ); + await container + .read(currentDiverIdProvider.notifier) + .setCurrentDiver(diver.id); + // Let the SettingsNotifier finish its reload before applying, mirroring + // a settled re-entry session (bounded poll; see plan Task 5 note). + container.read(settingsProvider.notifier); + var waited = 0; + while (waited < 1000) { + await Future.delayed(const Duration(milliseconds: 20)); + waited += 20; + // A loaded notifier persists writes; probe via the repository. + final probe = await DiverSettingsRepository().getSettingsForDiver( + diver.id, + ); + if (probe != null) break; + } + + final service = container.read(setupApplyServiceProvider); + const base = SetupWizardDraft(mode: SetupWizardMode.settings); + final draft = base.applyingUnitPreset(UnitPreset.imperial); + await service.applySettingsMode(draft); + + expect(container.read(settingsProvider).depthUnit, DepthUnit.feet); + final stored = await DiverSettingsRepository().getSettingsForDiver( + diver.id, + ); + expect(stored!.depthUnit, DepthUnit.feet); + expect(stored.volumeUnit, VolumeUnit.cubicFeet); + }, + ); + + test( + 'settings-mode draft seeds live backup state; Finish preserves it', + () async { + // A backup schedule is already enabled before the wizard opens. + SharedPreferences.setMockInitialValues({ + 'backup_enabled': true, + 'backup_frequency': 'daily', + }); + final seededPrefs = await SharedPreferences.getInstance(); + final container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(seededPrefs)], + ); + addTearDown(container.dispose); + + // The re-entry draft must mirror the live schedule, not defaults. + final draft = container.read( + setupWizardProvider(SetupWizardMode.settings), + ); + expect(draft.backupEnabled, isTrue); + expect(draft.backupFrequency, BackupFrequency.daily); + + // Applying an untouched settings-mode draft must not disable backups + // (regression: seeding defaults here silently turned them off). + await container.read(setupApplyServiceProvider).applySettingsMode(draft); + expect(container.read(backupSettingsProvider).enabled, isTrue); + expect( + container.read(backupSettingsProvider).frequency, + BackupFrequency.daily, + ); + }, + ); +} diff --git a/test/features/setup_wizard/domain/setup_wizard_models_test.dart b/test/features/setup_wizard/domain/setup_wizard_models_test.dart new file mode 100644 index 000000000..a3bf02da6 --- /dev/null +++ b/test/features/setup_wizard/domain/setup_wizard_models_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; + +void main() { + group('computeSteps', () { + test('first run starts with fork only while path undecided', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + expect(computeSteps(draft), [SetupStepId.welcomeFork]); + }); + + test('fresh path shows full first-run flow', () { + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + ); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.units, + SetupStepId.backupSync, + SetupStepId.finish, + ]); + }); + + test('skip setup collapses fresh path to profile then finish', () { + const draft = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.fresh, + skipRequested: true, + ); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]); + }); + + test('existing-data path shows choice, then source step', () { + const undecided = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + ); + expect(computeSteps(undecided), [ + SetupStepId.welcomeFork, + SetupStepId.existingChoice, + ]); + + const restore = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.restoreBackup, + ); + expect(computeSteps(restore), [ + SetupStepId.welcomeFork, + SetupStepId.existingChoice, + SetupStepId.restore, + ]); + + const sync = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.cloudSync, + ); + expect(computeSteps(sync).last, SetupStepId.syncConnect); + + const folder = SetupWizardDraft( + mode: SetupWizardMode.firstRun, + path: SetupPath.existingData, + source: ExistingDataSource.openFolder, + ); + expect(computeSteps(folder).last, SetupStepId.openFolder); + }); + + test('settings mode hides fork and profile', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.settings); + expect(computeSteps(draft), [ + SetupStepId.units, + SetupStepId.backupSync, + SetupStepId.finish, + ]); + }); + }); + + group('SetupWizardDraft', () { + test('applyUnitPreset imperial sets the six core units', () { + const draft = SetupWizardDraft(mode: SetupWizardMode.firstRun); + final imperial = draft.applyingUnitPreset(UnitPreset.imperial); + expect(imperial.settings.depthUnit, DepthUnit.feet); + expect(imperial.settings.temperatureUnit, TemperatureUnit.fahrenheit); + expect(imperial.settings.pressureUnit, PressureUnit.psi); + expect(imperial.settings.volumeUnit, VolumeUnit.cubicFeet); + expect(imperial.settings.weightUnit, WeightUnit.pounds); + expect(imperial.settings.altitudeUnit, AltitudeUnit.feet); + // sacUnit untouched by preset + expect(imperial.settings.sacUnit, const AppSettings().sacUnit); + // and back to metric + final metric = imperial.applyingUnitPreset(UnitPreset.metric); + expect(metric.settings.unitPreset, UnitPreset.metric); + }); + }); +} diff --git a/test/features/setup_wizard/domain/unit_preset_detector_test.dart b/test/features/setup_wizard/domain/unit_preset_detector_test.dart new file mode 100644 index 000000000..fc94f07f5 --- /dev/null +++ b/test/features/setup_wizard/domain/unit_preset_detector_test.dart @@ -0,0 +1,19 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/unit_preset_detector.dart'; + +void main() { + test('US, Liberia, Myanmar map to imperial', () { + expect(presetForLocale(const Locale('en', 'US')), UnitPreset.imperial); + expect(presetForLocale(const Locale('en', 'LR')), UnitPreset.imperial); + expect(presetForLocale(const Locale('my', 'MM')), UnitPreset.imperial); + }); + + test('everything else maps to metric', () { + expect(presetForLocale(const Locale('de', 'DE')), UnitPreset.metric); + expect(presetForLocale(const Locale('en', 'GB')), UnitPreset.metric); + expect(presetForLocale(const Locale('en')), UnitPreset.metric); + }); +} diff --git a/test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart b/test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart new file mode 100644 index 000000000..545c349af --- /dev/null +++ b/test/features/setup_wizard/presentation/pages/first_run_redirect_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/router/app_router.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + testWidgets('zero divers redirects initial route to the setup wizard', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + final container = ProviderContainer( + overrides: [ + ...overrides, + hasAnyDiversProvider.overrideWith((ref) async => false), + ], + ); + addTearDown(container.dispose); + final router = container.read(appRouterProvider); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + // OceanBackground animates forever; use fixed pumps, not pumpAndSettle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byType(SetupWizardPage), findsOneWidget); + expect(find.text('Welcome to Submersion'), findsOneWidget); + }); +} diff --git a/test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart b/test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart new file mode 100644 index 000000000..e7209fe4c --- /dev/null +++ b/test/features/setup_wizard/presentation/pages/setup_wizard_page_test.dart @@ -0,0 +1,303 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/domain/entities/storage_config.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/core/services/sync/sync_initializer.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/pages/setup_wizard_page.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_app.dart'; + +/// Idle fakes so the existing-data source steps render without real pickers, +/// timers, or sync services when driven through the shell. +class _FakeStorageNotifier extends StateNotifier + implements StorageConfigNotifier { + _FakeStorageNotifier() + : super(const StorageConfigState(config: StorageConfig())); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeBackupOp extends StateNotifier + implements BackupOperationNotifier { + _FakeBackupOp() : super(const BackupOperationState()); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSyncInit implements SyncInitializer { + @override + Future> peerSyncFiles( + CloudStorageProvider provider, + ) async => const []; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSyncNotifier extends StateNotifier + implements SyncNotifier { + _FakeSyncNotifier() : super(const SyncState()); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// OceanBackground animates forever, so pumpAndSettle would time out. +/// Fixed pumps cover the post-frame advance, the 300 ms page transition, +/// and the setState frame that follows the transition future. +Future pumpWizard(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +void main() { + testWidgets('first run shows fork; fresh choice walks to profile and back', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), + ); + await pumpWizard(tester); + + expect(find.text('Welcome to Submersion'), findsOneWidget); + expect(find.text('Set up a new logbook'), findsOneWidget); + expect(find.text('I have existing Submersion data'), findsOneWidget); + + await tester.tap(find.text('Set up a new logbook')); + await pumpWizard(tester); + + expect(find.text('Create Your Profile'), findsOneWidget); + + // Next disabled with empty name. + final nextFinder = find.widgetWithText(FilledButton, 'Next'); + expect(tester.widget(nextFinder).onPressed, isNull); + + await tester.enterText(find.byType(TextFormField), 'Eric'); + await pumpWizard(tester); + expect(tester.widget(nextFinder).onPressed, isNotNull); + + await tester.tap(nextFinder); + await pumpWizard(tester); + // Units placeholder page (real step lands in Task 7). + expect(find.text('Units'), findsWidgets); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Back')); + await pumpWizard(tester); + expect(find.text('Create Your Profile'), findsOneWidget); + }); + + testWidgets('skip setup jumps from profile straight to finish placeholder', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), + ); + await pumpWizard(tester); + + await tester.ensureVisible(find.text('Skip setup')); + await tester.pump(); + await tester.tap(find.text('Skip setup')); + await pumpWizard(tester); + expect(find.text('Create Your Profile'), findsOneWidget); + + await tester.enterText(find.byType(TextFormField), 'Eric'); + await pumpWizard(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await pumpWizard(tester); + + expect(find.text("You're all set"), findsOneWidget); + }); + + testWidgets('settings mode starts at units with no fork', (tester) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.settings), + ), + ); + await pumpWizard(tester); + + expect(find.text('Welcome to Submersion'), findsNothing); + expect(find.text('Units'), findsWidgets); + }); + + testWidgets('existing-data steps have a back button to the fork', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: overrides, + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), + ); + await pumpWizard(tester); + + await tester.tap(find.text('I have existing Submersion data')); + await pumpWizard(tester); + expect(find.text('Bring your data'), findsOneWidget); + + // The choice step must be reversible (regression: it had no bottom bar + // and no back affordance, stranding the user). + final backButton = find.byTooltip('Back'); + expect(backButton, findsOneWidget); + + await tester.tap(backButton); + await pumpWizard(tester); + expect(find.text('Set up a new logbook'), findsOneWidget); + }); + + testWidgets('existing-data path renders each source step, reversibly', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.unsupported, + ), + backupOperationProvider.overrideWith((ref) => _FakeBackupOp()), + storageConfigNotifierProvider.overrideWith( + (ref) => _FakeStorageNotifier(), + ), + syncInitializerProvider.overrideWithValue(_FakeSyncInit()), + syncStateProvider.overrideWith((ref) => _FakeSyncNotifier()), + ], + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), + ); + await pumpWizard(tester); + + await tester.tap(find.text('I have existing Submersion data')); + await pumpWizard(tester); + expect(find.text('Bring your data'), findsOneWidget); + + // Restore source, then back to the choice. + await tester.tap(find.text('Restore a backup file')); + await pumpWizard(tester); + expect(find.text('Restore backup'), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + await pumpWizard(tester); + expect(find.text('Bring your data'), findsOneWidget); + + // Open-folder source, then back. + await tester.tap(find.text('Open an existing folder')); + await pumpWizard(tester); + expect(find.text('Open existing folder'), findsOneWidget); + await tester.tap(find.byTooltip('Back')); + await pumpWizard(tester); + + // Connect-cloud-sync source renders its connect phase. + await tester.tap(find.text('Connect cloud sync')); + await pumpWizard(tester); + expect(find.widgetWithText(FilledButton, 'Continue'), findsOneWidget); + }); + + testWidgets('fresh path walks profile, units, backup, then finish', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.unsupported, + ), + ], + child: const SetupWizardPage(mode: SetupWizardMode.firstRun), + ), + ); + await pumpWizard(tester); + + await tester.tap(find.text('Set up a new logbook')); + await pumpWizard(tester); + await tester.enterText(find.byType(TextFormField), 'Eric'); + await pumpWizard(tester); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await pumpWizard(tester); // -> units + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await pumpWizard(tester); // -> backup & sync + expect(find.text('Backups & Sync'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Next')); + await pumpWizard(tester); // -> finish + expect(find.text("You're all set"), findsOneWidget); + }); + + testWidgets('sync pull with no peer library pivots back to the fresh path', ( + tester, + ) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.unsupported, + ), + backupOperationProvider.overrideWith((ref) => _FakeBackupOp()), + storageConfigNotifierProvider.overrideWith( + (ref) => _FakeStorageNotifier(), + ), + syncInitializerProvider.overrideWithValue(_FakeSyncInit()), + syncStateProvider.overrideWith((ref) => _FakeSyncNotifier()), + ], + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const SetupWizardPage(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await pumpWizard(tester); + + await tester.tap(find.text('I have existing Submersion data')); + await pumpWizard(tester); + await tester.tap(find.text('Connect cloud sync')); + await pumpWizard(tester); + + // Simulate a completed connect, then pull: no peer library is found. + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await pumpWizard(tester); + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await pumpWizard(tester); + expect(find.text('No library found'), findsOneWidget); + + // Starting fresh pivots the wizard back to the profile step. + await tester.tap(find.widgetWithText(FilledButton, 'Start fresh')); + await pumpWizard(tester); + expect(find.text('Create Your Profile'), findsOneWidget); + }); +} diff --git a/test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart b/test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart new file mode 100644 index 000000000..b815df7c4 --- /dev/null +++ b/test/features/setup_wizard/presentation/providers/setup_wizard_providers_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; + +void main() { + ProviderContainer makeContainer() { + final container = ProviderContainer(); + addTearDown(container.dispose); + return container; + } + + test('starts undecided in first-run mode, seeded from defaults', () { + final container = makeContainer(); + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.undecided); + expect(draft.settings, const AppSettings()); + }); + + test('mutators update the draft', () { + final container = makeContainer(); + final notifier = container.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.choosePath(SetupPath.fresh); + notifier.setName(' Eric '); + notifier.applyUnitPreset(UnitPreset.imperial); + notifier.setBackupEnabled(true); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.fresh); + expect(draft.name, 'Eric'); + expect(draft.settings.unitPreset, UnitPreset.imperial); + expect(draft.backupEnabled, isTrue); + expect(computeSteps(draft), contains(SetupStepId.backupSync)); + }); + + test('setConnectedProvider(null) clears a previous connection', () { + final container = makeContainer(); + final notifier = container.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.setConnectedProvider(CloudProviderType.s3); + expect( + container + .read(setupWizardProvider(SetupWizardMode.firstRun)) + .connectedProvider, + CloudProviderType.s3, + ); + notifier.setConnectedProvider(null); + expect( + container + .read(setupWizardProvider(SetupWizardMode.firstRun)) + .connectedProvider, + isNull, + ); + }); + + test('requestSkip trims the fresh path', () { + final container = makeContainer(); + final notifier = container.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.requestSkip(); + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.path, SetupPath.fresh); + expect(draft.skipRequested, isTrue); + expect(computeSteps(draft), [ + SetupStepId.welcomeFork, + SetupStepId.profile, + SetupStepId.finish, + ]); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart new file mode 100644 index 000000000..2fa94342f --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/backup_sync_step_test.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart' + show CloudProviderType; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/core/services/sync/sync_initializer.dart'; +import 'package:submersion/features/backup/domain/entities/backup_settings.dart'; +import 'package:submersion/features/settings/presentation/pages/s3_config_page.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/backup_sync_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; +import '../../../../../helpers/test_app.dart'; + +class _FakeSyncInit implements SyncInitializer { + @override + Future saveProvider(CloudProviderType? provider) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSyncNotifier extends StateNotifier + implements SyncNotifier { + _FakeSyncNotifier() : super(const SyncState()); + + @override + Future refreshState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + testWidgets('backup toggle reveals frequency and updates draft', ( + tester, + ) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + // Deterministic gates: not Apple, no Dropbox key. + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const BackupSyncStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Frequency'), findsNothing); + await tester.tap(find.text('Automatic backups')); + await tester.pumpAndSettle(); + + expect(find.text('Frequency'), findsOneWidget); + await tester.tap(find.text('Daily')); + await tester.pumpAndSettle(); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.backupEnabled, isTrue); + expect(draft.backupFrequency, BackupFrequency.daily); + }); + + testWidgets('non-Apple platform without Dropbox shows S3 card only', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: const BackupSyncStep(mode: SetupWizardMode.firstRun), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('iCloud'), findsNothing); + expect(find.text('Dropbox'), findsNothing); + expect(find.text('S3'), findsOneWidget); + expect(find.text('Not connected'), findsOneWidget); + }); + + testWidgets('tapping S3 opens the config page via the root navigator', ( + tester, + ) async { + // testApp provides a plain Navigator but NO GoRouter. The pre-fix + // context.push would throw here; the fix uses the root Navigator so the + // config page opens without going through the onboarding redirect. + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + // Simulate S3 having been activated on the config page so the + // post-return handler records it in the draft. + selectedCloudProviderTypeProvider.overrideWith( + (ref) => CloudProviderType.s3, + ), + ], + child: const BackupSyncStep(mode: SetupWizardMode.firstRun), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('S3')); + await tester.pumpAndSettle(); + expect(find.byType(S3ConfigPage), findsOneWidget); + + // Close the config page; the step reads the active provider and reflects + // it as connected. + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('Connected to S3'), findsOneWidget); + }); + + const connectedNames = { + CloudProviderType.icloud: 'iCloud', + CloudProviderType.dropbox: 'Dropbox', + CloudProviderType.s3: 'S3', + CloudProviderType.googledrive: 'Google Drive', + }; + for (final entry in connectedNames.entries) { + testWidgets('settings mode shows connected UI for ${entry.value}', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + selectedCloudProviderTypeProvider.overrideWith((ref) => entry.key), + ], + child: const BackupSyncStep(mode: SetupWizardMode.settings), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Connected to ${entry.value}'), findsOneWidget); + expect(find.text('Manage in Settings'), findsOneWidget); + + // Toggling the cloud-copy switch drives setCloudBackupEnabled. + await tester.tap(find.text('Keep a backup copy in the cloud')); + await tester.pumpAndSettle(); + }); + } + + testWidgets('first run can disconnect and return to the provider cards', ( + tester, + ) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + syncInitializerProvider.overrideWithValue(_FakeSyncInit()), + syncStateProvider.overrideWith((ref) => _FakeSyncNotifier()), + ], + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const BackupSyncStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + // Simulate a connected provider (e.g. iCloud connected, then a failed + // pull left the user here). The cards are hidden. + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.icloud); + await tester.pumpAndSettle(); + expect(find.text('Connected to iCloud'), findsOneWidget); + expect(find.text('S3'), findsNothing); + + // Change provider clears the connection and restores the cards. + await tester.tap(find.text('Change provider')); + await tester.pumpAndSettle(); + expect(find.text('Connected to iCloud'), findsNothing); + expect(find.text('S3'), findsOneWidget); + expect( + container + .read(setupWizardProvider(SetupWizardMode.firstRun)) + .connectedProvider, + isNull, + ); + }); + + testWidgets('iCloud card renders when the platform supports it', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(true), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.available, + ), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: const BackupSyncStep(mode: SetupWizardMode.firstRun), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('iCloud'), findsOneWidget); + expect(find.text('S3'), findsOneWidget); + }); + + testWidgets('unsupported iCloud on Apple shows a disabled explanation', ( + tester, + ) async { + final overrides = await getBaseOverrides(); + await tester.pumpWidget( + testApp( + overrides: [ + ...overrides, + isApplePlatformProvider.overrideWithValue(true), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.unsupported, + ), + dropboxConfiguredProvider.overrideWithValue(false), + ], + child: const BackupSyncStep(mode: SetupWizardMode.firstRun), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('iCloud'), findsOneWidget); + expect(find.text('iCloud is not available on this device'), findsOneWidget); + // The disabled tile is not tappable. + final tile = tester.widget( + find.ancestor(of: find.text('iCloud'), matching: find.byType(ListTile)), + ); + expect(tile.enabled, isFalse); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart b/test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart new file mode 100644 index 000000000..eb01e9215 --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/existing_data_steps_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/existing_choice_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('existing choice cards set the draft source', (tester) async { + late ProviderContainer container; + final overrides = await getBaseOverrides(); + var advanced = 0; + await tester.pumpWidget( + testApp( + overrides: overrides, + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return ExistingChoiceStep( + mode: SetupWizardMode.firstRun, + onChosen: () => advanced++, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Bring your data'), findsOneWidget); + await tester.tap(find.text('Restore a backup file')); + await tester.pumpAndSettle(); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.source, ExistingDataSource.restoreBackup); + expect(advanced, 1); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/finish_routes_test.dart b/test/features/setup_wizard/presentation/widgets/steps/finish_routes_test.dart new file mode 100644 index 000000000..36d0614c0 --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/finish_routes_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/router/app_router.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/finish_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; + +/// Collects absolute paths from the route tree, resolving relative child +/// paths against their parent (go_router stores them relative). +Set _absolutePaths(List routes, [String prefix = '']) { + final out = {}; + for (final route in routes) { + if (route is GoRoute) { + final full = route.path.startsWith('/') + ? route.path + : '$prefix/${route.path}'; + out.add(full); + out.addAll(_absolutePaths(route.routes, full)); + } else if (route is ShellRoute) { + out.addAll(_absolutePaths(route.routes, prefix)); + } + } + return out; +} + +void main() { + test( + 'every finish-screen feature route resolves in the app router', + () async { + final overrides = await getBaseOverrides(); + final container = ProviderContainer( + overrides: [ + ...overrides, + hasAnyDiversProvider.overrideWith((ref) async => true), + ], + ); + addTearDown(container.dispose); + + final router = container.read(appRouterProvider); + final paths = _absolutePaths(router.configuration.routes); + + for (final route in kSetupFinishFeatureRoutes) { + expect( + paths, + contains(route), + reason: 'Finish-screen link "$route" has no matching route', + ); + } + }, + ); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart new file mode 100644 index 000000000..71bbd9171 --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/finish_step_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/finish_step.dart'; + +import '../../../../../helpers/test_app.dart'; +import '../../../../../helpers/test_database.dart'; + +void main() { + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + testWidgets('start logging applies draft and navigates to dashboard', ( + tester, + ) async { + var dashboardShown = false; + var seeded = false; + final router = GoRouter( + initialLocation: '/wizard', + routes: [ + GoRoute( + path: '/wizard', + builder: (context, state) => Consumer( + builder: (context, ref, _) { + // Watch keeps the autoDispose family instance alive for the + // whole test (in production the wizard shell watches it). + ref.watch(setupWizardProvider(SetupWizardMode.firstRun)); + // Seed a completed fresh-path draft after this build frame + // (mutating providers during build is forbidden). Once only: + // the mutation rebuilds this watcher, which must not reseed. + if (!seeded) { + seeded = true; + Future.microtask(() { + final notifier = ref.read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ); + notifier.choosePath(SetupPath.fresh); + notifier.setName('Eric'); + }); + } + return const Scaffold( + body: FinishStep(mode: SetupWizardMode.firstRun), + ); + }, + ), + ), + GoRoute( + path: '/dashboard', + builder: (context, state) { + dashboardShown = true; + return const Scaffold(body: Text('dashboard')); + }, + ), + ], + ); + + await tester.pumpWidget( + testAppRouter( + router: router, + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text("You're all set"), findsOneWidget); + expect(find.text('Download dives from your dive computer'), findsOneWidget); + + await tester.tap(find.text('Start logging')); + await tester.runAsync(() async { + // Apply performs real DB writes; let them settle outside fake time. + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect(dashboardShown, isTrue); + final divers = await tester.runAsync( + () => DiverRepository().getAllDivers(), + ); + expect(divers, hasLength(1)); + expect(divers!.single.name, 'Eric'); + }); + + testWidgets('apply failure surfaces an error and re-enables the button', ( + tester, + ) async { + var seeded = false; + await tester.pumpWidget( + testApp( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: Consumer( + builder: (context, ref, _) { + ref.watch(setupWizardProvider(SetupWizardMode.firstRun)); + if (!seeded) { + seeded = true; + // Fresh path but no name -> applyFirstRun throws ArgumentError. + Future.microtask( + () => ref + .read( + setupWizardProvider(SetupWizardMode.firstRun).notifier, + ) + .choosePath(SetupPath.fresh), + ); + } + return const FinishStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Start logging')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Could not complete setup'), findsOneWidget); + // Button re-enabled so the user can retry. + final button = tester.widget( + find.widgetWithText(FilledButton, 'Start logging'), + ); + expect(button.onPressed, isNotNull); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/open_folder_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/open_folder_step_test.dart new file mode 100644 index 000000000..a0af7950d --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/open_folder_step_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/domain/entities/storage_config.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/database_location_service.dart'; +import 'package:submersion/core/services/database_migration_service.dart'; +import 'package:submersion/features/settings/presentation/providers/storage_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/open_folder_step.dart'; + +import '../../../../../helpers/test_app.dart'; + +/// Minimal fake so the step's folder-adoption flow can be driven without a +/// real picker or database. Only the three methods the step calls are +/// implemented; everything else is unused. +class _FakeStorageNotifier extends StateNotifier + implements StorageConfigNotifier { + _FakeStorageNotifier() + : super(const StorageConfigState(config: StorageConfig())); + + FolderPickResultWithBookmark? pickResult; + ExistingDatabaseInfo? existingInfo; + MigrationResult? switchResult; + + @override + Future pickCustomFolder({ + Object? chooser, + }) async => pickResult; + + @override + Future checkForExistingDatabase( + String folderPath, + ) async => existingInfo; + + @override + Future switchToExistingDatabase(String folderPath) async => + switchResult!; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _existing = ExistingDatabaseInfo( + path: '/tmp/x/submersion.db', + userCount: 1, + diveCount: 5, + siteCount: 2, + tripCount: 0, + buddyCount: 0, + fileSize: 1024, +); + +void main() { + late _FakeStorageNotifier fake; + + setUp(() => fake = _FakeStorageNotifier()); + + Widget build() => testApp( + overrides: [storageConfigNotifierProvider.overrideWith((ref) => fake)], + child: const OpenFolderStep(), + ); + + testWidgets('idle shows the choose-folder button', (tester) async { + await tester.pumpWidget(build()); + expect(find.text('Choose folder'), findsOneWidget); + }); + + testWidgets('cancelled folder pick returns to idle', (tester) async { + fake.pickResult = null; + await tester.pumpWidget(build()); + await tester.tap(find.text('Choose folder')); + await tester.pumpAndSettle(); + expect(find.text('Choose folder'), findsOneWidget); + }); + + testWidgets('folder without a database shows the not-found message', ( + tester, + ) async { + fake.pickResult = const FolderPickResultWithBookmark(path: '/tmp/x'); + fake.existingInfo = null; + await tester.pumpWidget(build()); + await tester.tap(find.text('Choose folder')); + await tester.pumpAndSettle(); + expect( + find.text('The selected folder does not contain a Submersion database.'), + findsOneWidget, + ); + }); + + testWidgets('existing database + success surfaces no error', (tester) async { + fake.pickResult = const FolderPickResultWithBookmark(path: '/tmp/x'); + fake.existingInfo = _existing; + fake.switchResult = MigrationResult.success( + oldPath: '/a', + newPath: '/tmp/x', + ); + await tester.pumpWidget(build()); + await tester.tap(find.text('Choose folder')); + await tester.pumpAndSettle(); + // restartApp() was invoked (harmless ValueNotifier bump); no error shown. + expect(find.textContaining('does not contain'), findsNothing); + }); + + testWidgets('switch failure shows the error message', (tester) async { + fake.pickResult = const FolderPickResultWithBookmark(path: '/tmp/x'); + fake.existingInfo = _existing; + fake.switchResult = MigrationResult.failure('disk error'); + await tester.pumpWidget(build()); + await tester.tap(find.text('Choose folder')); + await tester.pumpAndSettle(); + expect(find.text('disk error'), findsOneWidget); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/restore_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/restore_step_test.dart new file mode 100644 index 000000000..1e10408f7 --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/restore_step_test.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/backup/presentation/pages/restore_complete_page.dart'; +import 'package:submersion/features/backup/presentation/providers/backup_providers.dart'; +import 'package:submersion/features/backup/presentation/widgets/restore_confirmation_dialog.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/restore_step.dart'; + +import '../../../../../helpers/test_app.dart'; + +class _FakeBackupOp extends StateNotifier + implements BackupOperationNotifier { + _FakeBackupOp(super.state); + + void push(BackupOperationState next) => state = next; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + Widget build(_FakeBackupOp fake) => testApp( + overrides: [backupOperationProvider.overrideWith((ref) => fake)], + child: const RestoreStep(), + ); + + testWidgets('idle shows the choose-file button', (tester) async { + await tester.pumpWidget(build(_FakeBackupOp(const BackupOperationState()))); + expect(find.text('Choose backup file'), findsOneWidget); + }); + + testWidgets('cancelled picker returns to idle without crashing', ( + tester, + ) async { + await tester.pumpWidget(build(_FakeBackupOp(const BackupOperationState()))); + // FilePicker returns no selection under test; the step must handle it. + await tester.tap(find.text('Choose backup file')); + await tester.pumpAndSettle(); + expect(find.text('Restore backup'), findsOneWidget); + expect(find.text('Choose backup file'), findsOneWidget); + }); + + testWidgets('in-progress shows a spinner and message', (tester) async { + await tester.pumpWidget( + build( + _FakeBackupOp( + const BackupOperationState( + status: BackupOperationStatus.inProgress, + message: 'Restoring backup...', + ), + ), + ), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Restoring backup...'), findsOneWidget); + expect(find.text('Choose backup file'), findsNothing); + }); + + testWidgets('error state shows the message and keeps the button', ( + tester, + ) async { + await tester.pumpWidget( + build( + _FakeBackupOp( + const BackupOperationState( + status: BackupOperationStatus.error, + message: 'Restore failed: bad file', + ), + ), + ), + ); + expect(find.text('Restore failed: bad file'), findsOneWidget); + expect(find.text('Choose backup file'), findsOneWidget); + }); + + testWidgets('picking a file opens the restore confirmation dialog', ( + tester, + ) async { + final dir = Directory.systemTemp.createTempSync('restore_step_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + final backup = File('${dir.path}/backup.db')..writeAsBytesSync([1, 2, 3]); + + await tester.pumpWidget( + testApp( + overrides: [ + backupOperationProvider.overrideWith( + (ref) => _FakeBackupOp(const BackupOperationState()), + ), + ], + child: RestoreStep( + pickBackupFile: () async => (path: backup.path, name: 'backup.db'), + ), + ), + ); + + await tester.runAsync(() async { + await tester.tap(find.text('Choose backup file')); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + + expect(find.byType(RestoreConfirmationDialog), findsOneWidget); + }); + + testWidgets('restoreComplete transition shows the completion page', ( + tester, + ) async { + final fake = _FakeBackupOp(const BackupOperationState()); + await tester.pumpWidget(build(fake)); + await tester.pumpAndSettle(); + + fake.push( + const BackupOperationState(status: BackupOperationStatus.restoreComplete), + ); + await tester.pumpAndSettle(); + + expect(find.byType(RestoreCompletePage), findsOneWidget); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/sync_connect_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/sync_connect_step_test.dart new file mode 100644 index 000000000..69ed418ce --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/sync_connect_step_test.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart' + show CloudProviderType; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/core/services/sync/sync_initializer.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/sync_connect_step.dart'; + +import '../../../../../helpers/mock_providers.dart'; +import '../../../../../helpers/test_app.dart'; +import '../../../../../helpers/test_database.dart'; + +class _FakeSyncInit implements SyncInitializer { + List peers = []; + bool throwOnPeers = false; + + @override + Future> peerSyncFiles( + CloudStorageProvider provider, + ) async { + if (throwOnPeers) throw Exception('network down'); + return peers; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSyncNotifier extends StateNotifier + implements SyncNotifier { + _FakeSyncNotifier({this.failSync = false}) : super(const SyncState()); + + final bool failSync; + + @override + Future performSync({bool auto = false}) async { + if (failSync) { + state = const SyncState(status: SyncStatus.error, message: 'sync boom'); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late _FakeSyncInit syncInit; + + setUp(() async { + syncInit = _FakeSyncInit(); + await setUpTestDatabase(); + }); + + tearDown(() async => tearDownTestDatabase()); + + Future> overrides({ + bool hasDivers = true, + bool failSync = false, + }) async { + final base = await getBaseOverrides(); + return [ + ...base, + isApplePlatformProvider.overrideWithValue(false), + dropboxConfiguredProvider.overrideWithValue(false), + iCloudAvailabilityProvider.overrideWith( + (ref) async => ICloudAvailability.unsupported, + ), + syncInitializerProvider.overrideWithValue(syncInit), + syncStateProvider.overrideWith( + (ref) => _FakeSyncNotifier(failSync: failSync), + ), + hasAnyDiversProvider.overrideWith((ref) async => hasDivers), + ]; + } + + testWidgets('connect phase gates Continue on a connected provider', ( + tester, + ) async { + late ProviderContainer container; + await tester.pumpWidget( + testApp( + overrides: await overrides(), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final continueButton = find.widgetWithText(FilledButton, 'Continue'); + expect(tester.widget(continueButton).onPressed, isNull); + + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + expect(tester.widget(continueButton).onPressed, isNotNull); + }); + + testWidgets('no peer library shows the no-library screen and pivots fresh', ( + tester, + ) async { + syncInit.peers = []; + var pivoted = 0; + late ProviderContainer container; + await tester.pumpWidget( + testApp( + overrides: await overrides(), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () => pivoted++, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await tester.pumpAndSettle(); + + expect(find.text('No library found'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Start fresh')); + expect(pivoted, 1); + }); + + testWidgets('a failing pull returns to the connect UI with an error', ( + tester, + ) async { + syncInit.throwOnPeers = true; + late ProviderContainer container; + await tester.pumpWidget( + testApp( + overrides: await overrides(), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await tester.pumpAndSettle(); + + // Error surfaced and the connect UI restored (Continue button present). + expect(find.textContaining('Could not connect'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Continue'), findsOneWidget); + }); + + testWidgets('a failed sync returns to connect instead of "no library"', ( + tester, + ) async { + syncInit.peers = [ + CloudFileInfo( + id: 'a', + name: 'peer.manifest.json', + modifiedTime: DateTime(2026), + ), + ]; + late ProviderContainer container; + await tester.pumpWidget( + testApp( + overrides: await overrides(failSync: true), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Could not connect'), findsOneWidget); + expect(find.text('No library found'), findsNothing); + expect(find.widgetWithText(FilledButton, 'Continue'), findsOneWidget); + }); + + testWidgets('peer library pulls and shows the adopted screen', ( + tester, + ) async { + syncInit.peers = [ + CloudFileInfo( + id: 'a', + name: 'peer.manifest.json', + modifiedTime: DateTime(2026), + ), + ]; + late ProviderContainer container; + await tester.pumpWidget( + testApp( + overrides: await overrides(hasDivers: true), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () {}, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + container + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + + expect(find.text('Library adopted'), findsOneWidget); + }); + + testWidgets('adopted screen Continue navigates to the dashboard', ( + tester, + ) async { + syncInit.peers = [ + CloudFileInfo( + id: 'a', + name: 'peer.manifest.json', + modifiedTime: DateTime(2026), + ), + ]; + var dashboard = false; + ProviderContainer? container; + final router = GoRouter( + initialLocation: '/w', + routes: [ + GoRoute( + path: '/w', + builder: (context, state) { + container = ProviderScope.containerOf(context); + return Scaffold( + body: SyncConnectStep( + mode: SetupWizardMode.firstRun, + onNoLibrary: () {}, + ), + ); + }, + ), + GoRoute( + path: '/dashboard', + builder: (context, state) { + dashboard = true; + return const Scaffold(body: Text('dash')); + }, + ), + ], + ); + await tester.pumpWidget( + testAppRouter(router: router, overrides: await overrides()), + ); + await tester.pumpAndSettle(); + container! + .read(setupWizardProvider(SetupWizardMode.firstRun).notifier) + .setConnectedProvider(CloudProviderType.s3); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Continue')); + await tester.pumpAndSettle(); + expect(dashboard, isTrue); + }); +} diff --git a/test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart b/test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart new file mode 100644 index 000000000..0d28d0372 --- /dev/null +++ b/test/features/setup_wizard/presentation/widgets/steps/units_step_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/units.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/setup_wizard/domain/setup_wizard_models.dart'; +import 'package:submersion/features/setup_wizard/presentation/providers/setup_wizard_providers.dart'; +import 'package:submersion/features/setup_wizard/presentation/widgets/steps/units_step.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets('preset toggle drives the draft units', (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'de', + 'DE', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + late ProviderContainer container; + await tester.pumpWidget( + testApp( + // UI pinned to English; the device locale set above only drives + // presetForLocale, not the labels this test taps. + locale: const Locale('en'), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Imperial')); + await tester.pumpAndSettle(); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.unitPreset, UnitPreset.imperial); + expect(draft.settings.depthUnit, DepthUnit.feet); + }); + + testWidgets('advanced expander exposes per-unit overrides', (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'de', + 'DE', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + late ProviderContainer container; + await tester.pumpWidget( + testApp( + // UI pinned to English; the device locale set above only drives + // presetForLocale, not the labels this test taps. + locale: const Locale('en'), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Fine-tune units')); + await tester.pumpAndSettle(); + + // Depth row: switch to feet only. + await tester.ensureVisible( + find.byKey(const ValueKey('setup-unit-depth-ft')), + ); + await tester.tap(find.byKey(const ValueKey('setup-unit-depth-ft'))); + await tester.pumpAndSettle(); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.depthUnit, DepthUnit.feet); + expect(draft.settings.temperatureUnit, TemperatureUnit.celsius); + expect(draft.settings.unitPreset, UnitPreset.custom); + }); + + testWidgets('US device locale preselects imperial once', (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'en', + 'US', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + late ProviderContainer container; + await tester.pumpWidget( + testApp( + // UI pinned to English; the device locale set above only drives + // presetForLocale, not the labels this test taps. + locale: const Locale('en'), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + final draft = container.read(setupWizardProvider(SetupWizardMode.firstRun)); + expect(draft.settings.unitPreset, UnitPreset.imperial); + }); + + testWidgets('each per-unit control writes to the draft', (tester) async { + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'de', + 'DE', + ); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + late ProviderContainer container; + await tester.pumpWidget( + testApp( + locale: const Locale('en'), + child: Builder( + builder: (context) { + container = ProviderScope.containerOf(context); + return const UnitsStep(mode: SetupWizardMode.firstRun); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fine-tune units')); + await tester.pumpAndSettle(); + + Future tapUnit(String key) async { + final finder = find.byKey(ValueKey(key)); + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pumpAndSettle(); + } + + // Flip each unit row's non-default segment; each fires its onChanged. + await tapUnit('setup-unit-depth-ft'); + await tapUnit('setup-unit-temperature-f'); + await tapUnit('setup-unit-pressure-psi'); + await tapUnit('setup-unit-volume-cuft'); + await tapUnit('setup-unit-weight-lbs'); + await tapUnit('setup-unit-altitude-ft'); + await tapUnit('setup-unit-sac-pressuremin'); + + final s = container + .read(setupWizardProvider(SetupWizardMode.firstRun)) + .settings; + expect(s.depthUnit, DepthUnit.feet); + expect(s.temperatureUnit, TemperatureUnit.fahrenheit); + expect(s.pressureUnit, PressureUnit.psi); + expect(s.volumeUnit, VolumeUnit.cubicFeet); + expect(s.weightUnit, WeightUnit.pounds); + expect(s.altitudeUnit, AltitudeUnit.feet); + expect(s.sacUnit, SacUnit.pressurePerMin); + + // Date-format dropdown fires its onChanged too. + final dropdown = find.byType(DropdownButton); + await tester.ensureVisible(dropdown); + await tester.tap(dropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text('YYYY-MM-DD').last); + await tester.pumpAndSettle(); + expect( + container + .read(setupWizardProvider(SetupWizardMode.firstRun)) + .settings + .dateFormat, + DateFormatPreference.yyyymmdd, + ); + }); +} diff --git a/test/helpers/test_app.dart b/test/helpers/test_app.dart index 97d652aee..917f5f632 100644 --- a/test/helpers/test_app.dart +++ b/test/helpers/test_app.dart @@ -14,10 +14,18 @@ import 'package:submersion/l10n/arb/app_localizations.dart'; /// Callers should pass the return values of `.overrideWithValue(...)` or /// `.overrideWith(...)` -- the Riverpod `Override` type is sealed and not /// re-exported, so we accept `dynamic` here. -Widget testApp({required Widget child, List? overrides}) { +/// Pass [locale] to pin the UI language deterministically (defaults to +/// MaterialApp's platform-locale resolution). Useful when a test drives the +/// device locale for other reasons but still asserts on English labels. +Widget testApp({ + required Widget child, + List? overrides, + Locale? locale, +}) { return ProviderScope( overrides: overrides?.cast() ?? [], child: MaterialApp( + locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: child),