diff --git a/docs/superpowers/plans/2026-08-05-trip-flight-no-fly-countdown.md b/docs/superpowers/plans/2026-08-05-trip-flight-no-fly-countdown.md new file mode 100644 index 0000000000..7b31a12c14 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-trip-flight-no-fly-countdown.md @@ -0,0 +1,1737 @@ +# Trip Return-Flight No-Fly Countdown 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:** Store a return-flight departure time on a trip and show, on four surfaces, how much diving time remains before the diver's no-fly interval would collide with that flight. + +**Architecture:** The trips feature stores one new nullable timestamp (`trips.return_flight_at`, schema v142). The safety feature owns all math: `NoFlyService` gains a pure `flightWindow()` method reusing the existing preset interval table, and a new provider family computes a `FlightWindowStatus` per trip. Four consumers render it: trip story card, No-Fly page section, dashboard gauge chip, dive-edit warning banner. + +**Tech Stack:** Flutter 3 / Material 3, Drift ORM (SQLite), Riverpod, go_router, flutter gen-l10n. + +**Spec:** `docs/superpowers/specs/2026-08-05-trip-flight-no-fly-countdown-design.md` + +## Global Constraints + +- Worktree: all work happens in `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/trip-flight-no-fly` on branch `worktree-trip-flight-no-fly`. Run `pwd` before trusting any shell result. +- Schema version: this feature claims **v142**. v138 (divelogs #603) and v139 (equipment currency #805) are reserved by parallel branches; the ladder deliberately skips them. Before starting Task 1, run `git fetch origin && git show origin/main:lib/core/database/database.dart | grep "currentSchemaVersion = "` — if main has advanced past 137, renumber every "142" in this plan to the next free number above both main and open-PR claims. +- Time frame: dive times in this app are **wall-clock-as-UTC** (`DateTime.utc(local components)`, displayed without `toLocal()`). Every new timestamp, comparison, and display in this feature uses that same frame. "Now" is obtained via the new `NoFlyService.wallClockNowUtc()`, never `DateTime.now().toUtc()`. +- Localization: every new user-facing string gets a key in `lib/l10n/arb/app_en.arb` AND translated values in all 10 other catalogs (`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`), then `flutter gen-l10n`. Real translations, not English copies. +- No emojis anywhere. `dart format .` (whole project) before every commit. `flutter analyze` on the whole project with NO output piping/truncation — infos are fatal in CI. +- Commit messages: plain imperative mood matching repo history (e.g. "Add trips.return_flight_at column"), no `feat:` prefix, no Co-Authored-By line, no session URL. +- Riverpod: import via the barrel `package:submersion/core/providers/provider.dart` (Riverpod 3 legacy shims live there). `ref.invalidateSelfWhen(stream)` is the established self-invalidation helper. +- After any `database.dart` table change: `dart run build_runner build --delete-conflicting-outputs` before analyzing or testing. + +--- + +### Task 1: Schema v142 — `trips.return_flight_at` + +**Files:** +- Modify: `lib/core/database/database.dart` (Trips table ~line 62-83; `currentSchemaVersion` line 2864; `migrationVersions` list starting line 2869; onUpgrade v137 block ~line 7159-7163; beforeOpen backstop ~line 7182; helper section near `_assertWeatherCodeColumn` ~line 3933) +- Create: `test/core/database/migration_v142_trip_return_flight_test.dart` + +**Interfaces:** +- Consumes: existing `_assertWeatherCodeColumn()` pattern. +- Produces: `trips.return_flight_at` INTEGER nullable column; generated `TripsCompanion.returnFlightAt` / `Trip.returnFlightAt` (Drift row class) after codegen. Later tasks rely on the column name `return_flight_at` and Dart getter `returnFlightAt`. + +- [ ] **Step 1: Write the failing migration test** + +Create `test/core/database/migration_v142_trip_return_flight_test.dart` (modeled exactly on `migration_v137_weather_code_test.dart`): + +```dart +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v142 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(142)); + expect(AppDatabase.migrationVersions, contains(142)); + }); + + test('a fresh database has trips.return_flight_at', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('trips')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('return_flight_at')); + }); + + test( + 'a database stranded before v142 gains return_flight_at via beforeOpen', + () async { + // Only the columns this migration touches are modelled. The beforeOpen + // backstop must add return_flight_at even when onUpgrade never ran + // (v138/v139 are reserved by parallel branches, so a DB can arrive at + // any intermediate version without this column). + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE trips ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT, + start_date INTEGER, + end_date INTEGER + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('trips')").get(); + expect( + cols.map((c) => c.read('name')).toSet(), + contains('return_flight_at'), + ); + }, + ); + + test('the assert is a no-op when the trips table is absent', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + // Opening must not throw on a minimal fixture. + await db.customSelect('SELECT 1').get(); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/core/database/migration_v142_trip_return_flight_test.dart` +Expected: FAIL — ladder test (137 < 142) and column tests (no `return_flight_at`). + +- [ ] **Step 3: Implement the migration** + +In `lib/core/database/database.dart`: + +3a. In `class Trips extends Table`, after the `isShared` column: + +```dart + /// Return flight departure, wall-clock-as-UTC epoch ms (v142). Drives the + /// remaining-dive-window countdown; null when the trip has no flight set. + IntColumn get returnFlightAt => integer().nullable()(); +``` + +3b. Bump `static const int currentSchemaVersion = 137;` to `142`. + +3c. Append `142` to the `migrationVersions` list (after `137`). + +3d. Add the idempotent helper next to `_assertWeatherCodeColumn()` (~line 3942): + +```dart + /// Idempotent DDL for the v142 return-flight column. Called from the v142 + /// onUpgrade step and the beforeOpen backstop, matching the + /// _assertWeatherCodeColumn pattern so a schema-version collision cannot + /// strand a database without it. Self-guarding when the table is absent + /// (minimal migration-test fixtures). + Future _assertTripReturnFlightColumn() async { + final cols = await customSelect("PRAGMA table_info('trips')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('return_flight_at')) { + await customStatement( + 'ALTER TABLE trips ADD COLUMN return_flight_at INTEGER', + ); + } + } +``` + +3e. In `onUpgrade`, after the `if (from < 137)` block: + +```dart + // v142: trips.return_flight_at (return-flight dive-window countdown). + // v138/v139 are reserved by parallel branches (#603, #805); the + // beforeOpen backstop heals any DB stranded between. + if (from < 142) { + await _assertTripReturnFlightColumn(); + } + if (from < 142) await reportProgress(); +``` + +3f. In `beforeOpen`, after the v137 backstop line: + +```dart + // v142 backstop: re-assert trips.return_flight_at. + await _assertTripReturnFlightColumn(); +``` + +- [ ] **Step 4: Regenerate Drift code** + +Run: `dart run build_runner build --delete-conflicting-outputs` +Expected: exits 0; `database.g.dart` gains `returnFlightAt` on the trips row class and companion. + +- [ ] **Step 5: Run the migration test and the neighboring ladder tests** + +Run: `flutter test test/core/database/migration_v142_trip_return_flight_test.dart test/core/database/migration_v137_weather_code_test.dart test/core/database/migration_v125_no_fly_preset_test.dart` +Expected: PASS (v137/v125 tests use greaterThanOrEqualTo, so the bump to 142 is safe). + +- [ ] **Step 6: Verify sync needs no per-column work** + +Run: `grep -rn "return_flight\|start_date" lib/core/data/repositories/sync_repository.dart | head` +Expected: no explicit trips column list (export serializes whole rows via generated `toJson()`; hydration uses schema defaults post-#858). If a trips column enumeration DOES appear, add `return_flight_at` there and note it in the commit message. + +- [ ] **Step 7: Format and commit** + +```bash +dart format . +git add -A +git commit -m "Add trips.return_flight_at column (schema v142)" +``` + +--- + +### Task 2: `Trip` entity field + +**Files:** +- Modify: `lib/features/trips/domain/entities/trip.dart` +- Modify: `test/features/trips/domain/entities/trip_test.dart` + +**Interfaces:** +- Produces: `Trip.returnFlightAt` (`DateTime?`), constructor param `this.returnFlightAt`, `copyWith(returnFlightAt: ...)` supporting explicit-null clearing via the file's existing `_undefined` sentinel. All later tasks use `trip.returnFlightAt`. + +- [ ] **Step 1: Write the failing entity tests** + +Append to `test/features/trips/domain/entities/trip_test.dart` (reuse the file's existing `Trip` fixture builder if one exists; otherwise construct inline as below): + +```dart + group('returnFlightAt', () { + final base = Trip( + id: 't1', + name: 'Red Sea', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + test('defaults to null and is preserved by unrelated copyWith', () { + expect(base.returnFlightAt, isNull); + final withFlight = base.copyWith( + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ); + expect( + withFlight.copyWith(name: 'Renamed').returnFlightAt, + DateTime.utc(2026, 8, 10, 14, 30), + ); + }); + + test('copyWith clears returnFlightAt with an explicit null', () { + final withFlight = base.copyWith( + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ); + expect(withFlight.copyWith(returnFlightAt: null).returnFlightAt, isNull); + }); + + test('participates in equality', () { + expect( + base.copyWith(returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30)), + isNot(equals(base)), + ); + }); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/trips/domain/entities/trip_test.dart` +Expected: FAIL — no `returnFlightAt` parameter. + +- [ ] **Step 3: Implement** + +In `lib/features/trips/domain/entities/trip.dart`: +- Field after `isShared`: `final DateTime? returnFlightAt;` with doc comment `/// Return flight departure, wall-clock-as-UTC (see dive-time convention).` +- Constructor: `this.returnFlightAt,` after `this.isShared = false,`. +- `copyWith`: parameter `Object? returnFlightAt = _undefined,` and assignment + `returnFlightAt: returnFlightAt == _undefined ? this.returnFlightAt : returnFlightAt as DateTime?,` + (identical to the existing `location` sentinel handling). +- `props`: append `returnFlightAt`. + +- [ ] **Step 4: Run tests** + +Run: `flutter test test/features/trips/domain/entities/trip_test.dart` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A +git commit -m "Add returnFlightAt to Trip entity" +``` + +--- + +### Task 3: Repository persistence + mapper consolidation + +**Files:** +- Modify: `lib/features/trips/data/repositories/trip_repository.dart` (`createTrip` ~L118, `updateTrip` ~L164, `_mapRowToTrip` ~L691, raw mappers in `searchTrips` ~L89, `findTripForDate` ~L583, `getAllTripsWithStats` ~L656) +- Modify: `test/features/trips/data/repositories/trip_repository_test.dart` + +**Interfaces:** +- Consumes: `Trip.returnFlightAt` (Task 2), `TripsCompanion.returnFlightAt` (Task 1 codegen). +- Produces: round-trip persistence. New private helper `domain.Trip _mapDataToTrip(Map data)` used by all three raw-SQL sites so a future column cannot miss one. + +- [ ] **Step 1: Write the failing repository tests** + +Append to `test/features/trips/data/repositories/trip_repository_test.dart`, reusing that file's existing setup/teardown harness (in-memory database via `DatabaseService`) and its existing trip-builder helper if present: + +```dart + group('returnFlightAt persistence', () { + test('createTrip and getTripById round-trip the flight time', () async { + final created = await repository.createTrip( + domain.Trip( + id: '', + name: 'Flight trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ), + ); + final loaded = await repository.getTripById(created.id); + expect( + loaded!.returnFlightAt!.millisecondsSinceEpoch, + DateTime.utc(2026, 8, 10, 14, 30).millisecondsSinceEpoch, + ); + }); + + test('updateTrip with null clears a previously set flight time', () async { + final created = await repository.createTrip( + domain.Trip( + id: '', + name: 'Cleared trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ), + ); + await repository.updateTrip(created.copyWith(returnFlightAt: null)); + final loaded = await repository.getTripById(created.id); + expect(loaded!.returnFlightAt, isNull); + }); + + test('findTripForDate surfaces returnFlightAt (raw-SQL mapper)', () async { + await repository.createTrip( + domain.Trip( + id: '', + name: 'Raw mapper trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ), + ); + final found = await repository.findTripForDate(DateTime.utc(2026, 8, 5)); + expect(found!.returnFlightAt, isNotNull); + }); + }); +``` + +Adjust construction to the harness's conventions (e.g. if the file already has a `makeTrip(...)` helper, extend it with a `returnFlightAt` parameter instead of inlining). + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/trips/data/repositories/trip_repository_test.dart` +Expected: FAIL — `returnFlightAt` never persisted (round-trip returns null). + +- [ ] **Step 3: Implement** + +In `trip_repository.dart`: + +3a. `createTrip` companion: add `returnFlightAt: Value(trip.returnFlightAt?.millisecondsSinceEpoch),` after `isShared`. + +3b. `updateTrip` companion: add the same line. (`Value(null)` writes SQL NULL, so clearing works without `.toCompanion` tricks.) + +3c. `_mapRowToTrip`: add + +```dart + returnFlightAt: row.returnFlightAt != null + ? DateTime.fromMillisecondsSinceEpoch(row.returnFlightAt!) + : null, +``` + +3d. Consolidate the three duplicated raw-SQL mappers. Add below `_mapRowToTrip`: + +```dart + /// Shared mapper for customSelect rows (searchTrips, findTripForDate, + /// getAllTripsWithStats) so a new trips column cannot silently miss one + /// of the hand-written sites. + domain.Trip _mapDataToTrip(Map data) { + return domain.Trip( + id: data['id'] as String, + diverId: data['diver_id'] as String?, + name: data['name'] as String, + startDate: DateTime.fromMillisecondsSinceEpoch(data['start_date'] as int), + endDate: DateTime.fromMillisecondsSinceEpoch(data['end_date'] as int), + location: data['location'] as String?, + resortName: data['resort_name'] as String?, + liveaboardName: data['liveaboard_name'] as String?, + notes: (data['notes'] as String?) ?? '', + tripType: TripType.fromName((data['trip_type'] as String?) ?? 'shore'), + isShared: (data['is_shared'] as int? ?? 0) != 0, + returnFlightAt: data['return_flight_at'] != null + ? DateTime.fromMillisecondsSinceEpoch(data['return_flight_at'] as int) + : null, + createdAt: DateTime.fromMillisecondsSinceEpoch(data['created_at'] as int), + updatedAt: DateTime.fromMillisecondsSinceEpoch(data['updated_at'] as int), + ); + } +``` + +Replace the inline `domain.Trip(...)` constructions in `searchTrips`, `findTripForDate`, and `getAllTripsWithStats` with `_mapDataToTrip(row.data)` / `_mapDataToTrip(result.data)` (in `getAllTripsWithStats` keep the surrounding `TripWithStats` wrapper). + +- [ ] **Step 4: Run repository tests (all three files, mappers are shared)** + +Run: `flutter test test/features/trips/data/repositories/` +Expected: PASS, including pre-existing scan/error tests. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A +git commit -m "Persist trip return flight time and consolidate trip row mappers" +``` + +--- + +### Task 4: `NoFlyService.flightWindow()` + +**Files:** +- Modify: `lib/features/safety/domain/services/no_fly_service.dart` +- Modify: `test/features/safety/domain/services/no_fly_service_test.dart` + +**Interfaces:** +- Consumes: existing `NoFlyPreset`, `NoFlyCategory`, `NoFlyStatus`. +- Produces (used by Tasks 5-11): + +```dart +enum FlightWindowState { open, closed, conflict } + +class FlightWindowStatus { + final FlightWindowState state; + final DateTime flightAt; // departure, wall-clock-as-UTC + final DateTime deadline; // latest safe surfacing time + final NoFlyCategory category; + final Duration interval; + Duration remaining(DateTime now); +} + +// on NoFlyService: +static Duration intervalFor(NoFlyPreset preset, NoFlyCategory category); +static DateTime wallClockNowUtc(); +FlightWindowStatus? flightWindow({required DateTime flightAt, required NoFlyPreset preset, required NoFlyCategory prospectiveCategory, DateTime? currentNoFlyUntil, required DateTime now}); +``` + +- [ ] **Step 1: Write the failing unit tests** + +Append to `test/features/safety/domain/services/no_fly_service_test.dart` (it already declares `const service = NoFlyService();` and frozen-clock style): + +```dart + group('flightWindow', () { + final flightAt = DateTime.utc(2026, 8, 10, 9); // Mon 09:00 departure + + test('open: standard repetitive deadline is departure - 18h', () { + final now = DateTime.utc(2026, 8, 9, 10); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: now, + ); + expect(status!.state, FlightWindowState.open); + expect(status.deadline, DateTime.utc(2026, 8, 9, 15)); + expect(status.remaining(now), const Duration(hours: 5)); + }); + + test('closed: past the deadline but before departure', () { + final now = DateTime.utc(2026, 8, 9, 16); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: now, + ); + expect(status!.state, FlightWindowState.closed); + expect(status.remaining(now), Duration.zero); + }); + + test('exactly at the deadline counts as closed', () { + final now = DateTime.utc(2026, 8, 9, 15); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: now, + ); + expect(status!.state, FlightWindowState.closed); + }); + + test('conflict: existing no-fly reaches past departure, beats open', () { + final now = DateTime.utc(2026, 8, 9, 10); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.deco, + currentNoFlyUntil: DateTime.utc(2026, 8, 10, 12), + now: now, + ); + expect(status!.state, FlightWindowState.conflict); + }); + + test('strict deco: deadline is departure - 48h', () { + final now = DateTime.utc(2026, 8, 8, 8); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.strict, + prospectiveCategory: NoFlyCategory.deco, + currentNoFlyUntil: null, + now: now, + ); + expect(status!.deadline, DateTime.utc(2026, 8, 8, 9)); + expect(status.state, FlightWindowState.open); + expect(status.interval, const Duration(hours: 48)); + }); + + test('returns null once the flight has departed', () { + final now = DateTime.utc(2026, 8, 10, 10); + expect( + service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: now, + ), + isNull, + ); + }); + }); + + test('intervalFor matches the table evaluate() uses', () { + expect( + NoFlyService.intervalFor(NoFlyPreset.standard, NoFlyCategory.single), + const Duration(hours: 12), + ); + expect( + NoFlyService.intervalFor(NoFlyPreset.strict, NoFlyCategory.repetitive), + const Duration(hours: 24), + ); + }); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/safety/domain/services/no_fly_service_test.dart` +Expected: FAIL — `flightWindow` / `intervalFor` undefined. + +- [ ] **Step 3: Implement** + +In `no_fly_service.dart`: + +3a. After the `NoFlyStatus` class, add: + +```dart +/// State of the forward-looking dive window before a booked flight. +enum FlightWindowState { + /// Diving may continue; the diver must surface by [FlightWindowStatus.deadline]. + open, + + /// The deadline has passed: no more diving before this flight. + closed, + + /// The diver's existing no-fly restriction already extends past the + /// flight departure. Takes precedence over open/closed. + conflict, +} + +/// Forward-looking dive window for a trip's return flight: the latest safe +/// surfacing time is the departure minus the guideline interval for the +/// (preset, category) pair. Same fixed-interval doctrine as [NoFlyStatus]. +class FlightWindowStatus { + final FlightWindowState state; + + /// Flight departure, wall-clock-as-UTC (the dive-time frame). + final DateTime flightAt; + + /// Latest safe surfacing time before [flightAt]. + final DateTime deadline; + + final NoFlyCategory category; + final Duration interval; + + const FlightWindowStatus({ + required this.state, + required this.flightAt, + required this.deadline, + required this.category, + required this.interval, + }); + + Duration remaining(DateTime now) => + deadline.isAfter(now) ? deadline.difference(now) : Duration.zero; +} +``` + +3b. In `NoFlyService`, extract the interval table (replace the inline `switch` inside `evaluate` with a call to this): + +```dart + /// Guideline pre-flight surface interval for a (preset, category) pair. + /// Single source of truth shared by [evaluate] and [flightWindow]. + static Duration intervalFor(NoFlyPreset preset, NoFlyCategory category) { + return switch ((preset, category)) { + (NoFlyPreset.standard, NoFlyCategory.single) => const Duration(hours: 12), + (NoFlyPreset.standard, NoFlyCategory.repetitive) => const Duration( + hours: 18, + ), + (NoFlyPreset.standard, NoFlyCategory.deco) => const Duration(hours: 24), + (NoFlyPreset.strict, NoFlyCategory.single) => const Duration(hours: 18), + (NoFlyPreset.strict, NoFlyCategory.repetitive) => const Duration( + hours: 24, + ), + (NoFlyPreset.strict, NoFlyCategory.deco) => const Duration(hours: 48), + }; + } + + /// The current moment in the app's wall-clock-as-UTC dive-time frame. + /// Dive entry/exit times are stored as `DateTime.utc(local components)`, + /// so comparisons against them must use the same construction -- NOT + /// `DateTime.now().toUtc()`, which is the true instant and differs by the + /// device's UTC offset. + static DateTime wallClockNowUtc() { + final now = DateTime.now(); + return DateTime.utc( + now.year, + now.month, + now.day, + now.hour, + now.minute, + now.second, + ); + } + + /// Computes the dive window before [flightAt], or null when the flight has + /// already departed. [prospectiveCategory] is the caller's forward-looking + /// classification (at least repetitive on a trip); [currentNoFlyUntil] is + /// the backward-looking restriction end used to detect a conflict. + FlightWindowStatus? flightWindow({ + required DateTime flightAt, + required NoFlyPreset preset, + required NoFlyCategory prospectiveCategory, + DateTime? currentNoFlyUntil, + required DateTime now, + }) { + if (!flightAt.isAfter(now)) return null; + final interval = intervalFor(preset, prospectiveCategory); + final deadline = flightAt.subtract(interval); + final FlightWindowState state; + if (currentNoFlyUntil != null && currentNoFlyUntil.isAfter(flightAt)) { + state = FlightWindowState.conflict; + } else if (now.isBefore(deadline)) { + state = FlightWindowState.open; + } else { + state = FlightWindowState.closed; + } + return FlightWindowStatus( + state: state, + flightAt: flightAt, + deadline: deadline, + category: prospectiveCategory, + interval: interval, + ); + } +``` + +3c. In `evaluate`, replace `final interval = switch ((preset, category)) { ... };` with `final interval = intervalFor(preset, category);` and delete the inline table. + +- [ ] **Step 4: Run the whole safety unit-test directory (evaluate refactor regression)** + +Run: `flutter test test/features/safety/domain/services/` +Expected: PASS — all pre-existing `evaluate` tests plus the new group. + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A +git commit -m "Add FlightWindowStatus and NoFlyService.flightWindow" +``` + +--- + +### Task 5: Flight-window providers + +**Files:** +- Create: `lib/features/safety/presentation/providers/flight_window_providers.dart` +- Create: `test/features/safety/presentation/providers/flight_window_providers_test.dart` + +**Interfaces:** +- Consumes: `NoFlyService.flightWindow/intervalFor/wallClockNowUtc` (Task 4), `Trip.returnFlightAt` (Task 2), `tripRepositoryProvider`/`tripForDateProvider` (existing), `diveRepositoryProvider.getNoFlyDiveInputs` (existing), `settingsProvider.noFlyPreset` (existing). +- Produces: + - `final tripFlightWindowProvider = FutureProvider.family` (keyed by trip id) + - `final activeTripFlightWindowProvider = FutureProvider` + +- [ ] **Step 1: Write the failing provider tests** + +Create `test/features/safety/presentation/providers/flight_window_providers_test.dart`. Open `test/features/safety/presentation/providers/no_fly_providers_test.dart` first and reuse its harness verbatim (database/DatabaseService setup, settings mock via `test/helpers` — see the settings-notifier mock helpers used across provider tests). Cover these four cases: + +1. Trip with `returnFlightAt` 24h ahead, no dives logged: status non-null, `state == FlightWindowState.open`, `category == NoFlyCategory.repetitive` (floor applies even with zero dives), `deadline == returnFlightAt - 18h` under the standard preset. +2. Trip with a deco dive inside the 48h lookback: `category == NoFlyCategory.deco`, `deadline == returnFlightAt - 24h`. +3. Trip without `returnFlightAt`: provider returns null. +4. Dive ending so recently that `until` (repetitive interval from dive end) lands after `returnFlightAt`: `state == FlightWindowState.conflict`. + +Use wall-clock-as-UTC fixtures (`DateTime.utc(...)`) for trip flight times and dive end times, mirroring how `no_fly_dive_inputs_test.dart` seeds dives. + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/safety/presentation/providers/flight_window_providers_test.dart` +Expected: FAIL — file under test does not exist. + +- [ ] **Step 3: Implement the providers** + +Create `lib/features/safety/presentation/providers/flight_window_providers.dart`: + +```dart +import 'dart:async'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; + +/// Forward-looking dive window for one trip's return flight, or null when +/// the trip has no flight set (or it already departed). +/// +/// Category floor is repetitive: a trip is multi-day diving by definition, +/// so the single-dive interval would overstate the window. A deco dive in +/// the lookback escalates to the deco interval. +final tripFlightWindowProvider = + FutureProvider.family((ref, tripId) async { + final tripRepository = ref.watch(tripRepositoryProvider); + ref.invalidateSelfWhen(tripRepository.watchTripsChanges()); + + final trip = await tripRepository.getTripById(tripId); + final flightAt = trip?.returnFlightAt; + if (flightAt == null) return null; + + final diveRepository = ref.watch(diveRepositoryProvider); + ref.invalidateSelfWhen(diveRepository.watchDivesChanges()); + + final preset = ref.watch(settingsProvider.select((s) => s.noFlyPreset)); + final diverId = ref.watch(currentDiverIdProvider); + + final now = NoFlyService.wallClockNowUtc(); + const service = NoFlyService(); + + NoFlyStatus? current; + if (diverId != null) { + final dives = await diveRepository.getNoFlyDiveInputs( + since: now.subtract(NoFlyService.lookback), + diverId: diverId, + ); + current = service.evaluate(dives: dives, preset: preset, now: now); + } + + final category = current?.category == NoFlyCategory.deco + ? NoFlyCategory.deco + : NoFlyCategory.repetitive; + final status = service.flightWindow( + flightAt: flightAt, + preset: preset, + prospectiveCategory: category, + currentNoFlyUntil: current?.until, + now: now, + ); + + // State flips (open -> closed at the deadline, gone at departure) + // happen without any table write; self-invalidate just past the next + // boundary, mirroring noFlyStatusProvider's expiry timer. + if (status != null) { + final boundary = now.isBefore(status.deadline) + ? status.deadline + : status.flightAt; + final untilBoundary = boundary.difference(now); + if (untilBoundary > Duration.zero) { + final timer = Timer( + untilBoundary + const Duration(seconds: 1), + ref.invalidateSelf, + ); + ref.onDispose(timer.cancel); + } + } + return status; + }); + +/// Flight window for the trip containing today, or null. Feeds the +/// dashboard gauge and the No-Fly page. +final activeTripFlightWindowProvider = FutureProvider(( + ref, +) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final trip = await ref.watch(tripForDateProvider(today).future); + if (trip == null || trip.returnFlightAt == null) return null; + return ref.watch(tripFlightWindowProvider(trip.id).future); +}); +``` + +- [ ] **Step 4: Run the provider tests** + +Run: `flutter test test/features/safety/presentation/providers/` +Expected: PASS (new file plus pre-existing no_fly/emergency/incident provider tests). + +- [ ] **Step 5: Commit** + +```bash +dart format . +git add -A +git commit -m "Add flight window providers" +``` + +--- + +### Task 6: Trip edit page — return flight field + +**Files:** +- Modify: `lib/features/trips/presentation/pages/trip_edit_page.dart` (state vars ~L55, hydration ~L128, dates section ~L232-283, picker helpers ~L680, `_saveTrip` ~L770) +- Modify: `lib/l10n/arb/app_en.arb` + the 10 other catalogs +- Modify: `test/features/trips/presentation/pages/trip_edit_page_test.dart` + +**Interfaces:** +- Consumes: `Trip.returnFlightAt`, `showAppDatePicker` (`lib/shared/widgets/app_date_picker.dart`), Material `showTimePicker`. +- Produces: l10n keys `trips_edit_returnFlightLabel` ("Return flight"), `trips_edit_returnFlightNotSet` ("Not set"), `trips_edit_returnFlightClear` ("Clear return flight"). + +- [ ] **Step 1: Add the l10n strings** + +In `app_en.arb`, next to the other `trips_edit_*` keys: + +```json + "trips_edit_returnFlightLabel": "Return flight", + "trips_edit_returnFlightNotSet": "Not set", + "trips_edit_returnFlightClear": "Clear return flight", +``` + +Add matching `@`-metadata only if sibling keys have it (the `trips_*` metadata block sits later in the file; simple no-placeholder strings need no metadata). Translate all three into the 10 other catalogs. Run `flutter gen-l10n`. + +- [ ] **Step 2: Write the failing widget test** + +Append to `test/features/trips/presentation/pages/trip_edit_page_test.dart`, reusing its pump harness: + +```dart + testWidgets('return flight row shows Not set and opens pickers', ( + tester, + ) async { + // Pump the edit page for a new trip using the file's existing harness. + // 1. Expect find.text('Return flight') to be present in the dates + // section and 'Not set' as its subtitle. + // 2. Tap the row; a date picker dialog appears (find.byType(DatePickerDialog)). + // 3. Confirm today's date; a time picker appears (find.byType(TimePickerDialog)). + // 4. Confirm; the subtitle now contains a formatted date instead of 'Not set'. + // 5. Tap the clear icon (find.byIcon(Icons.clear)); subtitle reverts to 'Not set'. + }); +``` + +Fill the body with the harness's real pump/override calls (the file already pumps `TripEditPage` inside a localized `MaterialApp` — pin `locale: const Locale('en')` per the widget-test locale convention). + +- [ ] **Step 3: Run to verify failure** + +Run: `flutter test test/features/trips/presentation/pages/trip_edit_page_test.dart` +Expected: new test FAILS (no 'Return flight' text). + +- [ ] **Step 4: Implement the field** + +In `trip_edit_page.dart`: + +4a. State (near `_startDate`/`_endDate`): `DateTime? _returnFlightAt;` + +4b. Hydration (where `_startDate = trip.startDate;` happens): `_returnFlightAt = trip.returnFlightAt;` + +4c. UI — after the duration row in the dates section: + +```dart + Semantics( + button: true, + label: l10n.trips_edit_returnFlightLabel, + child: ListTile( + leading: const Icon(Icons.flight_land), + title: Text(l10n.trips_edit_returnFlightLabel), + subtitle: Text( + _returnFlightAt == null + ? l10n.trips_edit_returnFlightNotSet + : '${dateFormat.format(_returnFlightAt!)}, ' + '${TimeOfDay.fromDateTime(_returnFlightAt!).format(context)}', + ), + trailing: _returnFlightAt == null + ? const Icon(Icons.edit) + : IconButton( + tooltip: l10n.trips_edit_returnFlightClear, + icon: const Icon(Icons.clear), + onPressed: () => setState(() { + _returnFlightAt = null; + _hasChanges = true; + }), + ), + contentPadding: EdgeInsets.zero, + onTap: _selectReturnFlight, + ), + ), +``` + +(`dateFormat` already exists in scope; match how the start/end tiles obtain `l10n`.) + +4d. Picker helper next to `_selectDate` (two sequential pickers, the dive-edit `_editEntry` pattern; wall-clock-as-UTC construction): + +```dart + Future _selectReturnFlight() async { + final initial = + _returnFlightAt ?? + DateTime(_endDate.year, _endDate.month, _endDate.day, 12); + final pickedDate = await showAppDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1950), + lastDate: DateTime(2100), + ); + if (pickedDate == null || !mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initial), + ); + if (pickedTime == null) return; + setState(() { + // Wall-clock-as-UTC, the same frame as dive times, so the no-fly + // math can compare this directly against dive end times. + _returnFlightAt = DateTime.utc( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + _hasChanges = true; + }); + } +``` + +4e. `_saveTrip`: add `returnFlightAt: _returnFlightAt,` to the `Trip(...)` construction. + +- [ ] **Step 5: Run the test** + +Run: `flutter test test/features/trips/presentation/pages/trip_edit_page_test.dart` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "Add return flight picker to trip edit page" +``` + +--- + +### Task 7: `FlightWindowCard` + No-Fly page section + +**Files:** +- Create: `lib/features/safety/presentation/widgets/flight_window_card.dart` +- Modify: `lib/features/safety/presentation/pages/no_fly_page.dart` (ListView children ~L45-69) +- Modify: `lib/l10n/arb/app_en.arb` + 10 catalogs +- Create: `test/features/safety/presentation/widgets/flight_window_card_test.dart` + +**Interfaces:** +- Consumes: `FlightWindowStatus` (Task 4), `activeTripFlightWindowProvider` (Task 5), `formatNoFlyRemaining` (`lib/features/safety/presentation/formatters/no_fly_format.dart`). +- Produces: `class FlightWindowCard extends StatelessWidget { const FlightWindowCard({super.key, required this.status}); final FlightWindowStatus status; }` — reused by Task 8's trip story wrapper. +- Produces l10n keys: + +```json + "flightWindow_openTitle": "Time left to dive: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { "remaining": { "type": "String" } } + }, + "flightWindow_surfaceBy": "Surface by {time}", + "@flightWindow_surfaceBy": { + "placeholders": { "time": { "type": "String" } } + }, + "flightWindow_departs": "Flight departs {time}", + "@flightWindow_departs": { + "placeholders": { "time": { "type": "String" } } + }, + "flightWindow_closed": "No more diving before your flight", + "flightWindow_conflict": "Your no-fly time extends past your flight departure", +``` + +- [ ] **Step 1: Add the l10n strings** (en + 10 translations, `flutter gen-l10n`). + +- [ ] **Step 2: Write the failing widget test** + +Create `test/features/safety/presentation/widgets/flight_window_card_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/widgets/flight_window_card.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Future pumpCard(WidgetTester tester, FlightWindowStatus status) { + return tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: FlightWindowCard(status: status)), + ), + ); + } + + FlightWindowStatus status(FlightWindowState state) => FlightWindowStatus( + state: state, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ); + + testWidgets('open state shows countdown and surface-by time', (tester) async { + await pumpCard(tester, status(FlightWindowState.open)); + expect(find.textContaining('Time left to dive'), findsOneWidget); + expect(find.textContaining('Surface by'), findsOneWidget); + }); + + testWidgets('closed state shows the stop-diving message', (tester) async { + await pumpCard(tester, status(FlightWindowState.closed)); + expect(find.text('No more diving before your flight'), findsOneWidget); + expect(find.textContaining('Flight departs'), findsOneWidget); + }); + + testWidgets('conflict state shows the alert message', (tester) async { + await pumpCard(tester, status(FlightWindowState.conflict)); + expect( + find.text('Your no-fly time extends past your flight departure'), + findsOneWidget, + ); + }); +} +``` + +(Far-future fixture dates keep the open state's `remaining(now)` positive without a fake clock.) + +- [ ] **Step 3: Run to verify failure** + +Run: `flutter test test/features/safety/presentation/widgets/flight_window_card_test.dart` +Expected: FAIL — widget file missing. + +- [ ] **Step 4: Implement the card** + +Create `lib/features/safety/presentation/widgets/flight_window_card.dart` (mirrors `NoFlyStatusCard`'s Card > Padding > Column layout): + +```dart +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/formatters/no_fly_format.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Presentational card for a [FlightWindowStatus]. Parents own the ticking: +/// re-build (NoFlyPage's minute timer, the trip story wrapper's timer) and +/// the countdown re-renders against the current wall-clock. +class FlightWindowCard extends StatelessWidget { + final FlightWindowStatus status; + + const FlightWindowCard({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + final now = NoFlyService.wallClockNowUtc(); + // Wall-clock-as-UTC values format their components directly -- no + // toLocal(), matching how dive times are displayed everywhere. + final timeFormat = DateFormat.E().add_jm(); + + final (IconData icon, Color color, String title, String subtitle) = + switch (status.state) { + FlightWindowState.open => ( + Icons.flight_takeoff, + scheme.primary, + l10n.flightWindow_openTitle( + formatNoFlyRemaining(status.remaining(now)), + ), + l10n.flightWindow_surfaceBy(timeFormat.format(status.deadline)), + ), + FlightWindowState.closed => ( + Icons.airplanemode_inactive, + scheme.tertiary, + l10n.flightWindow_closed, + l10n.flightWindow_departs(timeFormat.format(status.flightAt)), + ), + FlightWindowState.conflict => ( + Icons.warning_amber, + scheme.error, + l10n.flightWindow_conflict, + l10n.flightWindow_departs(timeFormat.format(status.flightAt)), + ), + }; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 4), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ); + } +} +``` + +Check `formatNoFlyRemaining`'s exact signature in `no_fly_format.dart` before use; it takes a `Duration`. + +- [ ] **Step 5: Wire into the No-Fly page** + +In `no_fly_page.dart`, add to the `ListView` children right after the status-card if/else chain: + +```dart + Consumer( + builder: (context, ref, _) { + final flightAsync = ref.watch(activeTripFlightWindowProvider); + final flight = flightAsync.valueOrNull; + if (flight == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: FlightWindowCard(status: flight), + ); + }, + ), +``` + +Imports: `flight_window_providers.dart`, `flight_window_card.dart`. The page is already a `ConsumerStatefulWidget` with a minute ticker, so if it watches via `ref` directly instead of a nested `Consumer`, that is also fine — match the file's style. The existing router test (`app_router_test.dart` 'noFly route builds the NoFlyPage') now exercises `activeTripFlightWindowProvider`; if it fails on missing database/diver setup, override `activeTripFlightWindowProvider` with `(ref) async => null` in that test's ProviderScope. + +- [ ] **Step 6: Run tests** + +Run: `flutter test test/features/safety/ test/core/router/app_router_test.dart` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +dart format . +git add -A +git commit -m "Add flight window card and No-Fly page section" +``` + +--- + +### Task 8: Trip story countdown card + +**Files:** +- Create: `lib/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart` +- Modify: `lib/features/trips/presentation/widgets/story/trip_story_view.dart` (`_contentSlivers()` ~L265-338, insertion right after the hero sliver, before the liveaboard block) +- Create: `test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart` + +**Interfaces:** +- Consumes: `tripFlightWindowProvider` (Task 5), `FlightWindowCard` (Task 7), `TripStory.trip` (existing). +- Produces: `class TripFlightCountdownCard extends ConsumerStatefulWidget { const TripFlightCountdownCard({super.key, required this.tripId}); final String tripId; }` + +- [ ] **Step 1: Write the failing widget test** + +Create `test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Future pump( + WidgetTester tester, { + required FlightWindowStatus? status, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tripFlightWindowProvider('t1').overrideWith((ref) async => status), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: TripFlightCountdownCard(tripId: 't1')), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders the flight window card when a status exists', ( + tester, + ) async { + await pump( + tester, + status: FlightWindowStatus( + state: FlightWindowState.open, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ), + ); + expect(find.textContaining('Time left to dive'), findsOneWidget); + }); + + testWidgets('renders nothing when the provider yields null', (tester) async { + await pump(tester, status: null); + expect(find.byType(Card), findsNothing); + }); +} +``` + +Note: the widget's periodic `Timer` must be created only in `initState` and cancelled in `dispose`, or `pumpAndSettle` will loop; with a 1-minute period this is safe. + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart` +Expected: FAIL — widget missing. + +- [ ] **Step 3: Implement the wrapper widget** + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/features/safety/presentation/widgets/flight_window_card.dart'; + +/// Trip story wrapper around [FlightWindowCard]: watches the trip's flight +/// window and re-renders each minute so the countdown stays current. +class TripFlightCountdownCard extends ConsumerStatefulWidget { + final String tripId; + + const TripFlightCountdownCard({super.key, required this.tripId}); + + @override + ConsumerState createState() => + _TripFlightCountdownCardState(); +} + +class _TripFlightCountdownCardState + extends ConsumerState { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final statusAsync = ref.watch(tripFlightWindowProvider(widget.tripId)); + final status = statusAsync.valueOrNull; + if (status == null) return const SizedBox.shrink(); + return FlightWindowCard(status: status); + } +} +``` + +- [ ] **Step 4: Insert into the story view** + +In `trip_story_view.dart` `_contentSlivers()`, between the hero sliver and the `if (trip.isLiveaboard)` block: + +```dart + if (trip.returnFlightAt != null && trip.isInProgress) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: TripFlightCountdownCard(tripId: trip.id), + ), + ), +``` + +(Shown only while the trip is underway; `isInProgress` is date-only, and the provider itself returns null once the flight departs.) + +- [ ] **Step 5: Run story tests** + +Run: `flutter test test/features/trips/presentation/widgets/story/` +Expected: PASS — new test plus existing story tests. `trip_story_view_test.dart` pumps stories whose trips have `returnFlightAt == null`, so the sliver stays absent there; if any story fixture trip is in progress AND gains a flight time, override `tripFlightWindowProvider()` in that test. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "Show flight window countdown card in trip story" +``` + +--- + +### Task 9: Dashboard gauge chip + +**Files:** +- Modify: `lib/features/dashboard/presentation/providers/gauge_providers.dart` (`HomeChipType` ~L24-37, `DashboardGauges` ~L53-103, `dashboardGaugesProvider` ~L180-216) +- Modify: `lib/features/dashboard/presentation/widgets/gauge_strip.dart` (no-fly chip block ~L152-178) +- Modify: `lib/features/settings/presentation/pages/home_appearance_page.dart` (exhaustive `chipName` switch ~L24) +- Modify: `lib/l10n/arb/app_en.arb` + 10 catalogs +- Modify: `test/features/dashboard/presentation/widgets/gauge_strip_test.dart` + +**Interfaces:** +- Consumes: `activeTripFlightWindowProvider`, `FlightWindowStatus`, `NoFlyService.wallClockNowUtc`. +- Produces: `HomeChipType.flightWindow` enum value; `DashboardGauges.flightWindow` (`FlightWindowStatus?`, constructor param, default null so existing const fixtures stay valid). +- Produces l10n keys: + +```json + "dashboard_gauges_flightWindow": "Dive window {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { "type": "String" }, + "minutes": { "type": "String" } + } + }, + "dashboard_gauges_flightWindowClosed": "No more diving before flight", + "settings_homeChips_flightWindow": "Flight dive window", +``` + +- [ ] **Step 1: Add the l10n strings** (en + 10 translations, `flutter gen-l10n`). + +- [ ] **Step 2: Write the failing widget test** + +Append to `test/features/dashboard/presentation/widgets/gauge_strip_test.dart`, reusing `pumpStrip` and the `_emptyGauges` fixture (copy it with the new field via the class's copy pattern or construct a new `DashboardGauges` inline): + +```dart + testWidgets('shows flight window chip when a window is open', (tester) async { + final gauges = DashboardGauges( + gearGauges: const [], + hasGear: true, + insurance: null, + noFlyStatus: null, + daysSinceLastDive: null, + flightWindow: FlightWindowStatus( + state: FlightWindowState.open, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ), + ); + await pumpStrip(tester, gauges); + expect(find.textContaining('Dive window'), findsOneWidget); + }); + + testWidgets('shows closed flight chip after the deadline', (tester) async { + final gauges = DashboardGauges( + gearGauges: const [], + hasGear: true, + insurance: null, + noFlyStatus: null, + daysSinceLastDive: null, + flightWindow: FlightWindowStatus( + state: FlightWindowState.closed, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ), + ); + await pumpStrip(tester, gauges); + expect(find.text('No more diving before flight'), findsOneWidget); + }); + + testWidgets('shows no flight chip when no window exists', (tester) async { + await pumpStrip(tester, _emptyGauges); + expect(find.textContaining('Dive window'), findsNothing); + }); +``` + +Match the fixture's actual required constructor arguments to `_emptyGauges` in the file (it may list more fields than shown here). + +- [ ] **Step 3: Run to verify failure** + +Run: `flutter test test/features/dashboard/presentation/widgets/gauge_strip_test.dart` +Expected: FAIL — `flightWindow` parameter unknown. + +- [ ] **Step 4: Implement** + +4a. `gauge_providers.dart`: +- `HomeChipType`: add `flightWindow,` (append at the end; `.name` is the persisted id, order is irrelevant). +- `DashboardGauges`: add `final FlightWindowStatus? flightWindow;` and constructor param `this.flightWindow,` (import `no_fly_service.dart` is already present for `NoFlyStatus`; add `flight_window_providers.dart` import in this file for the provider). +- `dashboardGaugesProvider`: add `final flightWindow = await ref.watch(activeTripFlightWindowProvider.future);` alongside the no-fly line and pass `flightWindow: flightWindow,`. + +4b. `gauge_strip.dart` — after the existing no-fly chip block: + +```dart + if (_shown(hidden, HomeChipType.flightWindow)) { + final flight = g.flightWindow; + if (flight != null) { + switch (flight.state) { + case FlightWindowState.open: + final remaining = flight.remaining(NoFlyService.wallClockNowUtc()); + chips.add( + _chip( + context, + icon: Icons.flight_takeoff_outlined, + label: l10n.dashboard_gauges_flightWindow( + remaining.inHours.toString(), + (remaining.inMinutes % 60).toString().padLeft(2, '0'), + ), + tone: _Tone.warn, + onTap: () => context.goNamed('noFly'), + ), + ); + case FlightWindowState.closed: + case FlightWindowState.conflict: + chips.add( + _chip( + context, + icon: Icons.flight_takeoff_outlined, + label: l10n.dashboard_gauges_flightWindowClosed, + tone: _Tone.alert, + onTap: () => context.goNamed('noFly'), + ), + ); + } + } + } +``` + +(If the file navigates with `context.go('/path')` only, mirror the no-fly page's actual full path from `app_router.dart` instead of `goNamed`; check how the trip chip navigates and stay consistent.) + +4c. `home_appearance_page.dart`: the `chipName` switch is exhaustive — add +`HomeChipType.flightWindow => l10n.settings_homeChips_flightWindow,`. + +- [ ] **Step 5: Run dashboard tests** + +Run: `flutter test test/features/dashboard/` +Expected: PASS. Known trap: `dashboardGaugesProvider` now watches `activeTripFlightWindowProvider`, which reaches the trip repository — provider-level tests (`gauge_providers_test.dart`, `dashboard_gauges_provider_test.dart`) may fail on missing setup even though `flutter analyze` is clean. Fix by overriding `activeTripFlightWindowProvider.overrideWith((ref) async => null)` in those tests' ProviderScopes/containers. + +- [ ] **Step 6: Commit** + +```bash +dart format . +git add -A +git commit -m "Add flight window chip to dashboard gauge strip" +``` + +--- + +### Task 10: Dive edit warning banner + +**Files:** +- Create: `lib/features/dive_log/presentation/widgets/flight_window_warning_banner.dart` +- Modify: `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (form column children ~L794-839; state fields `_entryDate`/`_entryTime`/`_exitDate`/`_exitTime`/`_runtimeController` ~L152-157) +- Modify: `lib/l10n/arb/app_en.arb` + 10 catalogs +- Create: `test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart` + +**Interfaces:** +- Consumes: `tripFlightWindowProvider` (Task 5). +- Produces: `class FlightWindowWarningBanner extends ConsumerWidget { const FlightWindowWarningBanner({super.key, required this.tripId, required this.diveEndTime}); final String? tripId; final DateTime? diveEndTime; }` +- Produces l10n key: + +```json + "diveEdit_flightWindowWarning": "This dive ends after the latest safe surfacing time for your flight ({time})", + "@diveEdit_flightWindowWarning": { + "placeholders": { "time": { "type": "String" } } + }, +``` + +- [ ] **Step 1: Add the l10n string** (en + 10 translations, `flutter gen-l10n`). + +- [ ] **Step 2: Write the failing widget test** + +Create `test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart` (same override approach as Task 8's test): + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/flight_window_warning_banner.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + final openStatus = FlightWindowStatus( + state: FlightWindowState.open, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ); + + Future pump( + WidgetTester tester, { + required String? tripId, + required DateTime? diveEndTime, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tripFlightWindowProvider( + 't1', + ).overrideWith((ref) async => openStatus), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: FlightWindowWarningBanner( + tripId: tripId, + diveEndTime: diveEndTime, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('warns when the dive ends after the deadline', (tester) async { + await pump( + tester, + tripId: 't1', + diveEndTime: DateTime.utc(2126, 8, 9, 16), + ); + expect( + find.textContaining('after the latest safe surfacing time'), + findsOneWidget, + ); + }); + + testWidgets('silent when the dive ends before the deadline', (tester) async { + await pump( + tester, + tripId: 't1', + diveEndTime: DateTime.utc(2126, 8, 9, 12), + ); + expect( + find.textContaining('after the latest safe surfacing time'), + findsNothing, + ); + expect(find.byType(SizedBox), findsWidgets); // collapsed to shrink + }); + + testWidgets('silent without a trip', (tester) async { + await pump( + tester, + tripId: null, + diveEndTime: DateTime.utc(2126, 8, 9, 16), + ); + expect( + find.textContaining('after the latest safe surfacing time'), + findsNothing, + ); + }); +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `flutter test test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart` +Expected: FAIL — widget missing. + +- [ ] **Step 4: Implement the banner** + +Create `flight_window_warning_banner.dart` (styling mirrors `trip_service_alert_banner.dart`; non-interactive, warn-only): + +```dart +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Non-blocking warning shown while editing a dive whose end time falls +/// after the latest safe surfacing time for the trip's return flight. +/// Warn, never block: the diver may be logging a past trip or know better. +class FlightWindowWarningBanner extends ConsumerWidget { + final String? tripId; + final DateTime? diveEndTime; + + const FlightWindowWarningBanner({ + super.key, + required this.tripId, + required this.diveEndTime, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final id = tripId; + final end = diveEndTime; + if (id == null || end == null) return const SizedBox.shrink(); + + final status = ref.watch(tripFlightWindowProvider(id)).valueOrNull; + if (status == null || !end.isAfter(status.deadline)) { + return const SizedBox.shrink(); + } + + final scheme = Theme.of(context).colorScheme; + final time = DateFormat.E().add_jm().format(status.deadline); + return Container( + width: double.infinity, + color: scheme.errorContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon(Icons.flight_takeoff, size: 16, color: scheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.diveEdit_flightWindowWarning(time), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onErrorContainer), + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 5: Wire into the dive edit page** + +5a. Find the selected-trip state variable: `grep -n "tripId" lib/features/dive_log/presentation/pages/dive_edit_page.dart | head -20` (the trip group section binds it; expect a field like `String? _selectedTripId` or similar — use the actual name found). + +5b. Add an end-time helper near the other private helpers (same derivation `_saveDive` uses — exit fields if both set, else entry + runtime minutes): + +```dart + /// In-edit dive end time, wall-clock-as-UTC: exit fields when both are + /// set, otherwise entry + runtime. Null when neither is derivable. + DateTime? _currentDiveEndTime() { + if (_exitDate != null && _exitTime != null) { + return DateTime.utc( + _exitDate!.year, + _exitDate!.month, + _exitDate!.day, + _exitTime!.hour, + _exitTime!.minute, + ); + } + final entry = DateTime.utc( + _entryDate.year, + _entryDate.month, + _entryDate.day, + _entryTime.hour, + _entryTime.minute, + ); + final runtimeMinutes = int.tryParse(_runtimeController.text); + if (runtimeMinutes == null || runtimeMinutes <= 0) return null; + return entry.add(Duration(minutes: runtimeMinutes)); + } +``` + +5c. In the form column children, immediately before `_buildTheDiveSection(units)`: + +```dart + FlightWindowWarningBanner( + tripId: /* the trip id field found in 5a */, + diveEndTime: _currentDiveEndTime(), + ), +``` + +The page rebuilds on every `setState` (entry/exit edits, runtime typing via `onChanged: _markDirty`), so the banner tracks edits live. If runtime edits don't trigger rebuilds (controller listeners only), add a `listener: () => setState(() {})` on `_runtimeController` in `initState` — verify by manual reasoning about `_markDirty` first; only add if actually needed. + +- [ ] **Step 6: Run dive edit tests** + +Run: `flutter test test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart test/features/dive_log/presentation/pages/` +Expected: PASS. Known trap: dive edit page tests now construct a widget watching `tripFlightWindowProvider`; with `tripId == null` the provider is never touched, so existing tests should pass unchanged — if one seeds a trip id, override the family for that id with `(ref) async => null`. + +- [ ] **Step 7: Commit** + +```bash +dart format . +git add -A +git commit -m "Warn in dive editor when a dive ends past the flight window deadline" +``` + +--- + +### Task 11: Full verification pass + +**Files:** none new. + +- [ ] **Step 1: Format the whole project** + +Run: `dart format .` +Expected: no files changed (everything formatted per-task). If files change, commit them. + +- [ ] **Step 2: Analyze the whole project** + +Run: `flutter analyze` +Expected: `No issues found!` — full output, no piping. Infos are fatal in CI; fix every one. + +- [ ] **Step 3: Regenerate l10n and check for drift** + +Run: `flutter gen-l10n && git status --porcelain` +Expected: no dirty files (generated localizations already committed per task). + +- [ ] **Step 4: Run the full test suite** + +Run: `flutter test` (background it; expect several minutes) +Expected: PASS. Known pre-existing flaky tests (backup suite, media upload drain, recovery-code yoyo) may fail unrelated to this work — re-run an isolated failure once before investigating; do not chase failures that reproduce on `main`. + +- [ ] **Step 5: Final commit if anything moved** + +```bash +git add -A +git commit -m "Format and test fixes for flight window feature" # only if needed +``` + +--- + +## Notes for the implementer + +- **Pre-existing frame discrepancy (do NOT fix here):** `noFlyStatusProvider` and `gauge_strip.dart` compare wall-clock-as-UTC dive times against `DateTime.now().toUtc()` (true instant). Off-UTC devices get a skewed no-fly countdown. This feature deliberately uses `NoFlyService.wallClockNowUtc()` for its own math; the existing provider is left untouched. Flag it to the user as a candidate follow-up issue. +- **Dashboard reach:** `activeTripFlightWindowProvider` keys `tripForDateProvider` with local-midnight `today`, whose containment check uses the trips table's local-frame `start_date`/`end_date` — consistent with how `findTripForDate` is already consumed elsewhere. +- **Schema ladder:** if `origin/main` advances past v137 before Task 1 lands, renumber (see Global Constraints). The beforeOpen backstop makes the migration safe for DBs stranded at any intermediate version either way. diff --git a/docs/superpowers/specs/2026-08-05-trip-flight-no-fly-countdown-design.md b/docs/superpowers/specs/2026-08-05-trip-flight-no-fly-countdown-design.md new file mode 100644 index 0000000000..38107af6bf --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-trip-flight-no-fly-countdown-design.md @@ -0,0 +1,191 @@ +# Trip Return-Flight No-Fly Countdown - Design + +Date: 2026-08-05 +Status: Approved pending user review +Branch: worktree-trip-flight-no-fly + +## Problem + +When a diver is on a trip with a booked return flight, the question that +matters on the last diving days is not "when can I fly?" but "how much longer +can I keep diving?" Flying too soon after diving risks decompression sickness +from reduced cabin pressure. The app already tracks backward-looking no-fly +status (Settings > Safety, `NoFlyService`); this feature adds the +forward-looking countdown: given the trip's return flight departure time, +show the remaining hours and minutes of dive window, i.e. the latest time the +diver must surface so that the required pre-flight surface interval fits +before departure. + +## Decisions (from brainstorming) + +- No-fly rule source: the existing "flying after diving" setting + (`DiverSettings.noFlyPreset`, standard 12/18/24h or strict 18/24/48h). + No tissue-model computation; `NoFlyService`'s fixed agency intervals are + deliberate and remain the single source of truth. +- Flight data stored: departure date/time only. No flight number, airline, + or airports. +- Deadline anchor: exactly at flight departure. No built-in or per-trip + buffer. +- Display surfaces: trip story view, No-Fly page, dashboard gauge strip, + and a warning in the dive logging flow. +- Architecture: Approach A - the safety feature owns all computation; the + trips feature only stores the flight time. + +## Data Model and Migration (schema v142) + +New nullable column on `Trips` in `lib/core/database/database.dart`: + +- `return_flight_at` INT (epoch ms), wall-clock in device-local time, the + same convention as `startDate`/`endDate` and dive times. No timezone + column. Rationale: during the trip the device clock is trip-local time, + which is the frame the countdown needs. Caveat (documented, not + engineered around): setting the flight time from home for a trip in a + different timezone stores home-wall-clock; editing it on location + corrects it. + +Migration mechanics (mirrors v135/v139 column-add pattern): + +- Idempotent `_assertTripReturnFlightColumn()` called from both the + `if (from < 142)` onUpgrade block and the beforeOpen backstop. +- Version number: v142 per the schema ladder (v138 = divelogs #603, + v139 = equipment currency #805). Re-grep `currentSchemaVersion = ` on + current origin/main immediately before implementation; renumber upward if + main has advanced. +- Migration test `migration_v142_trip_return_flight_test.dart` using + `greaterThanOrEqualTo(142)` + `contains(142)`, plus a fresh-DB (onCreate) + case and a stranded-at-currentSchemaVersion (backstop) case. + +Entity and repository: + +- `Trip.returnFlightAt` (`DateTime?`) with copyWith support that can also + clear the value; the repository update path uses the established + clear-field `.toCompanion(false)` pattern so null actually persists. +- Sync: `Trips` is already HLC-synced. Whole-row export picks up the new + column automatically; schema-default hydration (post-#858) hydrates the + column as null from older changesets. Updating the flight time bumps the + trip HLC as any trip edit does. No further sync work. + +Edit UI: + +- Optional "Return flight departure" date + time picker on + `trip_edit_page.dart`, with a clear affordance. Localized in en plus all + 10 non-English locales, l10n regenerated. + +## Domain Logic (safety feature) + +New pure method on `NoFlyService` +(`lib/features/safety/domain/services/no_fly_service.dart`), keeping `now` +as a parameter like the existing `NoFlyStatus.remaining(now)`: + +``` +FlightWindowStatus flightWindow({ + required DateTime flightAt, + required NoFlyPreset preset, + required NoFlyCategory prospectiveCategory, + DateTime? currentNoFlyUntil, + required DateTime now, +}) +``` + +- Deadline (latest safe surfacing time) = `flightAt` minus the interval for + (`preset`, category), reusing the exact interval table `evaluate()` uses. +- Prospective category: at least `repetitive` (a trip is multi-day diving + by definition; `single` would show up to 6 phantom hours under the + standard preset). Escalates to `deco` when any dive within the existing + 48h lookback had a deco obligation - the same signal `evaluate()` uses. + This holds even before the first trip dive is logged (consistent and + conservative). +- States on `FlightWindowStatus`: + - `open`: now < deadline. Exposes `deadline` and `remaining(now)` - the + time left in which diving may continue; the diver must surface by the + deadline. + - `closed`: deadline <= now < flightAt. No more diving before this + flight. + - `conflict`: the backward-looking `NoFlyStatus.until` (from + `noFlyStatusProvider`) is after `flightAt`. The diver has already dived + too recently for this flight. Alert treatment; takes precedence over + open/closed. + - `none`: flight is in the past, or no flight set (provider returns null + before the service is even consulted). +- Category escalation mid-trip (first deco dive logged) legitimately jumps + the deadline earlier and may flip open -> closed or conflict. + +## Providers and Reactivity (safety feature) + +- `tripFlightWindowProvider` - + `FutureProvider.family` keyed by trip id. + Reads the trip (`tripByIdProvider`), the `noFlyPreset` from + `settingsProvider`, and `getNoFlyDiveInputs` over the same 48h lookback + as `noFlyStatusProvider`. Returns null when the trip has no + `returnFlightAt`. Self-invalidates on dive writes, mirroring + `noFlyStatusProvider`. The provider derives `prospectiveCategory` by + running `NoFlyService.evaluate()` over the lookback inputs (which yields + the current category and `until`) and flooring the category at + `repetitive`; the service method itself stays pure and takes the result + as a parameter. +- `activeTripFlightWindowProvider` - resolves the trip containing today + (`tripForDateProvider`) that has a flight time set, then delegates to the + family. Consumed by the dashboard gauge and No-Fly page; the trip story + passes its own trip id. +- Ticking: computation is pure against `now`; surfaces re-evaluate on a + shared coarse minute-tick provider so displayed hh:mm stays current + without re-reading dive inputs every tick. + +Dependency direction note: the safety feature gains a read dependency on +the trip repository/providers (to fetch the active trip's flight time). +This matches the existing direction - the dashboard already reads safety +providers; trips never reads safety. + +## UI Surfaces + +1. Trip story (`TripStoryView`): a countdown card near the top while the + trip is in progress and a flight is set. + - open: "Time left to dive - 14h 32m (surface by Sat 09:15)" + - closed: "No more diving before your flight." + - conflict: alert-styled "Your no-fly time extends past your flight + departure." +2. No-Fly page (`no_fly_page.dart`): a "Your flight" section when an + active trip has a flight - departure time, latest safe surfacing time, + and the comparison against the current no-fly clock (where conflict is + most legible). +3. Dashboard gauge strip (`gauge_providers.dart` / `gauge_strip.dart`): a + new flight-window gauge kind, shown only when an active trip has a + flight and the state is open, closed, or conflict (i.e. inside the trip + with the flight ahead). Additive; no behavior change without a + trip/flight. +4. Dive logging (dive edit page): non-blocking warning banner when the + dive's end time falls after the deadline. Warn, never block - the diver + may be logging a past trip or knows better. + +All new strings localized in en + 10 non-English locales with l10n regen. + +## Edge Cases + +- No dives logged on the trip yet: still assume `repetitive`. +- Trip endDate after the flight (fly out mid-trip): countdown anchors to + the flight regardless of trip end. +- Overlapping trips: `tripForDateProvider` picks the containing trip; + tie-breaking is its existing concern, not this feature's. +- Flight time cleared: all surfaces revert to current behavior. +- Flight in the past relative to now: `none`; nothing shown. + +## Testing + +- Unit tests for `flightWindow()` with fixed clocks: every state, both + presets, category escalation, exact-boundary at the deadline, conflict + precedence over open/closed. +- Migration test for v142: upgrade path, fresh DB, backstop. +- Widget tests for the trip story card states (open/closed/conflict). +- Provider tests for `tripFlightWindowProvider` null and populated paths. +- Known trap: adding a provider dependency to the dive edit page and + No-Fly page breaks their existing consumer tests in ways + `flutter analyze` does not catch; those test files get overrides updated + in the same change. + +## Out of Scope + +- Flight number / airline / airport fields. +- Outbound-flight or multi-segment itineraries. +- Timezone modeling on trips. +- Tissue-model (Buhlmann) desaturation countdown. +- Notifications/alarms for the approaching deadline (possible follow-up). diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 6a4877eb80..a094e50d59 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -71,6 +71,10 @@ class Trips extends Table { TextColumn get tripType => text().withDefault(const Constant('shore'))(); TextColumn get notes => text().withDefault(const Constant(''))(); BoolColumn get isShared => boolean().withDefault(const Constant(false))(); + + /// Return flight departure, wall-clock-as-UTC epoch ms (v142). Drives the + /// remaining-dive-window countdown; null when the trip has no flight set. + IntColumn get returnFlightAt => integer().nullable()(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); @@ -2928,7 +2932,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 141; + static const int currentSchemaVersion = 142; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -3106,6 +3110,8 @@ class AppDatabase extends _$AppDatabase { // items). Renumbered from v138 and then v139 as those went to the // divelogs.de branch and the cylinder configs respectively. 141, + // v142: trips.return_flight_at (return-flight dive-window countdown). + 142, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -4097,6 +4103,22 @@ class AppDatabase extends _$AppDatabase { } } + /// Idempotent DDL for the v142 return-flight column. Called from the v142 + /// onUpgrade step and the beforeOpen backstop, matching the + /// _assertWeatherCodeColumn pattern so a schema-version collision cannot + /// strand a database without it. Self-guarding when the table is absent + /// (minimal migration-test fixtures). + Future _assertTripReturnFlightColumn() async { + final cols = await customSelect("PRAGMA table_info('trips')").get(); + if (cols.isEmpty) return; + final names = cols.map((c) => c.read('name')).toSet(); + if (!names.contains('return_flight_at')) { + await customStatement( + 'ALTER TABLE trips ADD COLUMN return_flight_at INTEGER', + ); + } + } + /// One-time clear of weather descriptions this app generated itself. /// /// Only rows whose weather_source is 'openMeteo' are touched -- those are @@ -7328,6 +7350,13 @@ class AppDatabase extends _$AppDatabase { await _assertDefaultCurrencyColumn(); } if (from < 141) await reportProgress(); + // v142: trips.return_flight_at (return-flight dive-window countdown). + // v138 (#603) and v140 (media section) are reserved by parallel + // branches; the beforeOpen backstop heals any DB stranded between. + if (from < 142) { + await _assertTripReturnFlightColumn(); + } + if (from < 142) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys @@ -7444,6 +7473,9 @@ class AppDatabase extends _$AppDatabase { // version collision self-heals here. await _assertCylinderConfigSchema(); + // v142 backstop: re-assert trips.return_flight_at. + await _assertTripReturnFlightColumn(); + // Built-in dive types are reference data: identical on every device and // undeletable through DiveTypeRepository. Nothing else restores them -- // the seed runs only in onCreate and the one-shot v93 step -- yet a diff --git a/lib/features/dashboard/presentation/providers/gauge_providers.dart b/lib/features/dashboard/presentation/providers/gauge_providers.dart index f4d0784b39..bf4f87631e 100644 --- a/lib/features/dashboard/presentation/providers/gauge_providers.dart +++ b/lib/features/dashboard/presentation/providers/gauge_providers.dart @@ -13,6 +13,7 @@ import 'package:submersion/features/equipment/presentation/providers/equipment_p import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/pre_dive/presentation/providers/pre_dive_providers.dart'; import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; import 'package:submersion/features/safety/presentation/providers/no_fly_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; import 'package:submersion/features/trips/domain/entities/trip.dart'; @@ -34,6 +35,7 @@ enum HomeChipType { backup, sync, dataQuality, + flightWindow, } /// The worst service clock for one equipment type, shown as one chip. @@ -84,6 +86,9 @@ class DashboardGauges { /// Open data-quality findings. final int dataQualityFindings; + /// Dive window before the active trip's return flight, if one is set. + final FlightWindowStatus? flightWindow; + const DashboardGauges({ required this.gearGauges, required this.hasGear, @@ -99,6 +104,7 @@ class DashboardGauges { this.syncEnabled = false, this.syncPending = 0, this.dataQualityFindings = 0, + this.flightWindow, }); } @@ -181,6 +187,7 @@ final dashboardGaugesProvider = FutureProvider((ref) async { final clocks = await ref.watch(activeEquipmentClocksProvider.future); final diver = await ref.watch(currentDiverProvider.future); final noFly = await ref.watch(noFlyStatusProvider.future); + final flightWindow = await ref.watch(activeTripFlightWindowProvider.future); final daysSince = await ref.watch(daysSinceLastDiveProvider.future); final certCount = await ref.watch(expiringCertificationCountProvider.future); final trips = await ref.watch(allTripsProvider.future); @@ -212,5 +219,6 @@ final dashboardGaugesProvider = FutureProvider((ref) async { syncEnabled: syncEnabled, syncPending: syncPending, dataQualityFindings: findings, + flightWindow: flightWindow, ); }); diff --git a/lib/features/dashboard/presentation/widgets/gauge_strip.dart b/lib/features/dashboard/presentation/widgets/gauge_strip.dart index c526125906..1e36e1461b 100644 --- a/lib/features/dashboard/presentation/widgets/gauge_strip.dart +++ b/lib/features/dashboard/presentation/widgets/gauge_strip.dart @@ -5,6 +5,7 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/dashboard/presentation/providers/gauge_providers.dart'; import 'package:submersion/features/equipment/domain/entities/service_clock_status.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -177,6 +178,39 @@ class GaugeStrip extends ConsumerWidget { } } + if (_shown(hidden, HomeChipType.flightWindow)) { + final flight = g.flightWindow; + if (flight != null) { + switch (flight.state) { + case FlightWindowState.open: + final remaining = flight.remaining(NoFlyService.wallClockNowUtc()); + chips.add( + _chip( + context, + icon: Icons.flight_takeoff_outlined, + label: l10n.dashboard_gauges_flightWindow( + remaining.inHours.toString(), + (remaining.inMinutes % 60).toString().padLeft(2, '0'), + ), + tone: _Tone.warn, + onTap: () => context.goNamed('noFly'), + ), + ); + case FlightWindowState.closed: + case FlightWindowState.conflict: + chips.add( + _chip( + context, + icon: Icons.flight_takeoff_outlined, + label: l10n.dashboard_gauges_flightWindowClosed, + tone: _Tone.alert, + onTap: () => context.goNamed('noFly'), + ), + ); + } + } + } + if (_shown(hidden, HomeChipType.lastDive)) { final days = g.daysSinceLastDive; final tone = days == null diff --git a/lib/features/dive_log/presentation/pages/dive_edit_page.dart b/lib/features/dive_log/presentation/pages/dive_edit_page.dart index c14a8e3c77..914f852c91 100644 --- a/lib/features/dive_log/presentation/pages/dive_edit_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_edit_page.dart @@ -96,6 +96,7 @@ import 'package:submersion/features/dive_log/domain/entities/bulk_edit_request.d import 'package:submersion/features/dive_log/presentation/pages/bulk_edit_field_set.dart'; import 'package:submersion/features/dive_log/presentation/providers/bulk_dive_edit_provider.dart'; import 'package:submersion/features/dive_log/presentation/widgets/bulk_collection_mode_selector.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/flight_window_warning_banner.dart'; import 'package:submersion/features/dive_log/presentation/widgets/bulk_field_gate.dart'; import 'package:submersion/core/constants/tank_presets.dart'; import 'package:submersion/features/tank_presets/domain/entities/tank_preset_entity.dart'; @@ -284,6 +285,31 @@ class _DiveEditPageState extends ConsumerState { /// trip the discard guard. bool _suppressDirty = true; + /// In-edit dive end time, wall-clock-as-UTC: exit fields when both are + /// set, otherwise entry + runtime. Null when neither is derivable. Feeds + /// the flight-window warning banner; mirrors the save-path derivation. + DateTime? _currentDiveEndTime() { + if (_exitDate != null && _exitTime != null) { + return DateTime.utc( + _exitDate!.year, + _exitDate!.month, + _exitDate!.day, + _exitTime!.hour, + _exitTime!.minute, + ); + } + final entry = DateTime.utc( + _entryDate.year, + _entryDate.month, + _entryDate.day, + _entryTime.hour, + _entryTime.minute, + ); + final runtimeMinutes = int.tryParse(_runtimeController.text); + if (runtimeMinutes == null || runtimeMinutes <= 0) return null; + return entry.add(Duration(minutes: runtimeMinutes)); + } + void _markDirty() { if (_suppressDirty || _hasUnsavedChanges) return; _hasUnsavedChanges = true; @@ -797,32 +823,47 @@ class _DiveEditPageState extends ConsumerState { final formBody = Form( key: _formKey, onChanged: _markDirty, - // Split after Gas & Gear so the two always-relevant groups lead the - // left column and the contextual ones fill the right on wide windows. - child: ResponsiveFormColumns( - splitIndex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildTheDiveSection(units), - _buildGasGearSection(units), - _buildConditionsSection(units), - _buildTripGroupSection(units), - _buildBuddiesSection(), - _buildExperienceSection(), - if (_showCourseSection) _buildCourseGroupSection(), - if (_showCustomFieldsSection) _buildCustomFieldsGroupSection(), - AddSectionRow( - entries: [ - if (!_showCourseSection) - AddSectionEntry( - label: context.l10n.diveLog_edit_section_trainingCourse, - onTap: () => setState(() => _expanded['course'] = true), - ), - if (!_showCustomFieldsSection) - AddSectionEntry( - label: context.l10n.diveLog_edit_section_customFields, - onTap: () => setState(() => _expanded['customFields'] = true), + // Pinned above the scrolling form so the warning stays visible. + FlightWindowWarningBanner( + tripId: _selectedTrip?.id, + diveEndTime: _currentDiveEndTime(), + ), + // Split after Gas & Gear so the two always-relevant groups lead + // the left column and the contextual ones fill the right on wide + // windows. ResponsiveFormColumns owns the scroll view, so it + // needs the bounded height Expanded provides. + Expanded( + child: ResponsiveFormColumns( + splitIndex: 2, + children: [ + _buildTheDiveSection(units), + _buildGasGearSection(units), + _buildConditionsSection(units), + _buildTripGroupSection(units), + _buildBuddiesSection(), + _buildExperienceSection(), + if (_showCourseSection) _buildCourseGroupSection(), + if (_showCustomFieldsSection) _buildCustomFieldsGroupSection(), + AddSectionRow( + entries: [ + if (!_showCourseSection) + AddSectionEntry( + label: context.l10n.diveLog_edit_section_trainingCourse, + onTap: () => setState(() => _expanded['course'] = true), + ), + if (!_showCustomFieldsSection) + AddSectionEntry( + label: context.l10n.diveLog_edit_section_customFields, + onTap: () => + setState(() => _expanded['customFields'] = true), + ), + ], ), - ], + ], + ), ), ], ), diff --git a/lib/features/dive_log/presentation/widgets/flight_window_warning_banner.dart b/lib/features/dive_log/presentation/widgets/flight_window_warning_banner.dart new file mode 100644 index 0000000000..f7ef295f85 --- /dev/null +++ b/lib/features/dive_log/presentation/widgets/flight_window_warning_banner.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Non-blocking warning shown while editing a dive whose end time falls +/// after the latest safe surfacing time for the trip's return flight. +/// Warn, never block: the diver may be logging a past trip or know better. +class FlightWindowWarningBanner extends ConsumerWidget { + final String? tripId; + final DateTime? diveEndTime; + + const FlightWindowWarningBanner({ + super.key, + required this.tripId, + required this.diveEndTime, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final id = tripId; + final end = diveEndTime; + if (id == null || end == null) return const SizedBox.shrink(); + + final status = ref.watch(tripFlightWindowProvider(id)).valueOrNull; + if (status == null || !end.isAfter(status.deadline)) { + return const SizedBox.shrink(); + } + + final scheme = Theme.of(context).colorScheme; + // Wall-clock-as-UTC deadline: format components directly, no toLocal(). + final time = DateFormat.E().add_jm().format(status.deadline); + return Container( + width: double.infinity, + color: scheme.errorContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + margin: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(Icons.flight_takeoff, size: 16, color: scheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.diveLog_edit_flightWindowWarning(time), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: scheme.onErrorContainer), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/safety/domain/services/no_fly_service.dart b/lib/features/safety/domain/services/no_fly_service.dart index 0e477965ef..b95bbbf255 100644 --- a/lib/features/safety/domain/services/no_fly_service.dart +++ b/lib/features/safety/domain/services/no_fly_service.dart @@ -51,6 +51,47 @@ class NoFlyStatus { bool isActiveAt(DateTime now) => until.isAfter(now); } +/// State of the forward-looking dive window before a booked flight. +enum FlightWindowState { + /// Diving may continue; the diver must surface by + /// [FlightWindowStatus.deadline]. + open, + + /// The deadline has passed: no more diving before this flight. + closed, + + /// The diver's existing no-fly restriction already extends past the + /// flight departure. Takes precedence over open/closed. + conflict, +} + +/// Forward-looking dive window for a trip's return flight: the latest safe +/// surfacing time is the departure minus the guideline interval for the +/// (preset, category) pair. Same fixed-interval doctrine as [NoFlyStatus]. +class FlightWindowStatus { + final FlightWindowState state; + + /// Flight departure, wall-clock-as-UTC (the dive-time frame). + final DateTime flightAt; + + /// Latest safe surfacing time before [flightAt]. + final DateTime deadline; + + final NoFlyCategory category; + final Duration interval; + + const FlightWindowStatus({ + required this.state, + required this.flightAt, + required this.deadline, + required this.category, + required this.interval, + }); + + Duration remaining(DateTime now) => + deadline.isAfter(now) ? deadline.difference(now) : Duration.zero; +} + /// Classifies the trailing dive window per DAN/UHMS flying-after-diving /// guidance and computes the countdown anchor. The fixed guideline intervals /// are authoritative here by design -- no agency endorses computed @@ -85,7 +126,17 @@ class NoFlyService { .map((d) => d.endTime) .reduce((a, b) => a.isAfter(b) ? a : b); - final interval = switch ((preset, category)) { + final interval = intervalFor(preset, category); + + final until = lastEnd.add(interval); + if (!until.isAfter(now)) return null; + return NoFlyStatus(until: until, category: category, interval: interval); + } + + /// Guideline pre-flight surface interval for a (preset, category) pair. + /// Single source of truth shared by [evaluate] and [flightWindow]. + static Duration intervalFor(NoFlyPreset preset, NoFlyCategory category) { + return switch ((preset, category)) { (NoFlyPreset.standard, NoFlyCategory.single) => const Duration(hours: 12), (NoFlyPreset.standard, NoFlyCategory.repetitive) => const Duration( hours: 18, @@ -97,9 +148,54 @@ class NoFlyService { ), (NoFlyPreset.strict, NoFlyCategory.deco) => const Duration(hours: 48), }; + } - final until = lastEnd.add(interval); - if (!until.isAfter(now)) return null; - return NoFlyStatus(until: until, category: category, interval: interval); + /// The current moment in the app's wall-clock-as-UTC dive-time frame. + /// Dive entry/exit times are stored as `DateTime.utc(local components)`, + /// so comparisons against them must use the same construction -- NOT + /// `DateTime.now().toUtc()`, which is the true instant and differs by the + /// device's UTC offset. + static DateTime wallClockNowUtc() { + final now = DateTime.now(); + return DateTime.utc( + now.year, + now.month, + now.day, + now.hour, + now.minute, + now.second, + ); + } + + /// Computes the dive window before [flightAt], or null when the flight + /// has already departed. [prospectiveCategory] is the caller's + /// forward-looking classification (at least repetitive on a trip); + /// [currentNoFlyUntil] is the backward-looking restriction end used to + /// detect a conflict. + FlightWindowStatus? flightWindow({ + required DateTime flightAt, + required NoFlyPreset preset, + required NoFlyCategory prospectiveCategory, + DateTime? currentNoFlyUntil, + required DateTime now, + }) { + if (!flightAt.isAfter(now)) return null; + final interval = intervalFor(preset, prospectiveCategory); + final deadline = flightAt.subtract(interval); + final FlightWindowState state; + if (currentNoFlyUntil != null && currentNoFlyUntil.isAfter(flightAt)) { + state = FlightWindowState.conflict; + } else if (now.isBefore(deadline)) { + state = FlightWindowState.open; + } else { + state = FlightWindowState.closed; + } + return FlightWindowStatus( + state: state, + flightAt: flightAt, + deadline: deadline, + category: prospectiveCategory, + interval: interval, + ); } } diff --git a/lib/features/safety/presentation/pages/no_fly_page.dart b/lib/features/safety/presentation/pages/no_fly_page.dart index 995cadda44..4ad7950054 100644 --- a/lib/features/safety/presentation/pages/no_fly_page.dart +++ b/lib/features/safety/presentation/pages/no_fly_page.dart @@ -4,9 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:submersion/core/providers/async_value_extensions.dart'; import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; import 'package:submersion/features/safety/presentation/formatters/no_fly_format.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; import 'package:submersion/features/safety/presentation/providers/no_fly_providers.dart'; +import 'package:submersion/features/safety/presentation/widgets/flight_window_card.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/l10n/l10n_extension.dart'; @@ -64,6 +67,20 @@ class _NoFlyPageState extends ConsumerState { icon: Icons.hourglass_empty, text: l10n.common_label_loading, ), + // Forward-looking window for the active trip's return flight, if + // one is set. The page's minute ticker keeps the countdown fresh. + Builder( + builder: (context) { + final flight = ref + .watch(activeTripFlightWindowProvider) + .valueOrNull; + if (flight == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: FlightWindowCard(status: flight), + ); + }, + ), ], ), ); diff --git a/lib/features/safety/presentation/providers/flight_window_providers.dart b/lib/features/safety/presentation/providers/flight_window_providers.dart new file mode 100644 index 0000000000..2fdede1287 --- /dev/null +++ b/lib/features/safety/presentation/providers/flight_window_providers.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; + +/// Forward-looking dive window for one trip's return flight, or null when +/// the trip has no flight set (or it already departed). +/// +/// Category floor is repetitive: a trip is multi-day diving by definition, +/// so the single-dive interval would overstate the window. A deco dive in +/// the lookback escalates to the deco interval. +final tripFlightWindowProvider = + FutureProvider.family((ref, tripId) async { + final tripRepository = ref.watch(tripRepositoryProvider); + ref.invalidateSelfWhen(tripRepository.watchTripsChanges()); + + final trip = await tripRepository.getTripById(tripId); + final flightAt = trip?.returnFlightAt; + if (flightAt == null) return null; + + final diveRepository = ref.watch(diveRepositoryProvider); + ref.invalidateSelfWhen(diveRepository.watchDivesChanges()); + + final preset = ref.watch(settingsProvider.select((s) => s.noFlyPreset)); + final diverId = ref.watch(currentDiverIdProvider); + + final now = NoFlyService.wallClockNowUtc(); + const service = NoFlyService(); + + NoFlyStatus? current; + if (diverId != null) { + final dives = await diveRepository.getNoFlyDiveInputs( + since: now.subtract(NoFlyService.lookback), + diverId: diverId, + ); + current = service.evaluate(dives: dives, preset: preset, now: now); + } + + final category = current?.category == NoFlyCategory.deco + ? NoFlyCategory.deco + : NoFlyCategory.repetitive; + final status = service.flightWindow( + flightAt: flightAt, + preset: preset, + prospectiveCategory: category, + currentNoFlyUntil: current?.until, + now: now, + ); + + // State flips (open -> closed at the deadline, gone at departure) + // happen without any table write; self-invalidate just past the next + // boundary, mirroring noFlyStatusProvider's expiry timer. + if (status != null) { + final boundary = now.isBefore(status.deadline) + ? status.deadline + : status.flightAt; + final untilBoundary = boundary.difference(now); + if (untilBoundary > Duration.zero) { + final timer = Timer( + untilBoundary + const Duration(seconds: 1), + ref.invalidateSelf, + ); + ref.onDispose(timer.cancel); + } + } + return status; + }); + +/// Flight window for the trip containing today, or null. Feeds the +/// dashboard gauge and the No-Fly page. +final activeTripFlightWindowProvider = FutureProvider(( + ref, +) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final trip = await ref.watch(tripForDateProvider(today).future); + if (trip == null || trip.returnFlightAt == null) return null; + return ref.watch(tripFlightWindowProvider(trip.id).future); +}); diff --git a/lib/features/safety/presentation/widgets/flight_window_card.dart b/lib/features/safety/presentation/widgets/flight_window_card.dart new file mode 100644 index 0000000000..3e1e8556b8 --- /dev/null +++ b/lib/features/safety/presentation/widgets/flight_window_card.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/formatters/no_fly_format.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Presentational card for a [FlightWindowStatus]. Parents own the ticking: +/// re-build (NoFlyPage's minute timer, the trip story wrapper's timer) and +/// the countdown re-renders against the current wall-clock. +class FlightWindowCard extends StatelessWidget { + final FlightWindowStatus status; + + const FlightWindowCard({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + final now = NoFlyService.wallClockNowUtc(); + // Wall-clock-as-UTC values format their components directly -- no + // toLocal(), matching how dive times are displayed everywhere. + final timeFormat = DateFormat.E().add_jm(); + + final ( + IconData icon, + Color color, + String title, + String subtitle, + ) = switch (status.state) { + FlightWindowState.open => ( + Icons.flight_takeoff, + scheme.primary, + l10n.flightWindow_openTitle( + formatNoFlyRemaining(status.remaining(now)), + ), + l10n.flightWindow_surfaceBy(timeFormat.format(status.deadline)), + ), + FlightWindowState.closed => ( + Icons.airplanemode_inactive, + scheme.tertiary, + l10n.flightWindow_closed, + l10n.flightWindow_departs(timeFormat.format(status.flightAt)), + ), + FlightWindowState.conflict => ( + Icons.warning_amber, + scheme.error, + l10n.flightWindow_conflict, + l10n.flightWindow_departs(timeFormat.format(status.flightAt)), + ), + }; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 4), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/pages/home_appearance_page.dart b/lib/features/settings/presentation/pages/home_appearance_page.dart index f058107339..052fa47752 100644 --- a/lib/features/settings/presentation/pages/home_appearance_page.dart +++ b/lib/features/settings/presentation/pages/home_appearance_page.dart @@ -34,6 +34,7 @@ class HomeAppearancePage extends ConsumerWidget { HomeChipType.backup => l10n.settings_homeChips_backup, HomeChipType.sync => l10n.settings_homeChips_sync, HomeChipType.dataQuality => l10n.settings_homeChips_dataQuality, + HomeChipType.flightWindow => l10n.settings_homeChips_flightWindow, }; final content = ListView( diff --git a/lib/features/trips/data/repositories/trip_repository.dart b/lib/features/trips/data/repositories/trip_repository.dart index f85d1258d3..0971fa0e58 100644 --- a/lib/features/trips/data/repositories/trip_repository.dart +++ b/lib/features/trips/data/repositories/trip_repository.dart @@ -85,33 +85,7 @@ class TripRepository { ORDER BY start_date DESC ''', variables: variables).get(); - return results.map((row) { - return domain.Trip( - id: row.data['id'] as String, - diverId: row.data['diver_id'] as String?, - name: row.data['name'] as String, - startDate: DateTime.fromMillisecondsSinceEpoch( - row.data['start_date'] as int, - ), - endDate: DateTime.fromMillisecondsSinceEpoch( - row.data['end_date'] as int, - ), - location: row.data['location'] as String?, - resortName: row.data['resort_name'] as String?, - liveaboardName: row.data['liveaboard_name'] as String?, - notes: (row.data['notes'] as String?) ?? '', - tripType: TripType.fromName( - (row.data['trip_type'] as String?) ?? 'shore', - ), - isShared: (row.data['is_shared'] as int? ?? 0) != 0, - createdAt: DateTime.fromMillisecondsSinceEpoch( - row.data['created_at'] as int, - ), - updatedAt: DateTime.fromMillisecondsSinceEpoch( - row.data['updated_at'] as int, - ), - ); - }).toList(); + return results.map((row) => _mapDataToTrip(row.data)).toList(); } /// Create a new trip @@ -136,6 +110,9 @@ class TripRepository { notes: Value(trip.notes), tripType: Value(trip.tripType.name), isShared: Value(trip.isShared), + returnFlightAt: Value( + trip.returnFlightAt?.millisecondsSinceEpoch, + ), createdAt: Value(now.millisecondsSinceEpoch), updatedAt: Value(now.millisecondsSinceEpoch), ), @@ -177,6 +154,8 @@ class TripRepository { notes: Value(trip.notes), tripType: Value(trip.tripType.name), isShared: Value(trip.isShared), + // Value(null) writes SQL NULL, so clearing the flight time works. + returnFlightAt: Value(trip.returnFlightAt?.millisecondsSinceEpoch), updatedAt: Value(now), ), ); @@ -580,31 +559,7 @@ class TripRepository { if (result == null) return null; - return domain.Trip( - id: result.data['id'] as String, - diverId: result.data['diver_id'] as String?, - name: result.data['name'] as String, - startDate: DateTime.fromMillisecondsSinceEpoch( - result.data['start_date'] as int, - ), - endDate: DateTime.fromMillisecondsSinceEpoch( - result.data['end_date'] as int, - ), - location: result.data['location'] as String?, - resortName: result.data['resort_name'] as String?, - liveaboardName: result.data['liveaboard_name'] as String?, - notes: (result.data['notes'] as String?) ?? '', - tripType: TripType.fromName( - (result.data['trip_type'] as String?) ?? 'shore', - ), - isShared: (result.data['is_shared'] as int? ?? 0) != 0, - createdAt: DateTime.fromMillisecondsSinceEpoch( - result.data['created_at'] as int, - ), - updatedAt: DateTime.fromMillisecondsSinceEpoch( - result.data['updated_at'] as int, - ), - ); + return _mapDataToTrip(result.data); } /// Get all trips with their statistics. @@ -653,31 +608,7 @@ class TripRepository { ''', variables: variables).get(); return rows.map((row) { - final trip = domain.Trip( - id: row.data['id'] as String, - diverId: row.data['diver_id'] as String?, - name: row.data['name'] as String, - startDate: DateTime.fromMillisecondsSinceEpoch( - row.data['start_date'] as int, - ), - endDate: DateTime.fromMillisecondsSinceEpoch( - row.data['end_date'] as int, - ), - location: row.data['location'] as String?, - resortName: row.data['resort_name'] as String?, - liveaboardName: row.data['liveaboard_name'] as String?, - notes: (row.data['notes'] as String?) ?? '', - tripType: TripType.fromName( - (row.data['trip_type'] as String?) ?? 'shore', - ), - isShared: (row.data['is_shared'] as int? ?? 0) != 0, - createdAt: DateTime.fromMillisecondsSinceEpoch( - row.data['created_at'] as int, - ), - updatedAt: DateTime.fromMillisecondsSinceEpoch( - row.data['updated_at'] as int, - ), - ); + final trip = _mapDataToTrip(row.data); return domain.TripWithStats( trip: trip, diveCount: row.data['dive_count'] as int, @@ -701,8 +632,45 @@ class TripRepository { notes: row.notes, tripType: TripType.fromName(row.tripType), isShared: row.isShared, + // Wall-clock-as-UTC: decode with isUtc so the stored components are + // preserved rather than shifted into the device's timezone. + returnFlightAt: row.returnFlightAt != null + ? DateTime.fromMillisecondsSinceEpoch( + row.returnFlightAt!, + isUtc: true, + ) + : null, createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt), updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt), ); } + + /// Shared mapper for customSelect rows (searchTrips, findTripForDate, + /// getAllTripsWithStats) so a new trips column cannot silently miss one + /// of the hand-written sites. + domain.Trip _mapDataToTrip(Map data) { + return domain.Trip( + id: data['id'] as String, + diverId: data['diver_id'] as String?, + name: data['name'] as String, + startDate: DateTime.fromMillisecondsSinceEpoch(data['start_date'] as int), + endDate: DateTime.fromMillisecondsSinceEpoch(data['end_date'] as int), + location: data['location'] as String?, + resortName: data['resort_name'] as String?, + liveaboardName: data['liveaboard_name'] as String?, + notes: (data['notes'] as String?) ?? '', + tripType: TripType.fromName((data['trip_type'] as String?) ?? 'shore'), + isShared: (data['is_shared'] as int? ?? 0) != 0, + // Wall-clock-as-UTC: decode with isUtc so the stored components are + // preserved rather than shifted into the device's timezone. + returnFlightAt: data['return_flight_at'] != null + ? DateTime.fromMillisecondsSinceEpoch( + data['return_flight_at'] as int, + isUtc: true, + ) + : null, + createdAt: DateTime.fromMillisecondsSinceEpoch(data['created_at'] as int), + updatedAt: DateTime.fromMillisecondsSinceEpoch(data['updated_at'] as int), + ); + } } diff --git a/lib/features/trips/domain/entities/trip.dart b/lib/features/trips/domain/entities/trip.dart index e17443b81a..47a07acd8d 100644 --- a/lib/features/trips/domain/entities/trip.dart +++ b/lib/features/trips/domain/entities/trip.dart @@ -14,6 +14,9 @@ class Trip extends Equatable { final TripType tripType; final String notes; final bool isShared; + + /// Return flight departure, wall-clock-as-UTC (the dive-time frame). + final DateTime? returnFlightAt; final DateTime createdAt; final DateTime updatedAt; @@ -29,6 +32,7 @@ class Trip extends Equatable { this.tripType = TripType.shore, this.notes = '', this.isShared = false, + this.returnFlightAt, required this.createdAt, required this.updatedAt, }); @@ -102,6 +106,7 @@ class Trip extends Equatable { TripType? tripType, String? notes, bool? isShared, + Object? returnFlightAt = _undefined, DateTime? createdAt, DateTime? updatedAt, }) { @@ -121,6 +126,9 @@ class Trip extends Equatable { tripType: tripType ?? this.tripType, notes: notes ?? this.notes, isShared: isShared ?? this.isShared, + returnFlightAt: returnFlightAt == _undefined + ? this.returnFlightAt + : returnFlightAt as DateTime?, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); @@ -139,6 +147,7 @@ class Trip extends Equatable { tripType, notes, isShared, + returnFlightAt, createdAt, updatedAt, ]; diff --git a/lib/features/trips/presentation/pages/trip_edit_page.dart b/lib/features/trips/presentation/pages/trip_edit_page.dart index 3b7897799f..b8c179f728 100644 --- a/lib/features/trips/presentation/pages/trip_edit_page.dart +++ b/lib/features/trips/presentation/pages/trip_edit_page.dart @@ -53,6 +53,7 @@ class _TripEditPageState extends ConsumerState { LiveaboardDetails? _originalLiveaboardDetails; DateTime _startDate = DateTime.now(); + DateTime? _returnFlightAt; DateTime _endDate = DateTime.now().add(const Duration(days: 7)); bool _isLoading = false; bool _isSaving = false; @@ -127,6 +128,7 @@ class _TripEditPageState extends ConsumerState { setState(() { _startDate = trip.startDate; _endDate = trip.endDate; + _returnFlightAt = trip.returnFlightAt; _isShared = trip.isShared; _isLoading = false; _hasChanges = false; @@ -280,6 +282,34 @@ class _TripEditPageState extends ConsumerState { ), ), ), + // Return flight (optional; drives the no-fly countdown) + Semantics( + button: true, + label: context.l10n.trips_edit_label_returnFlight, + child: ListTile( + leading: const Icon(Icons.flight_land), + title: Text(context.l10n.trips_edit_label_returnFlight), + subtitle: Text( + _returnFlightAt == null + ? context.l10n.trips_edit_returnFlightNotSet + : '${dateFormat.format(_returnFlightAt!)}, ' + '${TimeOfDay.fromDateTime(_returnFlightAt!).format(context)}', + ), + trailing: _returnFlightAt == null + ? const Icon(Icons.edit) + : IconButton( + tooltip: + context.l10n.trips_edit_returnFlightClear, + icon: const Icon(Icons.clear), + onPressed: () => setState(() { + _returnFlightAt = null; + _hasChanges = true; + }), + ), + contentPadding: EdgeInsets.zero, + onTap: _selectReturnFlight, + ), + ), const SizedBox(height: 24), // Location section header @@ -704,6 +734,36 @@ class _TripEditPageState extends ConsumerState { } } + Future _selectReturnFlight() async { + final initial = + _returnFlightAt ?? + DateTime(_endDate.year, _endDate.month, _endDate.day, 12); + final pickedDate = await showAppDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1950), + lastDate: DateTime(2100), + ); + if (pickedDate == null || !mounted) return; + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initial), + ); + if (pickedTime == null || !mounted) return; + setState(() { + // Wall-clock-as-UTC, the same frame as dive times, so the no-fly + // math can compare this directly against dive end times. + _returnFlightAt = DateTime.utc( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + _hasChanges = true; + }); + } + Future _onWillPop() async { if (_hasChanges) { return await _showDiscardDialog() ?? false; @@ -796,6 +856,7 @@ class _TripEditPageState extends ConsumerState { tripType: _tripType, notes: _notesController.text.trim(), isShared: _isShared, + returnFlightAt: _returnFlightAt, createdAt: _originalTrip?.createdAt ?? now, updatedAt: now, ); diff --git a/lib/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart b/lib/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart new file mode 100644 index 0000000000..d9e066d465 --- /dev/null +++ b/lib/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/features/safety/presentation/widgets/flight_window_card.dart'; + +/// Trip story wrapper around [FlightWindowCard]: watches the trip's flight +/// window and re-renders each minute so the countdown stays current. +class TripFlightCountdownCard extends ConsumerStatefulWidget { + final String tripId; + + const TripFlightCountdownCard({super.key, required this.tripId}); + + @override + ConsumerState createState() => + _TripFlightCountdownCardState(); +} + +class _TripFlightCountdownCardState + extends ConsumerState { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final statusAsync = ref.watch(tripFlightWindowProvider(widget.tripId)); + final status = statusAsync.valueOrNull; + if (status == null) return const SizedBox.shrink(); + return FlightWindowCard(status: status); + } +} diff --git a/lib/features/trips/presentation/widgets/story/trip_story_view.dart b/lib/features/trips/presentation/widgets/story/trip_story_view.dart index 77e41850c6..1d682343ed 100644 --- a/lib/features/trips/presentation/widgets/story/trip_story_view.dart +++ b/lib/features/trips/presentation/widgets/story/trip_story_view.dart @@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart'; import 'package:submersion/features/checklists/presentation/widgets/trip_checklist_section.dart'; import 'package:submersion/features/trips/domain/entities/trip.dart'; import 'package:submersion/features/trips/domain/entities/trip_story.dart'; +import 'package:submersion/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart'; import 'package:submersion/features/trips/presentation/widgets/story/trip_story_day_card.dart'; import 'package:submersion/features/trips/presentation/widgets/story/trip_story_day_header.dart'; import 'package:submersion/features/trips/presentation/widgets/story/trip_story_hero.dart'; @@ -279,6 +280,15 @@ class _TripStoryViewState extends ConsumerState ), ), ), + // Return-flight dive-window countdown, shown while the trip is + // underway. The card hides itself once the flight departs. + if (trip.returnFlightAt != null && trip.isInProgress) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: TripFlightCountdownCard(tripId: trip.id), + ), + ), if (trip.isLiveaboard) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 263bc8fbc4..a60da27b77 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "العملة الافتراضية", "settings_units_dialog_defaultCurrency": "العملة الافتراضية", "diveSites_list_menu_select": "تحديد المواقع", + "diveLog_edit_flightWindowWarning": "ينتهي هذا الغوص بعد آخر وقت آمن للصعود إلى السطح قبل رحلتك ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "بالقرب من {location}", "diveLog_edit_geofenceSuggestion_title": "اقتراح المعدات", "diveLog_edit_geofenceSuggestion_body": "تطبيق مجموعة \"{setName}\"؟", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "ملاحظات", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "اسم المنتجع", + "trips_edit_label_returnFlight": "رحلة العودة", + "trips_edit_returnFlightClear": "مسح رحلة العودة", + "trips_edit_returnFlightNotSet": "غير محدد", "trips_edit_label_startDate": "تاريخ البدء", "trips_edit_label_tripName": "اسم الرحلة *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "انتهى التأمين", "dashboard_gauges_noInsurance": "لا يوجد تأمين مسجل", "dashboard_gauges_noFlyClear": "حظر الطيران 0:00", + "dashboard_gauges_flightWindow": "نافذة الغوص {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "لا مزيد من الغوص قبل الرحلة", "dashboard_gauges_noFlyRemaining": "حظر الطيران {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "آخر غطسة منذ {days} يوم", "dashboard_gauges_lastDiveToday": "غطست اليوم", "dashboard_gauges_noDivesYet": "لا توجد غطسات بعد", "settings_homeChips_pageTitle": "شرائح حالة الصفحة الرئيسية", "settings_homeChips_description": "اختر شرائح الحالة التي تظهر أعلى تبويب الرئيسية.", + "settings_homeChips_flightWindow": "نافذة الغوص قبل الرحلة", "settings_homeChips_gear": "صيانة المعدات", "settings_homeChips_insurance": "التأمين", "settings_homeChips_noFly": "مؤقت حظر الطيران", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "قياسي (12/18/24 س)", "safetySettings_noFlyPreset_strict": "صارم (18/24/48 س)", "safetySettings_noFlyPreset_subtitle": "فترات إرشادية بعد غطسة واحدة بلا توقفات، وغطسات متكررة، وغطسات بتخفيف الضغط", + "flightWindow_closed": "لا مزيد من الغوص قبل رحلتك", + "flightWindow_conflict": "تمتد فترة حظر الطيران إلى ما بعد إقلاع رحلتك", + "flightWindow_departs": "تقلع الرحلة {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "الوقت المتبقي للغوص: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "اصعد إلى السطح قبل {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "حظر الطيران: متبقٍ {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 4f4aba776c..fcd356b878 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Standardwährung", "settings_units_dialog_defaultCurrency": "Standardwährung", "diveSites_list_menu_select": "Tauchplätze auswählen", + "diveLog_edit_flightWindowWarning": "Dieser Tauchgang endet nach der letzten sicheren Auftauchzeit für deinen Flug ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "In der Nähe von {location}", "diveLog_edit_geofenceSuggestion_title": "Ausrüstungsvorschlag", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" übernehmen?", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "Notizen", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Resortname", + "trips_edit_label_returnFlight": "Rückflug", + "trips_edit_returnFlightClear": "Rückflug entfernen", + "trips_edit_returnFlightNotSet": "Nicht festgelegt", "trips_edit_label_startDate": "Startdatum", "trips_edit_label_tripName": "Reisename *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Versicherung abgelaufen", "dashboard_gauges_noInsurance": "Keine Versicherung hinterlegt", "dashboard_gauges_noFlyClear": "Flugverbot 0:00", + "dashboard_gauges_flightWindow": "Tauchfenster {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "Kein Tauchen mehr vor dem Flug", "dashboard_gauges_noFlyRemaining": "Flugverbot {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Letzter Tauchgang vor {days}T", "dashboard_gauges_lastDiveToday": "Heute getaucht", "dashboard_gauges_noDivesYet": "Noch keine Tauchgänge", "settings_homeChips_pageTitle": "Status-Chips der Startseite", "settings_homeChips_description": "Wähle, welche Status-Chips oben im Start-Tab erscheinen.", + "settings_homeChips_flightWindow": "Tauchfenster vor dem Flug", "settings_homeChips_gear": "Ausrüstungswartung", "settings_homeChips_insurance": "Versicherung", "settings_homeChips_noFly": "Flugverbots-Timer", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Standard (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Streng (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Richtwerte nach einem einzelnen Nullzeit-Tauchgang, Wiederholungstauchgängen und Deko-Tauchgängen", + "flightWindow_closed": "Kein Tauchen mehr vor deinem Flug", + "flightWindow_conflict": "Deine Flugverbotszeit reicht über deinen Abflug hinaus", + "flightWindow_departs": "Abflug {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Verbleibende Tauchzeit: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Auftauchen bis {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "Flugverbot: noch {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 849f1ec051..77b4f351e0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,12 @@ { + "diveLog_edit_flightWindowWarning": "This dive ends after the latest safe surfacing time for your flight ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Near {location}", "@diveLog_edit_geofenceSuggestion_near": { "placeholders": { @@ -1904,6 +1912,18 @@ "dashboard_gauges_insuranceExpired": "Insurance expired", "dashboard_gauges_noInsurance": "No insurance on file", "dashboard_gauges_noFlyClear": "No-fly 0:00", + "dashboard_gauges_flightWindow": "Dive window {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "No more diving before flight", "dashboard_gauges_noFlyRemaining": "No-fly {hours}:{minutes}", "@dashboard_gauges_noFlyRemaining": { "placeholders": { @@ -1927,6 +1947,7 @@ "dashboard_gauges_noDivesYet": "No dives yet", "settings_homeChips_pageTitle": "Home status chips", "settings_homeChips_description": "Choose which status chips appear at the top of the Home tab.", + "settings_homeChips_flightWindow": "Flight dive window", "settings_homeChips_gear": "Gear service", "settings_homeChips_insurance": "Insurance", "settings_homeChips_noFly": "No-fly timer", @@ -10165,6 +10186,9 @@ "trips_edit_label_location": "Location", "trips_edit_label_notes": "Notes", "trips_edit_label_resortName": "Resort Name", + "trips_edit_label_returnFlight": "Return Flight", + "trips_edit_returnFlightClear": "Clear return flight", + "trips_edit_returnFlightNotSet": "Not set", "trips_edit_label_startDate": "Start Date", "trips_edit_label_tripName": "Trip Name *", "trips_edit_sectionTitle_dates": "Trip Dates", @@ -13915,6 +13939,32 @@ "safetySettings_noFlyPreset_standard": "Standard (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Strict (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Guideline intervals after a single no-deco dive, repetitive dives, and deco dives", + "flightWindow_closed": "No more diving before your flight", + "flightWindow_conflict": "Your no-fly time extends past your flight departure", + "flightWindow_departs": "Flight departs {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Time left to dive: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Surface by {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "No-fly: {remaining} remaining", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index d3e20b4222..1c1d18cc77 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Moneda predeterminada", "settings_units_dialog_defaultCurrency": "Moneda predeterminada", "diveSites_list_menu_select": "Seleccionar puntos", + "diveLog_edit_flightWindowWarning": "Esta inmersión termina después de la última hora segura para emerger antes de tu vuelo ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Cerca de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugerencia de equipo", "diveLog_edit_geofenceSuggestion_body": "¿Aplicar tu conjunto \"{setName}\"?", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "Notas", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Nombre del resort", + "trips_edit_label_returnFlight": "Vuelo de regreso", + "trips_edit_returnFlightClear": "Borrar vuelo de regreso", + "trips_edit_returnFlightNotSet": "Sin definir", "trips_edit_label_startDate": "Fecha de inicio", "trips_edit_label_tripName": "Nombre del viaje *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Seguro vencido", "dashboard_gauges_noInsurance": "Sin seguro registrado", "dashboard_gauges_noFlyClear": "No volar 0:00", + "dashboard_gauges_flightWindow": "Ventana de buceo {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "No más buceo antes del vuelo", "dashboard_gauges_noFlyRemaining": "No volar {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Última inmersión hace {days}d", "dashboard_gauges_lastDiveToday": "Buceaste hoy", "dashboard_gauges_noDivesYet": "Aún sin inmersiones", "settings_homeChips_pageTitle": "Chips de estado de Inicio", "settings_homeChips_description": "Elige qué chips de estado aparecen en la parte superior de la pestaña Inicio.", + "settings_homeChips_flightWindow": "Ventana de buceo antes del vuelo", "settings_homeChips_gear": "Mantenimiento del equipo", "settings_homeChips_insurance": "Seguro", "settings_homeChips_noFly": "Temporizador de no volar", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Estándar (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Estricto (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Intervalos orientativos tras una única inmersión sin deco, inmersiones sucesivas e inmersiones con deco", + "flightWindow_closed": "No bucees más antes de tu vuelo", + "flightWindow_conflict": "Tu tiempo de no volar se extiende más allá de la salida del vuelo", + "flightWindow_departs": "El vuelo sale {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Tiempo restante para bucear: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Emerger antes de {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "No volar: quedan {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 02c36050bc..c19419e27b 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Devise par défaut", "settings_units_dialog_defaultCurrency": "Devise par défaut", "diveSites_list_menu_select": "Sélectionner des sites", + "diveLog_edit_flightWindowWarning": "Cette plongée se termine après l'heure limite de remontée pour votre vol ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Près de {location}", "diveLog_edit_geofenceSuggestion_title": "Suggestion d'équipement", "diveLog_edit_geofenceSuggestion_body": "Appliquer l'ensemble \"{setName}\" ?", @@ -5258,6 +5266,9 @@ "trips_edit_label_notes": "Notes", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Nom du resort", + "trips_edit_label_returnFlight": "Vol retour", + "trips_edit_returnFlightClear": "Effacer le vol retour", + "trips_edit_returnFlightNotSet": "Non défini", "trips_edit_label_startDate": "Date de debut", "trips_edit_label_tripName": "Nom du voyage *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Assurance expirée", "dashboard_gauges_noInsurance": "Aucune assurance enregistrée", "dashboard_gauges_noFlyClear": "Délai avant vol 0:00", + "dashboard_gauges_flightWindow": "Fenêtre de plongée {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "Plus de plongée avant le vol", "dashboard_gauges_noFlyRemaining": "Délai avant vol {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Dernière plongée il y a {days}j", "dashboard_gauges_lastDiveToday": "Plongé aujourd'hui", "dashboard_gauges_noDivesYet": "Aucune plongée", "settings_homeChips_pageTitle": "Pastilles d'état de l'accueil", "settings_homeChips_description": "Choisissez les pastilles d'état affichées en haut de l'onglet Accueil.", + "settings_homeChips_flightWindow": "Fenêtre de plongée avant vol", "settings_homeChips_gear": "Entretien du matériel", "settings_homeChips_insurance": "Assurance", "settings_homeChips_noFly": "Délai avant vol", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Standard (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Strict (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Intervalles indicatifs après une plongée unique sans déco, des plongées successives et des plongées avec déco", + "flightWindow_closed": "Plus de plongée avant votre vol", + "flightWindow_conflict": "Votre délai avant vol dépasse le départ de votre vol", + "flightWindow_departs": "Le vol part {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Temps de plongée restant : {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Remonter avant {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "Interdiction de vol : {remaining} restant", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 3c36d8ef0f..7d6d3f0b9b 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "מטבע ברירת מחדל", "settings_units_dialog_defaultCurrency": "מטבע ברירת מחדל", "diveSites_list_menu_select": "בחירת אתרים", + "diveLog_edit_flightWindowWarning": "הצלילה הזו מסתיימת אחרי הזמן הבטוח האחרון לעלייה לפני הטיסה שלך ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "ליד {location}", "diveLog_edit_geofenceSuggestion_title": "הצעת ציוד", "diveLog_edit_geofenceSuggestion_body": "להחיל את ערכת \"{setName}\"?", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "הערות", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "שם אתר הנופש", + "trips_edit_label_returnFlight": "טיסת חזרה", + "trips_edit_returnFlightClear": "נקה טיסת חזרה", + "trips_edit_returnFlightNotSet": "לא הוגדר", "trips_edit_label_startDate": "תאריך התחלה", "trips_edit_label_tripName": "שם הטיול *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "הביטוח פג תוקף", "dashboard_gauges_noInsurance": "אין ביטוח רשום", "dashboard_gauges_noFlyClear": "איסור טיסה 0:00", + "dashboard_gauges_flightWindow": "חלון צלילה {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "אין יותר צלילות לפני הטיסה", "dashboard_gauges_noFlyRemaining": "איסור טיסה {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "צלילה אחרונה לפני {days} ימים", "dashboard_gauges_lastDiveToday": "צללת היום", "dashboard_gauges_noDivesYet": "אין צלילות עדיין", "settings_homeChips_pageTitle": "שבבי מצב של דף הבית", "settings_homeChips_description": "בחר אילו שבבי מצב יופיעו בראש לשונית הבית.", + "settings_homeChips_flightWindow": "חלון צלילה לפני טיסה", "settings_homeChips_gear": "תחזוקת ציוד", "settings_homeChips_insurance": "ביטוח", "settings_homeChips_noFly": "טיימר איסור טיסה", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "רגיל (12/18/24 ש')", "safetySettings_noFlyPreset_strict": "מחמיר (18/24/48 ש')", "safetySettings_noFlyPreset_subtitle": "מרווחים מנחים אחרי צלילה בודדת ללא דקו, צלילות חוזרות וצלילות דקומפרסיה", + "flightWindow_closed": "אין יותר צלילות לפני הטיסה", + "flightWindow_conflict": "זמן איסור הטיסה שלך נמשך מעבר להמראה", + "flightWindow_departs": "הטיסה ממריאה {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "זמן צלילה שנותר: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "לעלות אל פני השטח עד {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "איסור טיסה: נותרו {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index d45b7de6e5..7dfbd30a96 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Alapértelmezett pénznem", "settings_units_dialog_defaultCurrency": "Alapértelmezett pénznem", "diveSites_list_menu_select": "Merülőhelyek kiválasztása", + "diveLog_edit_flightWindowWarning": "Ez a merülés a járatod előtti utolsó biztonságos felszínre érési idő után ér véget ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "{location} közelében", "diveLog_edit_geofenceSuggestion_title": "Felszerelési javaslat", "diveLog_edit_geofenceSuggestion_body": "Alkalmazza a(z) \"{setName}\" készletet?", @@ -5258,6 +5266,9 @@ "trips_edit_label_notes": "Jegyzetek", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Udulohely neve", + "trips_edit_label_returnFlight": "Visszaúti járat", + "trips_edit_returnFlightClear": "Visszaúti járat törlése", + "trips_edit_returnFlightNotSet": "Nincs megadva", "trips_edit_label_startDate": "Kezdes datuma", "trips_edit_label_tripName": "Ut neve *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "A biztosítás lejárt", "dashboard_gauges_noInsurance": "Nincs rögzített biztosítás", "dashboard_gauges_noFlyClear": "Repülési tilalom 0:00", + "dashboard_gauges_flightWindow": "Merülési ablak {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "A repülés előtt már nincs merülés", "dashboard_gauges_noFlyRemaining": "Repülési tilalom {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Utolsó merülés {days} napja", "dashboard_gauges_lastDiveToday": "Ma merültél", "dashboard_gauges_noDivesYet": "Még nincs merülés", "settings_homeChips_pageTitle": "Kezdőlap állapotjelzői", "settings_homeChips_description": "Válaszd ki, mely állapotjelzők jelenjenek meg a Kezdőlap tetején.", + "settings_homeChips_flightWindow": "Merülési ablak repülés előtt", "settings_homeChips_gear": "Felszerelés szervize", "settings_homeChips_insurance": "Biztosítás", "settings_homeChips_noFly": "Repülési tilalom időzítő", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Normál (12/18/24 ó)", "safetySettings_noFlyPreset_strict": "Szigorú (18/24/48 ó)", "safetySettings_noFlyPreset_subtitle": "Irányadó időközök egyetlen nullidős merülés, ismétlő merülések és dekós merülések után", + "flightWindow_closed": "A repülés előtt már ne merülj", + "flightWindow_conflict": "A repülési tilalmad túlnyúlik a járat indulásán", + "flightWindow_departs": "A járat indul: {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Hátralévő merülési idő: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Felszínre érés eddig: {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "Repülési tilalom: {remaining} van hátra", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 3ebe61894a..10d38bb198 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Valuta predefinita", "settings_units_dialog_defaultCurrency": "Valuta predefinita", "diveSites_list_menu_select": "Seleziona siti", + "diveLog_edit_flightWindowWarning": "Questa immersione termina dopo l'ultimo orario sicuro di riemersione per il tuo volo ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Vicino a {location}", "diveLog_edit_geofenceSuggestion_title": "Suggerimento attrezzatura", "diveLog_edit_geofenceSuggestion_body": "Applicare il set \"{setName}\"?", @@ -5254,6 +5262,9 @@ "trips_edit_label_notes": "Note", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Nome resort", + "trips_edit_label_returnFlight": "Volo di ritorno", + "trips_edit_returnFlightClear": "Rimuovi volo di ritorno", + "trips_edit_returnFlightNotSet": "Non impostato", "trips_edit_label_startDate": "Data di inizio", "trips_edit_label_tripName": "Nome viaggio *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Assicurazione scaduta", "dashboard_gauges_noInsurance": "Nessuna assicurazione registrata", "dashboard_gauges_noFlyClear": "No-fly 0:00", + "dashboard_gauges_flightWindow": "Finestra di immersione {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "Niente più immersioni prima del volo", "dashboard_gauges_noFlyRemaining": "No-fly {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Ultima immersione {days}g fa", "dashboard_gauges_lastDiveToday": "Immersione oggi", "dashboard_gauges_noDivesYet": "Nessuna immersione", "settings_homeChips_pageTitle": "Chip di stato della Home", "settings_homeChips_description": "Scegli quali chip di stato compaiono in cima alla scheda Home.", + "settings_homeChips_flightWindow": "Finestra di immersione pre-volo", "settings_homeChips_gear": "Manutenzione attrezzatura", "settings_homeChips_insurance": "Assicurazione", "settings_homeChips_noFly": "Timer no-fly", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Standard (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Rigoroso (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Intervalli indicativi dopo una singola immersione senza deco, immersioni ripetitive e immersioni con deco", + "flightWindow_closed": "Niente più immersioni prima del volo", + "flightWindow_conflict": "Il tuo tempo di no-fly si estende oltre la partenza del volo", + "flightWindow_departs": "Il volo parte {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Tempo di immersione rimanente: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Riemergere entro {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "No-fly: mancano {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 5b0c3dd284..21c6550c56 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -116,6 +116,12 @@ abstract class AppLocalizations { Locale('zh'), ]; + /// No description provided for @diveLog_edit_flightWindowWarning. + /// + /// In en, this message translates to: + /// **'This dive ends after the latest safe surfacing time for your flight ({time})'** + String diveLog_edit_flightWindowWarning(String time); + /// No description provided for @diveLog_edit_geofenceSuggestion_near. /// /// In en, this message translates to: @@ -5291,6 +5297,18 @@ abstract class AppLocalizations { /// **'No-fly 0:00'** String get dashboard_gauges_noFlyClear; + /// No description provided for @dashboard_gauges_flightWindow. + /// + /// In en, this message translates to: + /// **'Dive window {hours}:{minutes}'** + String dashboard_gauges_flightWindow(String hours, String minutes); + + /// No description provided for @dashboard_gauges_flightWindowClosed. + /// + /// In en, this message translates to: + /// **'No more diving before flight'** + String get dashboard_gauges_flightWindowClosed; + /// No description provided for @dashboard_gauges_noFlyRemaining. /// /// In en, this message translates to: @@ -5327,6 +5345,12 @@ abstract class AppLocalizations { /// **'Choose which status chips appear at the top of the Home tab.'** String get settings_homeChips_description; + /// No description provided for @settings_homeChips_flightWindow. + /// + /// In en, this message translates to: + /// **'Flight dive window'** + String get settings_homeChips_flightWindow; + /// No description provided for @settings_homeChips_gear. /// /// In en, this message translates to: @@ -29464,6 +29488,24 @@ abstract class AppLocalizations { /// **'Resort Name'** String get trips_edit_label_resortName; + /// No description provided for @trips_edit_label_returnFlight. + /// + /// In en, this message translates to: + /// **'Return Flight'** + String get trips_edit_label_returnFlight; + + /// No description provided for @trips_edit_returnFlightClear. + /// + /// In en, this message translates to: + /// **'Clear return flight'** + String get trips_edit_returnFlightClear; + + /// No description provided for @trips_edit_returnFlightNotSet. + /// + /// In en, this message translates to: + /// **'Not set'** + String get trips_edit_returnFlightNotSet; + /// No description provided for @trips_edit_label_startDate. /// /// In en, this message translates to: @@ -37012,6 +37054,36 @@ abstract class AppLocalizations { /// **'Guideline intervals after a single no-deco dive, repetitive dives, and deco dives'** String get safetySettings_noFlyPreset_subtitle; + /// No description provided for @flightWindow_closed. + /// + /// In en, this message translates to: + /// **'No more diving before your flight'** + String get flightWindow_closed; + + /// No description provided for @flightWindow_conflict. + /// + /// In en, this message translates to: + /// **'Your no-fly time extends past your flight departure'** + String get flightWindow_conflict; + + /// No description provided for @flightWindow_departs. + /// + /// In en, this message translates to: + /// **'Flight departs {time}'** + String flightWindow_departs(String time); + + /// No description provided for @flightWindow_openTitle. + /// + /// In en, this message translates to: + /// **'Time left to dive: {remaining}'** + String flightWindow_openTitle(String remaining); + + /// No description provided for @flightWindow_surfaceBy. + /// + /// In en, this message translates to: + /// **'Surface by {time}'** + String flightWindow_surfaceBy(String time); + /// No description provided for @safetyHub_noFly_active_title. /// /// 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 7deca1f92d..e64a2a4f78 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'ينتهي هذا الغوص بعد آخر وقت آمن للصعود إلى السطح قبل رحلتك ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'بالقرب من $location'; @@ -3047,6 +3052,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'حظر الطيران 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'نافذة الغوص $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'لا مزيد من الغوص قبل الرحلة'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'حظر الطيران $hours:$minutes'; @@ -3070,6 +3084,9 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_homeChips_description => 'اختر شرائح الحالة التي تظهر أعلى تبويب الرئيسية.'; + @override + String get settings_homeChips_flightWindow => 'نافذة الغوص قبل الرحلة'; + @override String get settings_homeChips_gear => 'صيانة المعدات'; @@ -17254,6 +17271,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get trips_edit_label_resortName => 'اسم المنتجع'; + @override + String get trips_edit_label_returnFlight => 'رحلة العودة'; + + @override + String get trips_edit_returnFlightClear => 'مسح رحلة العودة'; + + @override + String get trips_edit_returnFlightNotSet => 'غير محدد'; + @override String get trips_edit_label_startDate => 'تاريخ البدء'; @@ -21743,6 +21769,28 @@ class AppLocalizationsAr extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'فترات إرشادية بعد غطسة واحدة بلا توقفات، وغطسات متكررة، وغطسات بتخفيف الضغط'; + @override + String get flightWindow_closed => 'لا مزيد من الغوص قبل رحلتك'; + + @override + String get flightWindow_conflict => + 'تمتد فترة حظر الطيران إلى ما بعد إقلاع رحلتك'; + + @override + String flightWindow_departs(String time) { + return 'تقلع الرحلة $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'الوقت المتبقي للغوص: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'اصعد إلى السطح قبل $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'حظر الطيران: متبقٍ $remaining'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 40ec107d40..b90c71d1af 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Dieser Tauchgang endet nach der letzten sicheren Auftauchzeit für deinen Flug ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'In der Nähe von $location'; @@ -3122,6 +3127,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'Flugverbot 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Tauchfenster $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'Kein Tauchen mehr vor dem Flug'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'Flugverbot $hours:$minutes'; @@ -3145,6 +3159,9 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_homeChips_description => 'Wähle, welche Status-Chips oben im Start-Tab erscheinen.'; + @override + String get settings_homeChips_flightWindow => 'Tauchfenster vor dem Flug'; + @override String get settings_homeChips_gear => 'Ausrüstungswartung'; @@ -17544,6 +17561,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trips_edit_label_resortName => 'Resortname'; + @override + String get trips_edit_label_returnFlight => 'Rückflug'; + + @override + String get trips_edit_returnFlightClear => 'Rückflug entfernen'; + + @override + String get trips_edit_returnFlightNotSet => 'Nicht festgelegt'; + @override String get trips_edit_label_startDate => 'Startdatum'; @@ -22103,6 +22129,28 @@ class AppLocalizationsDe extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Richtwerte nach einem einzelnen Nullzeit-Tauchgang, Wiederholungstauchgängen und Deko-Tauchgängen'; + @override + String get flightWindow_closed => 'Kein Tauchen mehr vor deinem Flug'; + + @override + String get flightWindow_conflict => + 'Deine Flugverbotszeit reicht über deinen Abflug hinaus'; + + @override + String flightWindow_departs(String time) { + return 'Abflug $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Verbleibende Tauchzeit: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Auftauchen bis $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'Flugverbot: noch $remaining'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index d25ae5ec32..beda17244d 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'This dive ends after the latest safe surfacing time for your flight ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Near $location'; @@ -3055,6 +3060,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'No-fly 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Dive window $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'No more diving before flight'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'No-fly $hours:$minutes'; @@ -3078,6 +3092,9 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_homeChips_description => 'Choose which status chips appear at the top of the Home tab.'; + @override + String get settings_homeChips_flightWindow => 'Flight dive window'; + @override String get settings_homeChips_gear => 'Gear service'; @@ -17274,6 +17291,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trips_edit_label_resortName => 'Resort Name'; + @override + String get trips_edit_label_returnFlight => 'Return Flight'; + + @override + String get trips_edit_returnFlightClear => 'Clear return flight'; + + @override + String get trips_edit_returnFlightNotSet => 'Not set'; + @override String get trips_edit_label_startDate => 'Start Date'; @@ -21770,6 +21796,28 @@ class AppLocalizationsEn extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Guideline intervals after a single no-deco dive, repetitive dives, and deco dives'; + @override + String get flightWindow_closed => 'No more diving before your flight'; + + @override + String get flightWindow_conflict => + 'Your no-fly time extends past your flight departure'; + + @override + String flightWindow_departs(String time) { + return 'Flight departs $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Time left to dive: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Surface by $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'No-fly: $remaining remaining'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 476ba441c4..94ae6e28eb 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Esta inmersión termina después de la última hora segura para emerger antes de tu vuelo ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Cerca de $location'; @@ -3115,6 +3120,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'No volar 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Ventana de buceo $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'No más buceo antes del vuelo'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'No volar $hours:$minutes'; @@ -3138,6 +3152,10 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_homeChips_description => 'Elige qué chips de estado aparecen en la parte superior de la pestaña Inicio.'; + @override + String get settings_homeChips_flightWindow => + 'Ventana de buceo antes del vuelo'; + @override String get settings_homeChips_gear => 'Mantenimiento del equipo'; @@ -17590,6 +17608,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trips_edit_label_resortName => 'Nombre del resort'; + @override + String get trips_edit_label_returnFlight => 'Vuelo de regreso'; + + @override + String get trips_edit_returnFlightClear => 'Borrar vuelo de regreso'; + + @override + String get trips_edit_returnFlightNotSet => 'Sin definir'; + @override String get trips_edit_label_startDate => 'Fecha de inicio'; @@ -22155,6 +22182,28 @@ class AppLocalizationsEs extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Intervalos orientativos tras una única inmersión sin deco, inmersiones sucesivas e inmersiones con deco'; + @override + String get flightWindow_closed => 'No bucees más antes de tu vuelo'; + + @override + String get flightWindow_conflict => + 'Tu tiempo de no volar se extiende más allá de la salida del vuelo'; + + @override + String flightWindow_departs(String time) { + return 'El vuelo sale $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Tiempo restante para bucear: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Emerger antes de $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'No volar: quedan $remaining'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index e93e9ffe00..139d0ea787 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Cette plongée se termine après l\'heure limite de remontée pour votre vol ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Près de $location'; @@ -3125,6 +3130,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'Délai avant vol 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Fenêtre de plongée $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'Plus de plongée avant le vol'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'Délai avant vol $hours:$minutes'; @@ -3148,6 +3162,9 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_homeChips_description => 'Choisissez les pastilles d\'état affichées en haut de l\'onglet Accueil.'; + @override + String get settings_homeChips_flightWindow => 'Fenêtre de plongée avant vol'; + @override String get settings_homeChips_gear => 'Entretien du matériel'; @@ -17646,6 +17663,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trips_edit_label_resortName => 'Nom du resort'; + @override + String get trips_edit_label_returnFlight => 'Vol retour'; + + @override + String get trips_edit_returnFlightClear => 'Effacer le vol retour'; + + @override + String get trips_edit_returnFlightNotSet => 'Non défini'; + @override String get trips_edit_label_startDate => 'Date de debut'; @@ -22211,6 +22237,28 @@ class AppLocalizationsFr extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Intervalles indicatifs après une plongée unique sans déco, des plongées successives et des plongées avec déco'; + @override + String get flightWindow_closed => 'Plus de plongée avant votre vol'; + + @override + String get flightWindow_conflict => + 'Votre délai avant vol dépasse le départ de votre vol'; + + @override + String flightWindow_departs(String time) { + return 'Le vol part $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Temps de plongée restant : $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Remonter avant $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'Interdiction de vol : $remaining restant'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index f8ad147c6e..61474433f7 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsHe extends AppLocalizations { AppLocalizationsHe([String locale = 'he']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'הצלילה הזו מסתיימת אחרי הזמן הבטוח האחרון לעלייה לפני הטיסה שלך ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'ליד $location'; @@ -3024,6 +3029,15 @@ class AppLocalizationsHe extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'איסור טיסה 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'חלון צלילה $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'אין יותר צלילות לפני הטיסה'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'איסור טיסה $hours:$minutes'; @@ -3047,6 +3061,9 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_homeChips_description => 'בחר אילו שבבי מצב יופיעו בראש לשונית הבית.'; + @override + String get settings_homeChips_flightWindow => 'חלון צלילה לפני טיסה'; + @override String get settings_homeChips_gear => 'תחזוקת ציוד'; @@ -17125,6 +17142,15 @@ class AppLocalizationsHe extends AppLocalizations { @override String get trips_edit_label_resortName => 'שם אתר הנופש'; + @override + String get trips_edit_label_returnFlight => 'טיסת חזרה'; + + @override + String get trips_edit_returnFlightClear => 'נקה טיסת חזרה'; + + @override + String get trips_edit_returnFlightNotSet => 'לא הוגדר'; + @override String get trips_edit_label_startDate => 'תאריך התחלה'; @@ -21590,6 +21616,27 @@ class AppLocalizationsHe extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'מרווחים מנחים אחרי צלילה בודדת ללא דקו, צלילות חוזרות וצלילות דקומפרסיה'; + @override + String get flightWindow_closed => 'אין יותר צלילות לפני הטיסה'; + + @override + String get flightWindow_conflict => 'זמן איסור הטיסה שלך נמשך מעבר להמראה'; + + @override + String flightWindow_departs(String time) { + return 'הטיסה ממריאה $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'זמן צלילה שנותר: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'לעלות אל פני השטח עד $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'איסור טיסה: נותרו $remaining'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 09322e81e1..5cf42beacd 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsHu extends AppLocalizations { AppLocalizationsHu([String locale = 'hu']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Ez a merülés a járatod előtti utolsó biztonságos felszínre érési idő után ér véget ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return '$location közelében'; @@ -3097,6 +3102,15 @@ class AppLocalizationsHu extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'Repülési tilalom 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Merülési ablak $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'A repülés előtt már nincs merülés'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'Repülési tilalom $hours:$minutes'; @@ -3120,6 +3134,9 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_homeChips_description => 'Válaszd ki, mely állapotjelzők jelenjenek meg a Kezdőlap tetején.'; + @override + String get settings_homeChips_flightWindow => 'Merülési ablak repülés előtt'; + @override String get settings_homeChips_gear => 'Felszerelés szervize'; @@ -17521,6 +17538,15 @@ class AppLocalizationsHu extends AppLocalizations { @override String get trips_edit_label_resortName => 'Udulohely neve'; + @override + String get trips_edit_label_returnFlight => 'Visszaúti járat'; + + @override + String get trips_edit_returnFlightClear => 'Visszaúti járat törlése'; + + @override + String get trips_edit_returnFlightNotSet => 'Nincs megadva'; + @override String get trips_edit_label_startDate => 'Kezdes datuma'; @@ -22064,6 +22090,28 @@ class AppLocalizationsHu extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Irányadó időközök egyetlen nullidős merülés, ismétlő merülések és dekós merülések után'; + @override + String get flightWindow_closed => 'A repülés előtt már ne merülj'; + + @override + String get flightWindow_conflict => + 'A repülési tilalmad túlnyúlik a járat indulásán'; + + @override + String flightWindow_departs(String time) { + return 'A járat indul: $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Hátralévő merülési idő: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Felszínre érés eddig: $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'Repülési tilalom: $remaining van hátra'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index ab5a2b8b44..09b7e9eeb5 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Questa immersione termina dopo l\'ultimo orario sicuro di riemersione per il tuo volo ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Vicino a $location'; @@ -3110,6 +3115,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'No-fly 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Finestra di immersione $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'Niente più immersioni prima del volo'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'No-fly $hours:$minutes'; @@ -3133,6 +3147,10 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_homeChips_description => 'Scegli quali chip di stato compaiono in cima alla scheda Home.'; + @override + String get settings_homeChips_flightWindow => + 'Finestra di immersione pre-volo'; + @override String get settings_homeChips_gear => 'Manutenzione attrezzatura'; @@ -17575,6 +17593,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get trips_edit_label_resortName => 'Nome resort'; + @override + String get trips_edit_label_returnFlight => 'Volo di ritorno'; + + @override + String get trips_edit_returnFlightClear => 'Rimuovi volo di ritorno'; + + @override + String get trips_edit_returnFlightNotSet => 'Non impostato'; + @override String get trips_edit_label_startDate => 'Data di inizio'; @@ -22137,6 +22164,28 @@ class AppLocalizationsIt extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Intervalli indicativi dopo una singola immersione senza deco, immersioni ripetitive e immersioni con deco'; + @override + String get flightWindow_closed => 'Niente più immersioni prima del volo'; + + @override + String get flightWindow_conflict => + 'Il tuo tempo di no-fly si estende oltre la partenza del volo'; + + @override + String flightWindow_departs(String time) { + return 'Il volo parte $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Tempo di immersione rimanente: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Riemergere entro $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'No-fly: mancano $remaining'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 75b6b5183f..892d613742 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsNl extends AppLocalizations { AppLocalizationsNl([String locale = 'nl']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Deze duik eindigt na het laatste veilige opstijgmoment voor je vlucht ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Bij $location'; @@ -3089,6 +3094,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'Vliegverbod 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Duikvenster $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'Niet meer duiken vóór de vlucht'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'Vliegverbod $hours:$minutes'; @@ -3112,6 +3126,9 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_homeChips_description => 'Kies welke statuschips bovenaan het tabblad Start verschijnen.'; + @override + String get settings_homeChips_flightWindow => 'Duikvenster voor vlucht'; + @override String get settings_homeChips_gear => 'Uitrustingsonderhoud'; @@ -17428,6 +17445,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trips_edit_label_resortName => 'Resortnaam'; + @override + String get trips_edit_label_returnFlight => 'Terugvlucht'; + + @override + String get trips_edit_returnFlightClear => 'Terugvlucht wissen'; + + @override + String get trips_edit_returnFlightNotSet => 'Niet ingesteld'; + @override String get trips_edit_label_startDate => 'Startdatum'; @@ -21968,6 +21994,28 @@ class AppLocalizationsNl extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Richttijden na een enkele duik zonder deco, herhalingsduiken en decoduiken'; + @override + String get flightWindow_closed => 'Niet meer duiken vóór je vlucht'; + + @override + String get flightWindow_conflict => + 'Je no-flytijd loopt door tot na je vertrek'; + + @override + String flightWindow_departs(String time) { + return 'Vlucht vertrekt $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Resterende duiktijd: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Boven water vóór $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'Vliegverbod: nog $remaining'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index f24f89b4da..2e8245dd09 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return 'Este mergulho termina depois do último horário seguro para emergir antes do seu voo ($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return 'Perto de $location'; @@ -3116,6 +3121,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => 'Não voar 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return 'Janela de mergulho $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => + 'Não mergulhe mais antes do voo'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return 'Não voar $hours:$minutes'; @@ -3139,6 +3153,10 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_homeChips_description => 'Escolha que chips de estado aparecem no topo do separador Início.'; + @override + String get settings_homeChips_flightWindow => + 'Janela de mergulho antes do voo'; + @override String get settings_homeChips_gear => 'Manutenção do equipamento'; @@ -17584,6 +17602,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trips_edit_label_resortName => 'Nome do Resort'; + @override + String get trips_edit_label_returnFlight => 'Voo de volta'; + + @override + String get trips_edit_returnFlightClear => 'Limpar voo de volta'; + + @override + String get trips_edit_returnFlightNotSet => 'Não definido'; + @override String get trips_edit_label_startDate => 'Data de Inicio'; @@ -22142,6 +22169,28 @@ class AppLocalizationsPt extends AppLocalizations { String get safetySettings_noFlyPreset_subtitle => 'Intervalos orientativos após um único mergulho sem deco, mergulhos repetitivos e mergulhos com deco'; + @override + String get flightWindow_closed => 'Não mergulhe mais antes do seu voo'; + + @override + String get flightWindow_conflict => + 'Seu tempo de não voar ultrapassa a partida do voo'; + + @override + String flightWindow_departs(String time) { + return 'O voo parte $time'; + } + + @override + String flightWindow_openTitle(String remaining) { + return 'Tempo restante para mergulhar: $remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return 'Emergir até $time'; + } + @override String safetyHub_noFly_active_title(String remaining) { return 'Não voar: faltam $remaining'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 2ef67642d0..eaaad24850 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -8,6 +8,11 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String diveLog_edit_flightWindowWarning(String time) { + return '此次潜水的结束时间晚于您航班的最后安全出水时间($time)'; + } + @override String diveLog_edit_geofenceSuggestion_near(String location) { return '靠近 $location'; @@ -2945,6 +2950,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get dashboard_gauges_noFlyClear => '禁飞 0:00'; + @override + String dashboard_gauges_flightWindow(String hours, String minutes) { + return '潜水窗口 $hours:$minutes'; + } + + @override + String get dashboard_gauges_flightWindowClosed => '航班前请勿再潜水'; + @override String dashboard_gauges_noFlyRemaining(String hours, String minutes) { return '禁飞 $hours:$minutes'; @@ -2967,6 +2980,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_homeChips_description => '选择主页顶部显示哪些状态标签。'; + @override + String get settings_homeChips_flightWindow => '航班前潜水窗口'; + @override String get settings_homeChips_gear => '装备保养'; @@ -16677,6 +16693,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trips_edit_label_resortName => '度假村名称'; + @override + String get trips_edit_label_returnFlight => '返程航班'; + + @override + String get trips_edit_returnFlightClear => '清除返程航班'; + + @override + String get trips_edit_returnFlightNotSet => '未设置'; + @override String get trips_edit_label_startDate => '开始日期'; @@ -21028,6 +21053,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get safetySettings_noFlyPreset_subtitle => '单次免减压潜水、重复潜水和减压潜水后的指导间隔'; + @override + String get flightWindow_closed => '航班前请勿再潜水'; + + @override + String get flightWindow_conflict => '您的禁飞时间超过了航班起飞时间'; + + @override + String flightWindow_departs(String time) { + return '航班 $time 起飞'; + } + + @override + String flightWindow_openTitle(String remaining) { + return '剩余潜水时间:$remaining'; + } + + @override + String flightWindow_surfaceBy(String time) { + return '请在 $time 前出水'; + } + @override String safetyHub_noFly_active_title(String remaining) { return '禁飞:剩余 $remaining'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index e008e9af7e..72db7b94b9 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Standaardvaluta", "settings_units_dialog_defaultCurrency": "Standaardvaluta", "diveSites_list_menu_select": "Duikstekken selecteren", + "diveLog_edit_flightWindowWarning": "Deze duik eindigt na het laatste veilige opstijgmoment voor je vlucht ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Bij {location}", "diveLog_edit_geofenceSuggestion_title": "Uitrustingssuggestie", "diveLog_edit_geofenceSuggestion_body": "Set \"{setName}\" toepassen?", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "Notities", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Resortnaam", + "trips_edit_label_returnFlight": "Terugvlucht", + "trips_edit_returnFlightClear": "Terugvlucht wissen", + "trips_edit_returnFlightNotSet": "Niet ingesteld", "trips_edit_label_startDate": "Startdatum", "trips_edit_label_tripName": "Reisnaam *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Verzekering verlopen", "dashboard_gauges_noInsurance": "Geen verzekering geregistreerd", "dashboard_gauges_noFlyClear": "Vliegverbod 0:00", + "dashboard_gauges_flightWindow": "Duikvenster {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "Niet meer duiken vóór de vlucht", "dashboard_gauges_noFlyRemaining": "Vliegverbod {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Laatste duik {days}d geleden", "dashboard_gauges_lastDiveToday": "Vandaag gedoken", "dashboard_gauges_noDivesYet": "Nog geen duiken", "settings_homeChips_pageTitle": "Statuschips van start", "settings_homeChips_description": "Kies welke statuschips bovenaan het tabblad Start verschijnen.", + "settings_homeChips_flightWindow": "Duikvenster voor vlucht", "settings_homeChips_gear": "Uitrustingsonderhoud", "settings_homeChips_insurance": "Verzekering", "settings_homeChips_noFly": "Vliegverbod-timer", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Standaard (12/18/24 u)", "safetySettings_noFlyPreset_strict": "Strikt (18/24/48 u)", "safetySettings_noFlyPreset_subtitle": "Richttijden na een enkele duik zonder deco, herhalingsduiken en decoduiken", + "flightWindow_closed": "Niet meer duiken vóór je vlucht", + "flightWindow_conflict": "Je no-flytijd loopt door tot na je vertrek", + "flightWindow_departs": "Vlucht vertrekt {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Resterende duiktijd: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Boven water vóór {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "Vliegverbod: nog {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index f1bd066b23..d7ac04acf9 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "Moeda padrão", "settings_units_dialog_defaultCurrency": "Moeda padrão", "diveSites_list_menu_select": "Selecionar pontos", + "diveLog_edit_flightWindowWarning": "Este mergulho termina depois do último horário seguro para emergir antes do seu voo ({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "Perto de {location}", "diveLog_edit_geofenceSuggestion_title": "Sugestão de equipamento", "diveLog_edit_geofenceSuggestion_body": "Aplicar o conjunto \"{setName}\"?", @@ -5330,6 +5338,9 @@ "trips_edit_label_notes": "Notas", "trips_edit_label_operatorName": "Operator / Charter", "trips_edit_label_resortName": "Nome do Resort", + "trips_edit_label_returnFlight": "Voo de volta", + "trips_edit_returnFlightClear": "Limpar voo de volta", + "trips_edit_returnFlightNotSet": "Não definido", "trips_edit_label_startDate": "Data de Inicio", "trips_edit_label_tripName": "Nome da Viagem *", "trips_edit_label_tripType": "Trip Type", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "Seguro expirado", "dashboard_gauges_noInsurance": "Sem seguro registado", "dashboard_gauges_noFlyClear": "Não voar 0:00", + "dashboard_gauges_flightWindow": "Janela de mergulho {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "Não mergulhe mais antes do voo", "dashboard_gauges_noFlyRemaining": "Não voar {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "Último mergulho há {days}d", "dashboard_gauges_lastDiveToday": "Mergulhou hoje", "dashboard_gauges_noDivesYet": "Ainda sem mergulhos", "settings_homeChips_pageTitle": "Chips de estado do Início", "settings_homeChips_description": "Escolha que chips de estado aparecem no topo do separador Início.", + "settings_homeChips_flightWindow": "Janela de mergulho antes do voo", "settings_homeChips_gear": "Manutenção do equipamento", "settings_homeChips_insurance": "Seguro", "settings_homeChips_noFly": "Temporizador de não voar", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "Padrão (12/18/24 h)", "safetySettings_noFlyPreset_strict": "Rigoroso (18/24/48 h)", "safetySettings_noFlyPreset_subtitle": "Intervalos orientativos após um único mergulho sem deco, mergulhos repetitivos e mergulhos com deco", + "flightWindow_closed": "Não mergulhe mais antes do seu voo", + "flightWindow_conflict": "Seu tempo de não voar ultrapassa a partida do voo", + "flightWindow_departs": "O voo parte {time}", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "Tempo restante para mergulhar: {remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "Emergir até {time}", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "Não voar: faltam {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index cb2990f63a..2b5107d31a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -2,6 +2,14 @@ "settings_units_defaultCurrency": "默认货币", "settings_units_dialog_defaultCurrency": "默认货币", "diveSites_list_menu_select": "选择潜水点", + "diveLog_edit_flightWindowWarning": "此次潜水的结束时间晚于您航班的最后安全出水时间({time})", + "@diveLog_edit_flightWindowWarning": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "diveLog_edit_geofenceSuggestion_near": "靠近 {location}", "diveLog_edit_geofenceSuggestion_title": "装备建议", "diveLog_edit_geofenceSuggestion_body": "应用\"{setName}\"套装?", @@ -5511,6 +5519,9 @@ "trips_edit_label_notes": "备注", "trips_edit_label_operatorName": "运营商/包船", "trips_edit_label_resortName": "度假村名称", + "trips_edit_label_returnFlight": "返程航班", + "trips_edit_returnFlightClear": "清除返程航班", + "trips_edit_returnFlightNotSet": "未设置", "trips_edit_label_startDate": "开始日期", "trips_edit_label_tripName": "旅行名称 *", "trips_edit_label_tripType": "旅行类型", @@ -5759,12 +5770,25 @@ "dashboard_gauges_insuranceExpired": "保险已过期", "dashboard_gauges_noInsurance": "未登记保险", "dashboard_gauges_noFlyClear": "禁飞 0:00", + "dashboard_gauges_flightWindow": "潜水窗口 {hours}:{minutes}", + "@dashboard_gauges_flightWindow": { + "placeholders": { + "hours": { + "type": "String" + }, + "minutes": { + "type": "String" + } + } + }, + "dashboard_gauges_flightWindowClosed": "航班前请勿再潜水", "dashboard_gauges_noFlyRemaining": "禁飞 {hours}:{minutes}", "dashboard_gauges_lastDiveDays": "上次潜水 {days} 天前", "dashboard_gauges_lastDiveToday": "今天潜过水", "dashboard_gauges_noDivesYet": "暂无潜水记录", "settings_homeChips_pageTitle": "主页状态标签", "settings_homeChips_description": "选择主页顶部显示哪些状态标签。", + "settings_homeChips_flightWindow": "航班前潜水窗口", "settings_homeChips_gear": "装备保养", "settings_homeChips_insurance": "保险", "settings_homeChips_noFly": "禁飞计时", @@ -6549,6 +6573,32 @@ "safetySettings_noFlyPreset_standard": "标准(12/18/24 小时)", "safetySettings_noFlyPreset_strict": "严格(18/24/48 小时)", "safetySettings_noFlyPreset_subtitle": "单次免减压潜水、重复潜水和减压潜水后的指导间隔", + "flightWindow_closed": "航班前请勿再潜水", + "flightWindow_conflict": "您的禁飞时间超过了航班起飞时间", + "flightWindow_departs": "航班 {time} 起飞", + "@flightWindow_departs": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "flightWindow_openTitle": "剩余潜水时间:{remaining}", + "@flightWindow_openTitle": { + "placeholders": { + "remaining": { + "type": "String" + } + } + }, + "flightWindow_surfaceBy": "请在 {time} 前出水", + "@flightWindow_surfaceBy": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "safetyHub_noFly_active_title": "禁飞:剩余 {remaining}", "@safetyHub_noFly_active_title": { "placeholders": { diff --git a/test/core/database/migration_v142_trip_return_flight_test.dart b/test/core/database/migration_v142_trip_return_flight_test.dart new file mode 100644 index 0000000000..0833cd981d --- /dev/null +++ b/test/core/database/migration_v142_trip_return_flight_test.dart @@ -0,0 +1,63 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/core/database/database.dart'; + +void main() { + test('v142 is in the migration ladder', () { + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(142)); + expect(AppDatabase.migrationVersions, contains(142)); + }); + + test('a fresh database has trips.return_flight_at', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('trips')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, contains('return_flight_at')); + }); + + test( + 'a database stranded before v142 gains return_flight_at via beforeOpen', + () async { + // Only the columns this migration touches are modelled. The beforeOpen + // backstop must add return_flight_at even when onUpgrade never ran + // (v138/v140/v141 are reserved by parallel branches, so a DB can + // arrive at an intermediate version without this column). + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute(''' + CREATE TABLE trips ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT, + start_date INTEGER, + end_date INTEGER + ) + '''); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('trips')").get(); + expect( + cols.map((c) => c.read('name')).toSet(), + contains('return_flight_at'), + ); + }, + ); + + test('the assert is a no-op when the trips table is absent', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('CREATE TABLE unrelated (id TEXT)'); + }, + ); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + // Opening must not throw on a minimal fixture. + await db.customSelect('SELECT 1').get(); + }); +} diff --git a/test/features/dashboard/presentation/providers/dashboard_gauges_provider_test.dart b/test/features/dashboard/presentation/providers/dashboard_gauges_provider_test.dart index c975cf9e1b..199f6fcdcb 100644 --- a/test/features/dashboard/presentation/providers/dashboard_gauges_provider_test.dart +++ b/test/features/dashboard/presentation/providers/dashboard_gauges_provider_test.dart @@ -22,6 +22,7 @@ import 'package:submersion/features/media_store/presentation/providers/media_sto import 'package:submersion/features/pre_dive/domain/entities/pre_dive_session.dart'; import 'package:submersion/features/pre_dive/presentation/providers/pre_dive_providers.dart'; import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; import 'package:submersion/features/safety/presentation/providers/no_fly_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; import 'package:submersion/features/trips/domain/entities/trip.dart'; @@ -106,6 +107,7 @@ ProviderContainer makeContainer({ bool syncEnabled = false, int syncPending = 0, int findings = 0, + FlightWindowStatus? flightWindow, }) { final container = ProviderContainer( overrides: [ @@ -126,6 +128,7 @@ ProviderContainer makeContainer({ openQualityFindingsCountProvider.overrideWith( (ref) => Stream.value(findings), ), + activeTripFlightWindowProvider.overrideWith((ref) async => flightWindow), ], ); addTearDown(container.dispose); diff --git a/test/features/dashboard/presentation/widgets/gauge_strip_test.dart b/test/features/dashboard/presentation/widgets/gauge_strip_test.dart index c881c80df4..e7acc3e0df 100644 --- a/test/features/dashboard/presentation/widgets/gauge_strip_test.dart +++ b/test/features/dashboard/presentation/widgets/gauge_strip_test.dart @@ -402,6 +402,44 @@ void main() { }); }); + group('flight window chip', () { + DashboardGauges gaugesWith(FlightWindowState state) => DashboardGauges( + gearGauges: const [], + hasGear: true, + insurance: null, + noFlyStatus: null, + daysSinceLastDive: null, + flightWindow: FlightWindowStatus( + state: state, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ), + ); + + testWidgets('shows the dive window countdown while open', (tester) async { + await pumpStrip(tester, gaugesWith(FlightWindowState.open)); + expect(find.textContaining('Dive window'), findsOneWidget); + }); + + testWidgets('shows the closed message past the deadline', (tester) async { + await pumpStrip(tester, gaugesWith(FlightWindowState.closed)); + expect(find.text('No more diving before flight'), findsOneWidget); + }); + + testWidgets('shows the closed message on conflict', (tester) async { + await pumpStrip(tester, gaugesWith(FlightWindowState.conflict)); + expect(find.text('No more diving before flight'), findsOneWidget); + }); + + testWidgets('absent when no flight window exists', (tester) async { + await pumpStrip(tester, _emptyGauges); + expect(find.textContaining('Dive window'), findsNothing); + expect(find.text('No more diving before flight'), findsNothing); + }); + }); + group('dive currency chip', () { Future pumpDays(WidgetTester tester, int? days) => pumpStrip( tester, diff --git a/test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart b/test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart new file mode 100644 index 0000000000..fc4ddc22d9 --- /dev/null +++ b/test/features/dive_log/presentation/widgets/flight_window_warning_banner_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_log/presentation/widgets/flight_window_warning_banner.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + final openStatus = FlightWindowStatus( + state: FlightWindowState.open, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ); + + Future pump( + WidgetTester tester, { + required String? tripId, + required DateTime? diveEndTime, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tripFlightWindowProvider( + 't1', + ).overrideWith((ref) async => openStatus), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: FlightWindowWarningBanner( + tripId: tripId, + diveEndTime: diveEndTime, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('warns when the dive ends after the deadline', (tester) async { + await pump(tester, tripId: 't1', diveEndTime: DateTime.utc(2126, 8, 9, 16)); + expect( + find.textContaining('after the latest safe surfacing time'), + findsOneWidget, + ); + }); + + testWidgets('silent when the dive ends before the deadline', (tester) async { + await pump(tester, tripId: 't1', diveEndTime: DateTime.utc(2126, 8, 9, 12)); + expect( + find.textContaining('after the latest safe surfacing time'), + findsNothing, + ); + }); + + testWidgets('silent without a trip', (tester) async { + await pump(tester, tripId: null, diveEndTime: DateTime.utc(2126, 8, 9, 16)); + expect( + find.textContaining('after the latest safe surfacing time'), + findsNothing, + ); + }); +} diff --git a/test/features/safety/domain/services/no_fly_service_test.dart b/test/features/safety/domain/services/no_fly_service_test.dart index f2ed479d91..9432c6ffde 100644 --- a/test/features/safety/domain/services/no_fly_service_test.dart +++ b/test/features/safety/domain/services/no_fly_service_test.dart @@ -105,4 +105,98 @@ void main() { expect(NoFlyPreset.fromDbValue('nonsense'), NoFlyPreset.standard); expect(NoFlyPreset.strict.dbValue, 'strict'); }); + + group('flightWindow', () { + final flightAt = DateTime.utc(2026, 8, 10, 9); // Mon 09:00 departure + + test('open: standard repetitive deadline is departure - 18h', () { + final windowNow = DateTime.utc(2026, 8, 9, 10); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: windowNow, + ); + expect(status!.state, FlightWindowState.open); + expect(status.deadline, DateTime.utc(2026, 8, 9, 15)); + expect(status.remaining(windowNow), const Duration(hours: 5)); + }); + + test('closed: past the deadline but before departure', () { + final windowNow = DateTime.utc(2026, 8, 9, 16); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: windowNow, + ); + expect(status!.state, FlightWindowState.closed); + expect(status.remaining(windowNow), Duration.zero); + }); + + test('exactly at the deadline counts as closed', () { + final windowNow = DateTime.utc(2026, 8, 9, 15); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: windowNow, + ); + expect(status!.state, FlightWindowState.closed); + }); + + test('conflict: existing no-fly reaches past departure, beats open', () { + final windowNow = DateTime.utc(2026, 8, 9, 10); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.deco, + currentNoFlyUntil: DateTime.utc(2026, 8, 10, 12), + now: windowNow, + ); + expect(status!.state, FlightWindowState.conflict); + }); + + test('strict deco: deadline is departure - 48h', () { + final windowNow = DateTime.utc(2026, 8, 8, 8); + final status = service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.strict, + prospectiveCategory: NoFlyCategory.deco, + currentNoFlyUntil: null, + now: windowNow, + ); + expect(status!.deadline, DateTime.utc(2026, 8, 8, 9)); + expect(status.state, FlightWindowState.open); + expect(status.interval, const Duration(hours: 48)); + }); + + test('returns null once the flight has departed', () { + final windowNow = DateTime.utc(2026, 8, 10, 10); + expect( + service.flightWindow( + flightAt: flightAt, + preset: NoFlyPreset.standard, + prospectiveCategory: NoFlyCategory.repetitive, + currentNoFlyUntil: null, + now: windowNow, + ), + isNull, + ); + }); + }); + + test('intervalFor matches the table evaluate() uses', () { + expect( + NoFlyService.intervalFor(NoFlyPreset.standard, NoFlyCategory.single), + const Duration(hours: 12), + ); + expect( + NoFlyService.intervalFor(NoFlyPreset.strict, NoFlyCategory.repetitive), + const Duration(hours: 24), + ); + }); } diff --git a/test/features/safety/presentation/providers/flight_window_providers_test.dart b/test/features/safety/presentation/providers/flight_window_providers_test.dart new file mode 100644 index 0000000000..df102bb61a --- /dev/null +++ b/test/features/safety/presentation/providers/flight_window_providers_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; +import 'package:submersion/features/trips/domain/entities/trip.dart' as domain; +import 'package:submersion/features/trips/presentation/providers/trip_providers.dart'; + +import '../../../../helpers/mock_providers.dart'; + +class _StubTripRepository extends Fake implements TripRepository { + final domain.Trip? trip; + + _StubTripRepository(this.trip); + + @override + Stream watchTripsChanges() => const Stream.empty(); + + @override + Future getTripById(String id) async => trip; +} + +class _StubDiveRepository extends Fake implements DiveRepository { + final List inputs; + + _StubDiveRepository(this.inputs); + + @override + Stream watchDivesChanges() => const Stream.empty(); + + @override + Future> getNoFlyDiveInputs({ + required DateTime since, + String? diverId, + }) async { + return inputs; + } +} + +domain.Trip _trip({DateTime? returnFlightAt}) { + final now = NoFlyService.wallClockNowUtc(); + return domain.Trip( + id: 't1', + name: 'Trip', + startDate: now.subtract(const Duration(days: 3)), + endDate: now.add(const Duration(days: 3)), + returnFlightAt: returnFlightAt, + createdAt: now, + updatedAt: now, + ); +} + +ProviderContainer _container({ + required domain.Trip? trip, + required List dives, + String? diverId, +}) { + final diverNotifier = MockCurrentDiverIdNotifier(); + if (diverId != null) diverNotifier.setCurrentDiver(diverId); + final container = ProviderContainer( + overrides: [ + tripRepositoryProvider.overrideWithValue(_StubTripRepository(trip)), + diveRepositoryProvider.overrideWithValue(_StubDiveRepository(dives)), + currentDiverIdProvider.overrideWith((ref) => diverNotifier), + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + test('open window with repetitive floor even before any dives', () async { + final flightAt = NoFlyService.wallClockNowUtc().add( + const Duration(hours: 24), + ); + final container = _container( + trip: _trip(returnFlightAt: flightAt), + dives: const [], + diverId: 'diver-1', + ); + + final status = await container.read(tripFlightWindowProvider('t1').future); + + expect(status!.state, FlightWindowState.open); + expect(status.category, NoFlyCategory.repetitive); + expect(status.deadline, flightAt.subtract(const Duration(hours: 18))); + }); + + test('a deco dive in the lookback escalates to the deco interval', () async { + final now = NoFlyService.wallClockNowUtc(); + final flightAt = now.add(const Duration(hours: 30)); + final container = _container( + trip: _trip(returnFlightAt: flightAt), + dives: [ + NoFlyDiveInput( + endTime: now.subtract(const Duration(hours: 2)), + hadDecoObligation: true, + ), + ], + diverId: 'diver-1', + ); + + final status = await container.read(tripFlightWindowProvider('t1').future); + + expect(status!.category, NoFlyCategory.deco); + expect(status.deadline, flightAt.subtract(const Duration(hours: 24))); + }); + + test('returns null when the trip has no flight set', () async { + final container = _container( + trip: _trip(returnFlightAt: null), + dives: const [], + diverId: 'diver-1', + ); + + final status = await container.read(tripFlightWindowProvider('t1').future); + + expect(status, isNull); + }); + + test('conflict when the current no-fly ends after departure', () async { + final now = NoFlyService.wallClockNowUtc(); + // Flight in 6 h; a no-deco dive an hour ago carries a 12 h restriction + // (single) that lands 5 h past departure. + final flightAt = now.add(const Duration(hours: 6)); + final container = _container( + trip: _trip(returnFlightAt: flightAt), + dives: [ + NoFlyDiveInput( + endTime: now.subtract(const Duration(hours: 1)), + hadDecoObligation: false, + ), + ], + diverId: 'diver-1', + ); + + final status = await container.read(tripFlightWindowProvider('t1').future); + + expect(status!.state, FlightWindowState.conflict); + }); +} diff --git a/test/features/safety/presentation/widgets/flight_window_card_test.dart b/test/features/safety/presentation/widgets/flight_window_card_test.dart new file mode 100644 index 0000000000..b4c42b907c --- /dev/null +++ b/test/features/safety/presentation/widgets/flight_window_card_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/widgets/flight_window_card.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Future pumpCard(WidgetTester tester, FlightWindowStatus status) { + return tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: FlightWindowCard(status: status)), + ), + ); + } + + // Far-future fixture dates keep the open state's remaining(now) positive + // without a fake clock. + FlightWindowStatus status(FlightWindowState state) => FlightWindowStatus( + state: state, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ); + + testWidgets('open state shows countdown and surface-by time', (tester) async { + await pumpCard(tester, status(FlightWindowState.open)); + expect(find.textContaining('Time left to dive'), findsOneWidget); + expect(find.textContaining('Surface by'), findsOneWidget); + }); + + testWidgets('closed state shows the stop-diving message', (tester) async { + await pumpCard(tester, status(FlightWindowState.closed)); + expect(find.text('No more diving before your flight'), findsOneWidget); + expect(find.textContaining('Flight departs'), findsOneWidget); + }); + + testWidgets('conflict state shows the alert message', (tester) async { + await pumpCard(tester, status(FlightWindowState.conflict)); + expect( + find.text('Your no-fly time extends past your flight departure'), + findsOneWidget, + ); + }); +} diff --git a/test/features/trips/data/repositories/trip_repository_test.dart b/test/features/trips/data/repositories/trip_repository_test.dart index 38546b8223..7451d22db9 100644 --- a/test/features/trips/data/repositories/trip_repository_test.dart +++ b/test/features/trips/data/repositories/trip_repository_test.dart @@ -28,6 +28,7 @@ void main() { String? resortName, String? liveaboardName, String notes = '', + DateTime? returnFlightAt, }) { final now = DateTime.now(); final start = startDate ?? now; @@ -41,6 +42,7 @@ void main() { resortName: resortName, liveaboardName: liveaboardName, notes: notes, + returnFlightAt: returnFlightAt, createdAt: now, updatedAt: now, ); @@ -604,6 +606,70 @@ void main() { }); }); + group('returnFlightAt persistence', () { + test('createTrip and getTripById round-trip the flight time', () async { + final created = await repository.createTrip( + createTestTrip( + name: 'Flight trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ), + ); + + final loaded = await repository.getTripById(created.id); + + expect( + loaded!.returnFlightAt!.millisecondsSinceEpoch, + DateTime.utc(2026, 8, 10, 14, 30).millisecondsSinceEpoch, + ); + // Wall-clock-as-UTC round-trip: components must survive on any + // device timezone, so the decode has to be isUtc. + expect(loaded.returnFlightAt!.isUtc, isTrue); + expect(loaded.returnFlightAt!.hour, 14); + expect(loaded.returnFlightAt!.minute, 30); + }); + + test( + 'updateTrip with null clears a previously set flight time', + () async { + final created = await repository.createTrip( + createTestTrip( + name: 'Cleared trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ), + ); + + await repository.updateTrip(created.copyWith(returnFlightAt: null)); + + final loaded = await repository.getTripById(created.id); + expect(loaded!.returnFlightAt, isNull); + }, + ); + + test( + 'findTripForDate surfaces returnFlightAt (raw-SQL mapper)', + () async { + await repository.createTrip( + createTestTrip( + name: 'Raw mapper trip', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ), + ); + + final found = await repository.findTripForDate( + DateTime.utc(2026, 8, 5), + ); + + expect(found!.returnFlightAt, isNotNull); + }, + ); + }); + group('sharing actions', () { late AppDatabase db; const ts = 1700000000000; diff --git a/test/features/trips/domain/entities/trip_test.dart b/test/features/trips/domain/entities/trip_test.dart index e65b9fdf0a..c5c99524ed 100644 --- a/test/features/trips/domain/entities/trip_test.dart +++ b/test/features/trips/domain/entities/trip_test.dart @@ -341,6 +341,42 @@ void main() { }); }); + group('Trip.returnFlightAt', () { + final base = Trip( + id: 't1', + name: 'Red Sea', + startDate: DateTime.utc(2026, 8, 1), + endDate: DateTime.utc(2026, 8, 10), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + test('defaults to null and is preserved by unrelated copyWith', () { + expect(base.returnFlightAt, isNull); + final withFlight = base.copyWith( + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ); + expect( + withFlight.copyWith(name: 'Renamed').returnFlightAt, + DateTime.utc(2026, 8, 10, 14, 30), + ); + }); + + test('copyWith clears returnFlightAt with an explicit null', () { + final withFlight = base.copyWith( + returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30), + ); + expect(withFlight.copyWith(returnFlightAt: null).returnFlightAt, isNull); + }); + + test('participates in equality', () { + expect( + base.copyWith(returnFlightAt: DateTime.utc(2026, 8, 10, 14, 30)), + isNot(equals(base)), + ); + }); + }); + group('TripWithStats.formattedBottomTime', () { Trip makeTrip() => Trip( id: 't1', diff --git a/test/features/trips/presentation/pages/trip_edit_page_test.dart b/test/features/trips/presentation/pages/trip_edit_page_test.dart index fe21fce594..4b088609c2 100644 --- a/test/features/trips/presentation/pages/trip_edit_page_test.dart +++ b/test/features/trips/presentation/pages/trip_edit_page_test.dart @@ -1208,6 +1208,77 @@ void main() { }); }); + group('TripEditPage - return flight', () { + Future pumpNewTrip(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tripRepositoryProvider.overrideWithValue(_MockTripRepository()), + tripListNotifierProvider.overrideWith((ref) { + return _MockTripListNotifier([]); + }), + ], + child: const MaterialApp( + // Pinned: these tests assert English strings. + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TripEditPage(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows Not set and sets a flight via date + time pickers', ( + tester, + ) async { + await pumpNewTrip(tester); + + await tester.scrollUntilVisible( + find.text('Return Flight'), + 50.0, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Return Flight'), findsOneWidget); + expect(find.text('Not set'), findsOneWidget); + + await tester.tap(find.text('Return Flight')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.byType(TimePickerDialog), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Not set'), findsNothing); + expect(find.byIcon(Icons.clear), findsOneWidget); + }); + + testWidgets('clear icon reverts the flight to Not set', (tester) async { + await pumpNewTrip(tester); + + await tester.scrollUntilVisible( + find.text('Return Flight'), + 50.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Return Flight')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Not set'), findsNothing); + + await tester.tap(find.byIcon(Icons.clear)); + await tester.pumpAndSettle(); + expect(find.text('Not set'), findsOneWidget); + }); + }); + group('TripEditPage - duration display', () { testWidgets('updates duration text when start date moves past end date', ( tester, diff --git a/test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart b/test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart new file mode 100644 index 0000000000..6730d272c9 --- /dev/null +++ b/test/features/trips/presentation/widgets/story/trip_flight_countdown_card_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/safety/domain/services/no_fly_service.dart'; +import 'package:submersion/features/safety/presentation/providers/flight_window_providers.dart'; +import 'package:submersion/features/trips/presentation/widgets/story/trip_flight_countdown_card.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +void main() { + Future pump( + WidgetTester tester, { + required FlightWindowStatus? status, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tripFlightWindowProvider('t1').overrideWith((ref) async => status), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: TripFlightCountdownCard(tripId: 't1')), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders the flight window card when a status exists', ( + tester, + ) async { + await pump( + tester, + status: FlightWindowStatus( + state: FlightWindowState.open, + flightAt: DateTime.utc(2126, 8, 10, 9), + deadline: DateTime.utc(2126, 8, 9, 15), + category: NoFlyCategory.repetitive, + interval: const Duration(hours: 18), + ), + ); + expect(find.textContaining('Time left to dive'), findsOneWidget); + }); + + testWidgets('renders nothing when the provider yields null', (tester) async { + await pump(tester, status: null); + expect(find.byType(Card), findsNothing); + }); +}