diff --git a/docs/superpowers/plans/2026-09-18-rental-gear-memory.md b/docs/superpowers/plans/2026-09-18-rental-gear-memory.md new file mode 100644 index 0000000000..dff3970d63 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-rental-gear-memory.md @@ -0,0 +1,3975 @@ +# Rental Gear Memory 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:** A diver who returns to a dive center sees what worked last time with its rental gear (notes they wrote, plus the lead, weighting feedback and tanks of their last dive there) and can copy that dive's weights and tanks into the new dive in one tap. + +**Architecture:** One new synced child table of dive centers, `dive_center_gear_notes`, holds the diver's judgements. The "last dive here" facts are never stored: a repository query picks the latest non-planned dive at the center, the fully hydrated dive is loaded, and a pure value object converts it into display rows and fresh-id copies of its weights and tanks. Two surfaces render the same data: a card in the dive edit form under the dive center row, and a section on the dive center detail page. One shared bottom sheet creates, edits and deletes notes. + +**Tech Stack:** Flutter, Riverpod (hand-written providers), Drift (`dart run build_runner build`), `flutter gen-l10n` ARB localization, `flutter_test`, in-memory SQLite for repository tests. + +**Spec:** `docs/superpowers/specs/2026-09-18-rental-gear-memory-design.md`. Every decision there is fixed; this plan implements it. Two deliberate deviations from the spec text, both forced by the codebase: timestamps are stored as Unix milliseconds in integer columns like every neighbouring table (the spec said "datetime"), and the resolver takes the single hydrated latest dive rather than a list, because the repository query already excludes the current dive and orders by date. + +## Global Constraints + +- Schema rung is **221**. Main is at 219 and three open PRs (#2040, #1980, #1978) each claim 220. Re-check before Task 2 and again before the push: `git fetch origin && git show origin/main:lib/core/database/database.dart | grep "currentSchemaVersion ="` and `for n in 2040 1980 1978; do gh pr diff $n | grep -o "currentSchemaVersion = [0-9]*"; done`. If 221 has been taken, use the next free number everywhere this plan says 221. +- `minimumCompatibleSchemaVersion` stays 210. A new table never raises the floor. +- The Drift output `lib/core/database/database.g.dart` is gitignored and rebuilt per tree. After any change to `database.dart` run `dart run build_runner build --delete-conflicting-outputs` before compiling. Never stage a `.g.dart` file. +- The l10n output `lib/l10n/arb/app_localizations*.dart` (12 files) IS committed. Run `flutter gen-l10n` only after every one of the 11 ARBs carries the new keys (running it earlier bakes English into the other locales), then commit the 11 ARBs and the 12 generated files together. +- New user-visible strings are translated in all 11 locales: ar, de, en, es, fr, he, hu, it, nl, pt, zh. Locale ARBs get no `@` metadata; only `app_en.arb` does. New keys are appended at the end of each file, before the closing brace. +- Every value shown to the diver goes through `UnitFormatter` (weight and volume units, dates). Input in the diver's unit is converted with `UnitFormatter.weightToKg` and `volumeToLiters`; seeded text uses `formatRoundedForInput` and is read back with `parseUserDecimal`. +- Enum text read from the database uses `Enum.values.firstWhere((v) => v.name == text, orElse: ...)`, never `byName`. +- Never use em-dashes, en-dashes as punctuation, double hyphens or spaced hyphens as punctuation, in code, comments, tests, ARB strings, commit messages or the PR body. +- No mention of Claude, Claude Code or Anthropic in any commit, PR text or file. No `Co-Authored-By` trailer. +- No emojis in code, comments or docs. +- Immutability: never mutate a list or map that was passed in. +- TDD: write the failing test first, watch it fail, then implement. +- Run `dart format .` on the whole project before each commit. +- Run `flutter analyze` on its own, never piped through `grep` or `tail` (a pipe hides the exit status). Infos are fatal in CI. +- Run specific test files per task, never the full suite; the full suite runs once, in Task 10. Do not start a `flutter test` while another one is running on this machine. +- The Bash tool's working directory can reset; run every command from the worktree root `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/loving-keller-f8f99d` and stage explicit paths (`git add `), never `git add -A` or `git add -u`. +- Widget tests pin `locale: const Locale('en')`. +- The PR body must contain `Closes #2075`. +- Sync entity type string is `diveCenterGearNotes`; SQL table is `dive_center_gear_notes`; Drift table class is `DiveCenterGearNotes`; Drift row class is `DiveCenterGearNoteRow` (the domain entity is `DiveCenterGearNote`). + +## File Structure + +| File | Responsibility | +| --- | --- | +| `lib/features/dive_centers/domain/entities/dive_center_gear_note.dart` (create) | `RentalVerdict` enum, `DiveCenterGearNote` entity with `copyWith`, enum parsing helpers | +| `lib/features/dive_centers/domain/services/rental_memory_resolver.dart` (create) | `LastDiveAtCenter` value object: built from a hydrated `Dive`, exposes lead total and fresh-id copies of weights and tanks | +| `lib/core/database/database.dart` (modify) | `DiveCenterGearNotes` table, v221 rung, `_assertDiveCenterGearNotesSchema`, beforeOpen backstop, child hlc list | +| `lib/core/data/repositories/sync_repository.dart` (modify) | `hlcTargets` entry | +| `lib/core/services/sync/sync_data_serializer.dart` (modify) | `SyncData` field, base table, export, fetch, upsert, delete, id selector, parent-gated sets | +| `lib/core/services/sync/sync_service.dart` (modify) | apply order, `entityHasUpdatedAt`, `parentRefs` | +| `lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart` (create) | CRUD, change stream, pending marks and tombstones | +| `lib/features/dive_centers/data/repositories/dive_center_repository.dart` (modify) | `latestDiveIdAtCenter`; `deleteDiveCenter` tombstones the notes it cascades | +| `lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart` (create) | repository provider, notes-by-center, last-dive-at-center | +| `lib/l10n/arb/app_*.arb` (modify, 11 files) plus regenerated `app_localizations*.dart` (12 files) | 27 new strings | +| `lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart` (create) | `showRentalGearNoteSheet`, the editor, delete confirm | +| `lib/features/dive_centers/presentation/widgets/rental_memory_card.dart` (create) | the "Last time at {center}" card for the edit form | +| `lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart` (modify) | `centerChild` slot | +| `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (modify) | card wiring and `_applyLastDiveAtCenter` | +| `lib/features/dive_centers/presentation/widgets/rental_gear_section.dart` (create) | the detail page section | +| `lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart` (modify) | mounts the section | + +--- + +### Task 1: Domain entity and the last-dive value object + +**Files:** +- Create: `lib/features/dive_centers/domain/entities/dive_center_gear_note.dart` +- Create: `lib/features/dive_centers/domain/services/rental_memory_resolver.dart` +- Test: `test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart` +- Test: `test/features/dive_centers/domain/services/rental_memory_resolver_test.dart` + +**Interfaces:** +- Consumes: `EquipmentType`, `WeightType`, `WeightingFeedback`, `TankRole`, `GasMix` from `lib/core/constants/enums.dart` and `lib/features/dive_log/domain/entities/dive.dart`; `DiveWeight` from `lib/features/dive_log/domain/entities/dive_weight.dart`. +- Produces: `enum RentalVerdict { worked, avoid }`; `class DiveCenterGearNote` with fields `id, diveCenterId, gearType (EquipmentType), label (String?), size (String?), verdict, leadAdjustmentKg (double?), volumeLiters (double?), note (String), diveId (String?), notedAt, createdAt, updatedAt (DateTime)`, `copyWith` with `clearLabel, clearSize, clearLeadAdjustment, clearVolume, clearDiveId` flags, static `verdictFromName(String?)` and `gearTypeFromName(String?)`; `class LastDiveAtCenter` with `diveId, dateTime, weights, weightingFeedback, weightingFeedbackKg, tanks`, `double get totalLeadKg`, `factory LastDiveAtCenter.fromDive(Dive)`, `List weightsForNewDive({required String diveId, required String Function() newId})`, `List tanksForNewDive({required String Function() newId, required double startPressure, required double endPressure})`. + +- [ ] **Step 1: Write the failing entity test** + +Create `test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +void main() { + final now = DateTime.utc(2026, 9, 18, 10); + + DiveCenterGearNote note() => DiveCenterGearNote( + id: 'n1', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + size: null, + verdict: RentalVerdict.avoid, + leadAdjustmentKg: null, + volumeLiters: null, + note: 'Breathed wet below 20 m', + diveId: 'd1', + notedAt: now, + createdAt: now, + updatedAt: now, + ); + + test('copyWith replaces only the given fields', () { + final changed = note().copyWith( + verdict: RentalVerdict.worked, + leadAdjustmentKg: 2.0, + ); + expect(changed.verdict, RentalVerdict.worked); + expect(changed.leadAdjustmentKg, 2.0); + expect(changed.label, '14'); + expect(changed.diveId, 'd1'); + expect(changed.note, 'Breathed wet below 20 m'); + }); + + test('copyWith clear flags null a nullable field', () { + final cleared = note().copyWith( + clearLabel: true, + clearDiveId: true, + ); + expect(cleared.label, isNull); + expect(cleared.diveId, isNull); + // The untouched nullable stays. + expect(cleared.copyWith(size: 'L').size, 'L'); + }); + + test('verdictFromName falls back to worked for unknown text', () { + expect(DiveCenterGearNote.verdictFromName('avoid'), RentalVerdict.avoid); + expect(DiveCenterGearNote.verdictFromName('worked'), RentalVerdict.worked); + expect(DiveCenterGearNote.verdictFromName('later'), RentalVerdict.worked); + expect(DiveCenterGearNote.verdictFromName(null), RentalVerdict.worked); + }); + + test('gearTypeFromName falls back to other for a newer peer type', () { + expect(DiveCenterGearNote.gearTypeFromName('bcd'), EquipmentType.bcd); + expect( + DiveCenterGearNote.gearTypeFromName('hoverboard'), + EquipmentType.other, + ); + expect(DiveCenterGearNote.gearTypeFromName(null), EquipmentType.other); + }); + + test('equality is by value', () { + expect(note(), note()); + expect(note() == note().copyWith(note: 'x'), isFalse); + }); +} +``` + +- [ ] **Step 2: Write the failing resolver test** + +Create `test/features/dive_centers/domain/services/rental_memory_resolver_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; + +void main() { + final when = DateTime(2026, 3, 12, 9, 30); + + Dive dive({ + List weights = const [], + List tanks = const [], + WeightingFeedback? feedback, + double? feedbackKg, + }) => Dive( + id: 'd1', + dateTime: when, + weights: weights, + tanks: tanks, + weightingFeedback: feedback, + weightingFeedbackKg: feedbackKg, + ); + + const belt = DiveWeight( + id: 'w1', + diveId: 'd1', + weightType: WeightType.belt, + amountKg: 6, + notes: 'their belt', + ); + const trim = DiveWeight( + id: 'w2', + diveId: 'd1', + weightType: WeightType.trimWeights, + amountKg: 2, + ); + const al80 = DiveTank( + id: 't1', + name: 'Their AL80', + volume: 11.1, + workingPressure: 207, + startPressure: 200, + endPressure: 60, + gasMix: GasMix(oxygen: 32), + role: TankRole.backGas, + material: TankMaterial.aluminum, + order: 0, + presetName: 'al80', + computerId: 'comp', + transmitterSerial: 'TX1', + sourceTankIndex: 0, + regulatorEquipmentId: 'reg', + equipmentId: 'tank', + ); + + test('fromDive carries date, weights, feedback and tanks', () { + final last = LastDiveAtCenter.fromDive( + dive( + weights: const [belt, trim], + tanks: const [al80], + feedback: WeightingFeedback.overweighted, + feedbackKg: 1, + ), + ); + expect(last.diveId, 'd1'); + expect(last.dateTime, when); + expect(last.weights, const [belt, trim]); + expect(last.tanks, const [al80]); + expect(last.weightingFeedback, WeightingFeedback.overweighted); + expect(last.weightingFeedbackKg, 1); + expect(last.totalLeadKg, 8); + }); + + test('a dive with no weights or tanks resolves with empty lists', () { + final last = LastDiveAtCenter.fromDive(dive()); + expect(last.weights, isEmpty); + expect(last.tanks, isEmpty); + expect(last.totalLeadKg, 0); + expect(last.weightingFeedback, isNull); + }); + + test('weightsForNewDive copies type, amount and notes under fresh ids', () { + final last = LastDiveAtCenter.fromDive(dive(weights: const [belt, trim])); + var n = 0; + final copies = last.weightsForNewDive( + diveId: 'new', + newId: () => 'id${n++}', + ); + expect(copies.map((w) => w.id), ['id0', 'id1']); + expect(copies.map((w) => w.diveId), ['new', 'new']); + expect(copies.first.weightType, WeightType.belt); + expect(copies.first.amountKg, 6); + expect(copies.first.notes, 'their belt'); + expect(copies.last.weightType, WeightType.trimWeights); + // The source list is untouched. + expect(last.weights.first.id, 'w1'); + }); + + test('tanksForNewDive keeps the rig and takes default pressures', () { + final last = LastDiveAtCenter.fromDive(dive(tanks: const [al80])); + final copies = last.tanksForNewDive( + newId: () => 'fresh', + startPressure: 210, + endPressure: 50, + ); + final copy = copies.single; + expect(copy.id, 'fresh'); + expect(copy.name, 'Their AL80'); + expect(copy.volume, 11.1); + expect(copy.workingPressure, 207); + expect(copy.presetName, 'al80'); + expect(copy.material, TankMaterial.aluminum); + expect(copy.gasMix, const GasMix(oxygen: 32)); + expect(copy.role, TankRole.backGas); + expect(copy.order, 0); + expect(copy.startPressure, 210); + expect(copy.endPressure, 50); + // Device, transmitter and owned-gear links belong to the old dive. + expect(copy.computerId, isNull); + expect(copy.transmitterSerial, isNull); + expect(copy.sourceTankIndex, isNull); + expect(copy.regulatorEquipmentId, isNull); + expect(copy.equipmentId, isNull); + }); +} +``` + +If `Dive`'s constructor requires more than `id` and `dateTime` (check `lib/features/dive_log/domain/entities/dive.dart` around line 140 for `required` parameters), add those required arguments to the `dive()` helper with neutral values; do not change the entity. + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `flutter test test/features/dive_centers/domain/` +Expected: FAIL with "Target of URI doesn't exist" for both new imports. + +- [ ] **Step 4: Write the entity** + +Create `lib/features/dive_centers/domain/entities/dive_center_gear_note.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; + +/// Whether a piece of an operator's rental gear is worth asking for again. +enum RentalVerdict { worked, avoid } + +/// One thing a diver learned about a dive center's rental gear (issue +/// #2075): "their size-L wetsuit runs small", "regulator 14 breathes wet", +/// "their AL80 really holds 11.1 L", "I needed 2 kg more with their BCD". +/// +/// A note belongs to the center, not to a dive: it survives the dive it was +/// written on and shows on every later visit. [diveId] only records where it +/// was noticed. +class DiveCenterGearNote extends Equatable { + final String id; + final String diveCenterId; + final EquipmentType gearType; + + /// The operator's own mark for the item: "14", "AL80", "blue". + final String? label; + final String? size; + final RentalVerdict verdict; + + /// Signed, in kg: how much more (positive) or less lead this gear needed + /// than the diver's usual. + final double? leadAdjustmentKg; + + /// The cylinder's true capacity, for tank notes. + final double? volumeLiters; + final String note; + final String? diveId; + final DateTime notedAt; + final DateTime createdAt; + final DateTime updatedAt; + + const DiveCenterGearNote({ + required this.id, + required this.diveCenterId, + required this.gearType, + this.label, + this.size, + this.verdict = RentalVerdict.worked, + this.leadAdjustmentKg, + this.volumeLiters, + this.note = '', + this.diveId, + required this.notedAt, + required this.createdAt, + required this.updatedAt, + }); + + /// A stored verdict name, tolerating a value this build does not know. + static RentalVerdict verdictFromName(String? name) => + RentalVerdict.values.firstWhere( + (v) => v.name == name, + orElse: () => RentalVerdict.worked, + ); + + /// A stored gear type name; a type added by a newer peer reads as other. + static EquipmentType gearTypeFromName(String? name) => + EquipmentType.values.firstWhere( + (t) => t.name == name, + orElse: () => EquipmentType.other, + ); + + DiveCenterGearNote copyWith({ + String? id, + String? diveCenterId, + EquipmentType? gearType, + String? label, + bool clearLabel = false, + String? size, + bool clearSize = false, + RentalVerdict? verdict, + double? leadAdjustmentKg, + bool clearLeadAdjustment = false, + double? volumeLiters, + bool clearVolume = false, + String? note, + String? diveId, + bool clearDiveId = false, + DateTime? notedAt, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return DiveCenterGearNote( + id: id ?? this.id, + diveCenterId: diveCenterId ?? this.diveCenterId, + gearType: gearType ?? this.gearType, + label: clearLabel ? null : (label ?? this.label), + size: clearSize ? null : (size ?? this.size), + verdict: verdict ?? this.verdict, + leadAdjustmentKg: clearLeadAdjustment + ? null + : (leadAdjustmentKg ?? this.leadAdjustmentKg), + volumeLiters: clearVolume ? null : (volumeLiters ?? this.volumeLiters), + note: note ?? this.note, + diveId: clearDiveId ? null : (diveId ?? this.diveId), + notedAt: notedAt ?? this.notedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [ + id, + diveCenterId, + gearType, + label, + size, + verdict, + leadAdjustmentKg, + volumeLiters, + note, + diveId, + notedAt, + createdAt, + updatedAt, + ]; +} +``` + +- [ ] **Step 5: Write the value object** + +Create `lib/features/dive_centers/domain/services/rental_memory_resolver.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; + +/// What the diver's most recent dive at a center says about the rental rig +/// they used (issue #2075): the lead they carried, how it felt, and the +/// cylinders. Nothing here is stored; it is read off the hydrated dive every +/// time, so it can never drift from the dive itself. +/// +/// Build it only from the fully hydrated dive (`DiveRepository.getDiveById`). +/// The lean analysis hydration leaves `weights` empty, which would make every +/// last dive look weightless. +class LastDiveAtCenter extends Equatable { + final String diveId; + final DateTime dateTime; + final List weights; + final WeightingFeedback? weightingFeedback; + final double? weightingFeedbackKg; + final List tanks; + + const LastDiveAtCenter({ + required this.diveId, + required this.dateTime, + required this.weights, + required this.weightingFeedback, + required this.weightingFeedbackKg, + required this.tanks, + }); + + factory LastDiveAtCenter.fromDive(Dive dive) => LastDiveAtCenter( + diveId: dive.id, + dateTime: dive.dateTime, + weights: List.unmodifiable(dive.weights), + weightingFeedback: dive.weightingFeedback, + weightingFeedbackKg: dive.weightingFeedbackKg, + tanks: List.unmodifiable(dive.tanks), + ); + + double get totalLeadKg => weights.fold(0.0, (sum, w) => sum + w.amountKg); + + /// The weights as rows for a new dive, under fresh ids so the copies are + /// independent of the source dive once applied. + List weightsForNewDive({ + required String diveId, + required String Function() newId, + }) => [ + for (final w in weights) + DiveWeight( + id: newId(), + diveId: diveId, + weightType: w.weightType, + amountKg: w.amountKg, + notes: w.notes, + ), + ]; + + /// The cylinders as rows for a new dive: the rig (size, rating, preset, + /// material, mix, role, order) travels; the pressures do not, because the + /// new dive has not happened yet, and neither do the computer, transmitter + /// and owned-gear links, which describe the old dive's hardware. + List tanksForNewDive({ + required String Function() newId, + required double startPressure, + required double endPressure, + }) => [ + for (final t in tanks) + DiveTank( + id: newId(), + name: t.name, + volume: t.volume, + workingPressure: t.workingPressure, + startPressure: startPressure, + endPressure: endPressure, + gasMix: t.gasMix, + role: t.role, + material: t.material, + order: t.order, + presetName: t.presetName, + decoSwitchDepth: t.decoSwitchDepth, + isTravelGas: t.isTravelGas, + ), + ]; + + @override + List get props => [ + diveId, + dateTime, + weights, + weightingFeedback, + weightingFeedbackKg, + tanks, + ]; +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_centers/domain/` +Expected: all tests PASS. If the tank test fails on a `GasMix` constructor name (the field may be `o2Percent` rather than `oxygen`), read `class GasMix` in `dive.dart` and fix the test, not the code. + +- [ ] **Step 7: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_centers/domain/entities/dive_center_gear_note.dart lib/features/dive_centers/domain/services/rental_memory_resolver.dart test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart test/features/dive_centers/domain/services/rental_memory_resolver_test.dart +git commit -m "feat(dive-centers): rental gear note entity and last-dive value object" +``` + +--- + +### Task 2: Schema v221, the `dive_center_gear_notes` table + +**Files:** +- Modify: `lib/core/database/database.dart` (table after `WeightPresetEntries` ~line 1412; `@DriftDatabase` tables list ~line 4199; `currentSchemaVersion` line 4211; `migrationVersions` tail ~line 4283; the v219 rung ~line 12254; `beforeOpen` backstops ~line 12379; `_assertChildHlcColumns` list ~line 5242; new helper next to `_assertEquipmentTagSchema` ~line 8271) +- Modify: `test/core/database/migration_v219_equipment_tags_test.dart:92` +- Test: `test/core/database/migration_v221_dive_center_gear_notes_test.dart` (create) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: Drift table `DiveCenterGearNotes` (getter `db.diveCenterGearNotes`), row class `DiveCenterGearNoteRow`, companion `DiveCenterGearNotesCompanion` with fields `id, diveCenterId, gearType, label, size, verdict, leadAdjustmentKg, volumeLiters, note, diveId, notedAt, createdAt, updatedAt, hlc`; `AppDatabase.currentSchemaVersion == 221`. + +- [ ] **Step 1: Re-check the rung** + +Run the two commands from Global Constraints. If main is still at 219 and no open PR claims 221, continue with 221. + +- [ ] **Step 2: Write the failing migration test** + +Create `test/core/database/migration_v221_dive_center_gear_notes_test.dart`: + +```dart +import 'package:drift/drift.dart' show Variable; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Schema v221: rental gear memory, the dive_center_gear_notes table +/// (issue #2075). +void main() { + /// A v219 database with the two parents the new table references and + /// the tables the beforeOpen backstops touch, and no + /// dive_center_gear_notes. + NativeDatabase setupDb({ + int userVersion = 219, + bool withDiveCenters = true, + }) { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = $userVersion'); + rawDb.execute('CREATE TABLE divers (id TEXT PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dive_sites (id TEXT PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dives (id TEXT PRIMARY KEY)'); + if (withDiveCenters) { + rawDb.execute('CREATE TABLE dive_centers (id TEXT PRIMARY KEY)'); + } + rawDb.execute('CREATE TABLE equipment (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute(''' + CREATE TABLE tags ( + id TEXT NOT NULL PRIMARY KEY, + diver_id TEXT, + name TEXT NOT NULL, + color TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT, + applies_to_dives INTEGER NOT NULL DEFAULT 1 + CHECK (applies_to_dives IN (0, 1)), + applies_to_sites INTEGER NOT NULL DEFAULT 0 + CHECK (applies_to_sites IN (0, 1)), + applies_to_equipment INTEGER NOT NULL DEFAULT 0 + CHECK (applies_to_equipment IN (0, 1)) + ) + '''); + }, + ); + } + + Future> columnsOf(AppDatabase db, String table) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return cols.map((c) => c.read('name')).toSet(); + } + + Future>> tableInfo( + AppDatabase db, + String table, + ) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return [ + for (final c in cols) + { + 'name': c.data['name'], + 'type': c.data['type'], + 'notnull': c.data['notnull'], + 'dflt_value': c.data['dflt_value'], + 'pk': c.data['pk'], + }, + ]; + } + + Future ddlOf(AppDatabase db, String type, String name) async { + final rows = await db + .customSelect( + 'SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', + variables: [Variable(type), Variable(name)], + ) + .get(); + return rows.isEmpty ? null : rows.single.read('sql'); + } + + test('v221 is the current schema version and is in the ladder', () { + // The newest rung owns the exact assertion; relax it to + // greaterThanOrEqualTo when the next one lands. + expect(AppDatabase.currentSchemaVersion, 221); + expect(AppDatabase.migrationVersions, contains(221)); + expect(AppDatabase.migrationStepCount(219), 1); + // Additive rung: the sync compatibility floor must not move. + expect(AppDatabase.minimumCompatibleSchemaVersion, 210); + }); + + test('adds the dive_center_gear_notes table', () async { + final db = AppDatabase(setupDb()); + addTearDown(db.close); + + expect( + await columnsOf(db, 'dive_center_gear_notes'), + containsAll([ + 'id', + 'dive_center_id', + 'gear_type', + 'label', + 'size', + 'verdict', + 'lead_adjustment_kg', + 'volume_liters', + 'note', + 'dive_id', + 'noted_at', + 'created_at', + 'updated_at', + 'hlc', + ]), + ); + }); + + test('the table cascades from its center and detaches from a dive', () async { + final db = AppDatabase(setupDb()); + addTearDown(db.close); + + final ddl = await ddlOf(db, 'table', 'dive_center_gear_notes'); + expect(ddl, contains('REFERENCES dive_centers (id) ON DELETE CASCADE')); + expect(ddl, contains('REFERENCES dives (id) ON DELETE SET NULL')); + }); + + test('a fresh database matches the upgraded one', () async { + final upgraded = AppDatabase(setupDb()); + addTearDown(upgraded.close); + final fresh = AppDatabase(NativeDatabase.memory()); + addTearDown(fresh.close); + + expect( + await tableInfo(upgraded, 'dive_center_gear_notes'), + await tableInfo(fresh, 'dive_center_gear_notes'), + ); + expect( + await ddlOf(upgraded, 'table', 'dive_center_gear_notes'), + await ddlOf(fresh, 'table', 'dive_center_gear_notes'), + ); + }); + + test( + 'a database stamped v221 without the table heals in beforeOpen', + () async { + // A parallel branch that claimed 221 first carries a device past the + // rung; the beforeOpen backstop must build what the rung would have. + final db = AppDatabase(setupDb(userVersion: 221)); + addTearDown(db.close); + + expect( + await columnsOf(db, 'dive_center_gear_notes'), + contains('dive_center_id'), + ); + }, + ); + + test('a fixture without dive_centers skips the table', () async { + final db = AppDatabase(setupDb(withDiveCenters: false)); + addTearDown(db.close); + + expect(await columnsOf(db, 'dive_center_gear_notes'), isEmpty); + }); +} +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `flutter test test/core/database/migration_v221_dive_center_gear_notes_test.dart` +Expected: FAIL on the first test (`currentSchemaVersion` is 219) and the table tests (empty column set). + +- [ ] **Step 4: Add the table** + +In `lib/core/database/database.dart`, directly after the closing brace of `class WeightPresetEntries extends Table` (about line 1412), add: + +```dart +/// Rental gear memory (v221, issue #2075): what a diver learned about a dive +/// center's rental gear, kept per center so it surfaces on a return visit. +/// The note is the diver's judgement; the numbers of the last dive at the +/// center (lead, feedback, tanks) are read off that dive, never copied here. +@DataClassName('DiveCenterGearNoteRow') +class DiveCenterGearNotes extends Table { + TextColumn get id => text()(); + TextColumn get diveCenterId => + text().references(DiveCenters, #id, onDelete: KeyAction.cascade)(); + + /// EquipmentType.name of the rental item. + TextColumn get gearType => text()(); + + /// The operator's mark for the item: "14", "AL80". + TextColumn get label => text().nullable()(); + TextColumn get size => text().nullable()(); + + /// RentalVerdict.name: worked or avoid. + TextColumn get verdict => text()(); + + /// Signed kg: lead needed beyond the diver's usual with this gear. + RealColumn get leadAdjustmentKg => real().nullable()(); + + /// The cylinder's true capacity, for tank notes. + RealColumn get volumeLiters => real().nullable()(); + TextColumn get note => text().withDefault(const Constant(''))(); + + /// The dive the note was written on, if any; the note outlives it. + TextColumn get diveId => + text().nullable().references(Dives, #id, onDelete: KeyAction.setNull)(); + IntColumn get notedAt => integer()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + @override + Set get primaryKey => {id}; + + /// This child's own clock, stamped when it is marked pending, so the merge + /// refuses a remote copy strictly older than the local one + /// (SyncDataSerializer.parentGatedChildEntities). + TextColumn get hlc => text().nullable()(); +} +``` + +- [ ] **Step 5: Register the table, bump the version, add the rung** + +In the `@DriftDatabase(tables: [...])` list, after `CylinderConfigItems,` (about line 4199) add: + +```dart + // Rental gear memory (v221, issue #2075) + DiveCenterGearNotes, +``` + +Change line 4211: + +```dart + static const int currentSchemaVersion = 221; +``` + +At the tail of `migrationVersions`, after ` 219,` add: + +```dart + // v221: rental gear memory (issue #2075). dive_center_gear_notes, a + // child of dive_centers. Table-only rung, no backfill, so the + // compatibility floor stays. Takes 221, not 220: three open PRs held + // 220 when this landed, and a rung at or below the shipped version + // never runs its onUpgrade step. + 221, +``` + +In `onUpgrade`, directly after `if (from < 219) await reportProgress();` add: + +```dart + // v221: rental gear memory (issue #2075). Table-only rung, no + // backfill. + if (from < 221) { + await _assertDiveCenterGearNotesSchema(); + } + if (from < 221) await reportProgress(); +``` + +In `beforeOpen`, directly after the `await _assertEquipmentTagSchema();` backstop (about line 12379) add: + +```dart + // v221 backstop: the rental gear notes table (parallel-branch + // version-collision self-heal; createTable is idempotent). + await _assertDiveCenterGearNotesSchema(); +``` + +In `_assertChildHlcColumns`, add `'dive_center_gear_notes',` after `'gas_switches',` in the list (the column is created with the table; the entry keeps the parent-gated list and this list in step). + +Next to `_assertEquipmentTagSchema` (about line 8271) add: + +```dart + /// Idempotent creation of the v221 `dive_center_gear_notes` table (issue + /// #2075). Called from the v221 rung and the beforeOpen backstop. + /// + /// Skipped on a partial migration-test fixture that lacks either parent + /// table, so a fixture written for an older rung does not gain a table + /// whose foreign keys point nowhere. + Future _assertDiveCenterGearNotesSchema() async { + for (final parent in const ['dive_centers', 'dives']) { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(parent)], + ).get(); + if (rows.isEmpty) return; + } + await createMigrator().createTable(diveCenterGearNotes); + } +``` + +- [ ] **Step 6: Relax the v219 tripwire** + +In `test/core/database/migration_v219_equipment_tags_test.dart` line 92, change + +```dart + expect(AppDatabase.currentSchemaVersion, 219); +``` + +to + +```dart + // Relaxed once v221 (rental gear memory) landed on top; the newest rung + // owns the exact assertion. + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(219)); +``` + +- [ ] **Step 7: Regenerate Drift and run the migration tests** + +```bash +dart run build_runner build --delete-conflicting-outputs +flutter test test/core/database/migration_v221_dive_center_gear_notes_test.dart test/core/database/migration_v219_equipment_tags_test.dart test/core/database/migration_v218_site_detail_sections_test.dart +``` + +Expected: all PASS. If the cascade DDL assertion fails on spacing, print the DDL (`print(ddl)`) once, match Drift's exact `REFERENCES dive_centers (id) ON DELETE CASCADE` spelling, and remove the print. + +- [ ] **Step 8: Run the sync registration pin tests to see what Task 3 owes** + +Run: `flutter test test/core/services/sync/sync_hlc_target_registration_test.dart` +Expected: FAIL on "every table with an hlc column is registered in hlcTargets" naming `dive_center_gear_notes`. That failure is Task 3's first red test; do not fix it here. + +- [ ] **Step 9: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/core/database/database.dart test/core/database/migration_v221_dive_center_gear_notes_test.dart test/core/database/migration_v219_equipment_tags_test.dart +git commit -m "feat(database): schema v221, the dive_center_gear_notes table (#2075)" +``` + +--- + +### Task 3: Sync registration for `diveCenterGearNotes` + +**Files:** +- Modify: `lib/core/data/repositories/sync_repository.dart` (`hlcTargets`, the v210 parent-gated block after `'weightPresetEntries'` ~line 142) +- Modify: `lib/core/services/sync/sync_data_serializer.dart` (`SyncData` field ~284, constructor default ~379, `toJson` ~469, `fromJson` ~560, `_baseTables` ~894, `_buildSyncData` ~1776, `parentGatedChildEntities` ~1471, `parentGatedTables` ~1527, `fetchRecord` ~2339, `fetchRecords` ~2741, `upsertRecord` ~3630, `upsertRecords` ~4480, id selector ~5212, `TableInfo` switch ~5596, `deleteRecord` ~5941, and a new `_exportDiveCenterGearNotes` next to `_exportDiveCenters` ~6669) +- Modify: `lib/core/services/sync/sync_service.dart` (apply order ~1274, `entityHasUpdatedAt` ~2297, `parentRefs` ~2434) +- Modify: `test/core/services/sync/sync_parent_refs_completeness_test.dart` (`syncedTables` map ~line 29) +- Test: `test/core/services/sync/dive_center_gear_notes_sync_test.dart` (create) + +**Interfaces:** +- Consumes: Task 2's `db.diveCenterGearNotes` and `DiveCenterGearNoteRow`. +- Produces: entity type `'diveCenterGearNotes'` accepted by `SyncDataSerializer.upsertRecord / upsertRecords / fetchRecord / fetchRecords / deleteRecord / recordIdsFor`, carried by `SyncData.diveCenterGearNotes`, applied by `SyncService` after `diveCenters`. + +Every occurrence of `diveCenters` in the serializer and the service is the anchor: the new entity goes immediately after it in each list so the payload order, the base-table order and the apply order agree (three tests pin those orders). + +- [ ] **Step 1: Write the failing sync test** + +Create `test/core/services/sync/dive_center_gear_notes_sync_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; + +import '../../../helpers/test_database.dart'; + +/// diveCenterGearNotes is a parent-gated child of diveCenters (issue #2075): +/// own id, own hlc, exported with its center and on its own when pending, +/// applied after diveCenters and dives. +void main() { + late SyncDataSerializer serializer; + + setUp(() async { + await setUpTestDatabase(); + serializer = SyncDataSerializer(); + }); + tearDown(tearDownTestDatabase); + + Map noteJson(String id, {String? diveId}) => { + 'id': id, + 'diveCenterId': 'c1', + 'gearType': 'regulator', + 'label': '14', + 'size': null, + 'verdict': 'avoid', + 'leadAdjustmentKg': null, + 'volumeLiters': null, + 'note': 'wet', + 'diveId': diveId, + 'notedAt': 1000, + 'createdAt': 1000, + 'updatedAt': 1000, + 'hlc': null, + }; + + Future insertCenter(String id) => + serializer.upsertRecord('diveCenters', { + 'id': id, + 'name': id, + 'affiliations': '', + 'notes': '', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + + test('is registered everywhere a parent-gated child must be', () { + expect( + SyncRepository.hlcTargets['diveCenterGearNotes'], + (table: 'dive_center_gear_notes', pk: 'id'), + ); + expect( + SyncDataSerializer.parentGatedChildEntities, + contains('diveCenterGearNotes'), + ); + expect( + SyncDataSerializer.parentGatedTables['diveCenterGearNotes'], + 'dive_center_gear_notes', + ); + expect(SyncService.entityHasUpdatedAt['diveCenterGearNotes'], isFalse); + expect(SyncService.parentRefs['diveCenterGearNotes'], [ + (field: 'diveCenterId', parent: 'diveCenters', nullable: false), + (field: 'diveId', parent: 'dives', nullable: true), + ]); + }); + + test('SyncData carries the entity right after diveCenters', () { + final keys = const SyncData().toJson().keys.toList(); + expect(keys.indexOf('diveCenterGearNotes'), keys.indexOf('diveCenters') + 1); + expect( + SyncDataSerializer.debugBaseTableKeys, + contains('diveCenterGearNotes'), + ); + expect( + SyncData.fromJson({ + 'diveCenterGearNotes': [noteJson('n1')], + }).diveCenterGearNotes, + hasLength(1), + ); + }); + + test('round-trips through upsertRecord, fetchRecord, deleteRecord', () async { + await insertCenter('c1'); + await serializer.upsertRecord('diveCenterGearNotes', noteJson('n1')); + + final row = await serializer.fetchRecord('diveCenterGearNotes', 'n1'); + expect(row, isNotNull); + expect(row!['diveCenterId'], 'c1'); + expect(row['gearType'], 'regulator'); + expect(row['verdict'], 'avoid'); + expect(row['label'], '14'); + expect(row['note'], 'wet'); + + await serializer.deleteRecord('diveCenterGearNotes', 'n1'); + expect(await serializer.fetchRecord('diveCenterGearNotes', 'n1'), isNull); + }); + + test('round-trips through the batch paths and recordIdsFor', () async { + await insertCenter('c1'); + await serializer.upsertRecords('diveCenterGearNotes', [ + noteJson('n1'), + noteJson('n2'), + ]); + final fetched = await serializer.fetchRecords('diveCenterGearNotes', [ + 'n1', + 'n2', + ]); + expect(fetched.keys, containsAll(['n1', 'n2'])); + expect( + await serializer.recordIdsFor('diveCenterGearNotes'), + containsAll(['n1', 'n2']), + ); + }); + + test('a note whose center moved is exported by watermark', () async { + await insertCenter('c1'); + await serializer.upsertRecord('diveCenterGearNotes', noteJson('n1')); + // Stamp the center so its hlc is above the old watermark. + await SyncRepository().markRecordPending( + entityType: 'diveCenters', + recordId: 'c1', + localUpdatedAt: 2000, + ); + final since = await serializer.exportChangeset( + deviceId: 'dev', + hlcWatermark: '000000000000000:000000:0', + deletions: const [], + ); + expect( + since.data.diveCenterGearNotes.map((r) => r['id']), + contains('n1'), + ); + final base = await serializer.exportChangeset( + deviceId: 'dev', + hlcWatermark: null, + deletions: const [], + ); + expect(base.data.diveCenterGearNotes.map((r) => r['id']), contains('n1')); + }); +} +``` + +- [ ] **Step 2: Run it and the pin tests to verify they fail** + +Run: `flutter test test/core/services/sync/dive_center_gear_notes_sync_test.dart test/core/services/sync/sync_hlc_target_registration_test.dart` +Expected: FAIL: `hlcTargets['diveCenterGearNotes']` is null, `SyncData` has no such getter (compile error in the test is the expected red here), the census test names the unregistered table. + +- [ ] **Step 3: Register the HLC target** + +In `lib/core/data/repositories/sync_repository.dart`, in `hlcTargets`, after `'weightPresetEntries': (table: 'weight_preset_entries', pk: 'id'),` add: + +```dart + 'diveCenterGearNotes': (table: 'dive_center_gear_notes', pk: 'id'), +``` + +- [ ] **Step 4: Carry the entity in `SyncData`** + +In `lib/core/services/sync/sync_data_serializer.dart`, make these edits, each immediately after the `diveCenters` line at the quoted location: + +After ` final List> diveCenters;` (~284): + +```dart + final List> diveCenterGearNotes; +``` + +After ` this.diveCenters = const [],` (~379): + +```dart + this.diveCenterGearNotes = const [], +``` + +After ` 'diveCenters': diveCenters,` in `toJson` (~469): + +```dart + 'diveCenterGearNotes': diveCenterGearNotes, +``` + +After ` diveCenters: _parseList(json['diveCenters']),` in `fromJson` (~560): + +```dart + diveCenterGearNotes: _parseList(json['diveCenterGearNotes']), +``` + +After ` (key: 'diveCenters', table: _db.diveCenters, blob: false, full: null),` in `_baseTables` (~894): + +```dart + ( + key: 'diveCenterGearNotes', + table: _db.diveCenterGearNotes, + blob: false, + full: null, + ), +``` + +In `_buildSyncData`, after the `diveCenters: await _safeExport(...)` argument (~1776 to 1779): + +```dart + diveCenterGearNotes: await _safeExport( + 'diveCenterGearNotes', + () async => _withPendingChildren( + 'diveCenterGearNotes', + await _exportDiveCenterGearNotes(hlcSince), + pendingChildren, + ), + ), +``` + +- [ ] **Step 5: Parent-gated sets and the export query** + +In `parentGatedChildEntities` add `'diveCenterGearNotes',` after `'weightPresetEntries',`. In `parentGatedTables` add `'diveCenterGearNotes': 'dive_center_gear_notes',` after the `weightPresetEntries` entry. Nothing goes in `_parentGatedKeyColumns` (single `id` key). + +Next to `_exportDiveCenters` add: + +```dart + /// Rental gear notes ride their center: an incremental export re-sends + /// the whole note set of every center whose hlc moved, mirroring + /// [_exportWeightPresetEntries]. A note edited on its own reaches peers + /// through the pending-children path. + Future>> _exportDiveCenterGearNotes( + String? hlcSince, + ) async { + if (hlcSince != null) { + final modified = await (_db.select( + _db.diveCenters, + )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); + final ids = modified.map((c) => c.id).toSet(); + if (ids.isEmpty) return []; + final rows = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.diveCenterId.isIn(ids))).get(); + return rows.map((r) => r.toJson()).toList(); + } + final rows = await _db.select(_db.diveCenterGearNotes).get(); + return rows.map((r) => r.toJson()).toList(); + } +``` + +- [ ] **Step 6: The per-record switches** + +Add a `case 'diveCenterGearNotes':` arm directly after the `case 'diveCenters':` arm in each of these switches, shaped like the `weightPresetEntries` arms: + +`fetchRecord` (~2339): + +```dart + case 'diveCenterGearNotes': + final row = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); +``` + +`fetchRecords` (~2741): + +```dart + case 'diveCenterGearNotes': + final rows = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.isIn(idList))).get(); + return {for (final r in rows) r.id: r.toJson()}; +``` + +`upsertRecord` (~3630): + +```dart + case 'diveCenterGearNotes': + await _db + .into(_db.diveCenterGearNotes) + .insertOnConflictUpdate(DiveCenterGearNoteRow.fromJson(data)); + return; +``` + +`upsertRecords` (~4480): + +```dart + case 'diveCenterGearNotes': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.diveCenterGearNotes, + records.map((r) => DiveCenterGearNoteRow.fromJson(r)).toList(), + ), + ); + return; +``` + +Id selector (~5212): + +```dart + case 'diveCenterGearNotes': + return plain(_db.diveCenterGearNotes, _db.diveCenterGearNotes.id); +``` + +`TableInfo` switch (~5596): + +```dart + case 'diveCenterGearNotes': + return _db.diveCenterGearNotes; +``` + +`deleteRecord` (~5941): + +```dart + case 'diveCenterGearNotes': + await (_db.delete( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(recordId))).go(); + return; +``` + +If the variable names in a switch differ from `recordId`, `idList`, `data` or `records` at that location, use the names the neighbouring `diveCenters` arm uses. If `DiveCenterGearNoteRow.fromJson(data)` is rejected by `insertOnConflictUpdate`, append `.toCompanion(false)` as the `diveCenters` arm does. + +- [ ] **Step 7: The service** + +In `lib/core/services/sync/sync_service.dart`: + +After `(type: 'diveCenters', records: data.diveCenters, hasUpdatedAt: true),` (~1274): + +```dart + // Child of diveCenters (and optionally dives); applied after both + // so the deferred-FK commit sees the parent rows. + ( + type: 'diveCenterGearNotes', + records: data.diveCenterGearNotes, + hasUpdatedAt: false, + ), +``` + +If `dives` is applied later in that list than `diveCenters`, move the new entry to directly after the `dives` entry instead, and keep the comment. + +After `'diveCenters': true,` in `entityHasUpdatedAt` (~2297): + +```dart + 'diveCenterGearNotes': false, +``` + +In `parentRefs`, after the `'dives': [...]` entry (~2435): + +```dart + 'diveCenterGearNotes': [ + (field: 'diveCenterId', parent: 'diveCenters', nullable: false), + (field: 'diveId', parent: 'dives', nullable: true), + ], +``` + +- [ ] **Step 8: The completeness test's table map** + +In `test/core/services/sync/sync_parent_refs_completeness_test.dart`, in `syncedTables`, after `'dive_centers': 'diveCenters',` add: + +```dart + 'dive_center_gear_notes': 'diveCenterGearNotes', +``` + +- [ ] **Step 9: Run every sync pin test** + +Run: `flutter test test/core/services/sync/dive_center_gear_notes_sync_test.dart test/core/services/sync/sync_hlc_target_registration_test.dart test/core/services/sync/child_hlc_test.dart test/core/services/sync/pending_child_export_test.dart test/core/services/sync/sync_base_streaming_parity_test.dart test/core/services/sync/base_publish_streaming_parity_test.dart test/core/services/sync/sync_parent_refs_completeness_test.dart` +Expected: all PASS. A failure in the two parity tests means the new key's position differs between `toJson` and `_baseTables`; put it directly after `diveCenters` in both. + +- [ ] **Step 10: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/core/data/repositories/sync_repository.dart lib/core/services/sync/sync_data_serializer.dart lib/core/services/sync/sync_service.dart test/core/services/sync/sync_parent_refs_completeness_test.dart test/core/services/sync/dive_center_gear_notes_sync_test.dart +git commit -m "feat(sync): carry dive center gear notes as a parent-gated child (#2075)" +``` + +--- + +### Task 4: Repository, the latest-dive query and the providers + +**Files:** +- Create: `lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart` +- Modify: `lib/features/dive_centers/data/repositories/dive_center_repository.dart` (`deleteDiveCenter` ~line 222; new method after `getDiveCountForCenter` ~line 273) +- Create: `lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart` +- Test: `test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart` (create) +- Test: `test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart` (create) + +**Interfaces:** +- Consumes: Task 1's `DiveCenterGearNote`, `RentalVerdict`, `LastDiveAtCenter`; Task 2's table; `DiveRepository.getDiveById` and `watchDivesChanges` (existing); `SyncRepository.markRecordPending` and `logDeletion` (existing). +- Produces: `class DiveCenterGearNoteRepository` with `Stream watchChanges()`, `Future> getForCenter(String centerId)` (newest `notedAt` first), `Future getById(String id)`, `Future create(DiveCenterGearNote note)` (assigns the id when `note.id` is empty), `Future update(DiveCenterGearNote note)`, `Future delete(String id)`; `DiveCenterRepository.latestDiveIdAtCenter(String centerId, {String? excludingDiveId})` returning `Future`; providers `diveCenterGearNoteRepositoryProvider`, `diveCenterGearNotesProvider(centerId)` (`FutureProvider.family, String>`), `typedef LastDiveAtCenterQuery = ({String centerId, String? excludingDiveId})`, `lastDiveAtCenterProvider(query)` (`FutureProvider.family`). + +- [ ] **Step 1: Write the failing repository test** + +Create `test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveCenterGearNoteRepository repository; + const stale = 1000; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveCenterGearNoteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future seedCenter(String id) => db + .into(db.diveCenters) + .insert( + DiveCentersCompanion( + id: Value(id), + name: Value(id), + createdAt: const Value(stale), + updatedAt: const Value(stale), + ), + ); + + Future seedDive(String id, {String? centerId}) => db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: const Value(stale), + diveCenterId: Value(centerId), + createdAt: const Value(stale), + updatedAt: const Value(stale), + ), + ); + + DiveCenterGearNote note({ + String id = '', + String centerId = 'c1', + EquipmentType type = EquipmentType.regulator, + String? diveId, + DateTime? notedAt, + }) { + final now = DateTime.utc(2026, 9, 18, 10); + return DiveCenterGearNote( + id: id, + diveCenterId: centerId, + gearType: type, + label: '14', + verdict: RentalVerdict.avoid, + leadAdjustmentKg: 2, + note: 'wet', + diveId: diveId, + notedAt: notedAt ?? now, + createdAt: now, + updatedAt: now, + ); + } + + Future pendingCountFor(String recordId) async => + (await db + .customSelect( + "SELECT COUNT(*) AS n FROM sync_records WHERE entity_type = " + "'diveCenterGearNotes' AND record_id = ? " + "AND sync_status = 'pending'", + variables: [Variable(recordId)], + ) + .getSingle()) + .read('n'); + + Future tombstoneCountFor(String recordId) async => + (await db + .customSelect( + 'SELECT COUNT(*) AS n FROM deletion_log WHERE entity_type = ' + "'diveCenterGearNotes' AND record_id = ?", + variables: [Variable(recordId)], + ) + .getSingle()) + .read('n'); + + test('create assigns an id, stores every field and marks pending', () async { + await seedCenter('c1'); + await seedDive('d1', centerId: 'c1'); + + final created = await repository.create(note(diveId: 'd1')); + + expect(created.id, isNotEmpty); + final stored = await repository.getById(created.id); + expect(stored, isNotNull); + expect(stored!.diveCenterId, 'c1'); + expect(stored.gearType, EquipmentType.regulator); + expect(stored.label, '14'); + expect(stored.size, isNull); + expect(stored.verdict, RentalVerdict.avoid); + expect(stored.leadAdjustmentKg, 2); + expect(stored.volumeLiters, isNull); + expect(stored.note, 'wet'); + expect(stored.diveId, 'd1'); + expect(stored.notedAt, DateTime.utc(2026, 9, 18, 10)); + expect(await pendingCountFor(created.id), 1); + }); + + test('getForCenter lists newest noted first and only that center', () async { + await seedCenter('c1'); + await seedCenter('c2'); + final older = await repository.create( + note(notedAt: DateTime.utc(2026, 1, 1)), + ); + final newer = await repository.create( + note(notedAt: DateTime.utc(2026, 6, 1), type: EquipmentType.bcd), + ); + await repository.create(note(centerId: 'c2')); + + final notes = await repository.getForCenter('c1'); + expect(notes.map((n) => n.id), [newer.id, older.id]); + }); + + test('update rewrites the row and marks it pending again', () async { + await seedCenter('c1'); + final created = await repository.create(note()); + + await repository.update( + created.copyWith( + verdict: RentalVerdict.worked, + size: 'L', + clearLeadAdjustment: true, + volumeLiters: 11.1, + note: 'fine after service', + ), + ); + + final stored = await repository.getById(created.id); + expect(stored!.verdict, RentalVerdict.worked); + expect(stored.size, 'L'); + expect(stored.leadAdjustmentKg, isNull); + expect(stored.volumeLiters, 11.1); + expect(stored.note, 'fine after service'); + expect(stored.updatedAt.isBefore(created.updatedAt), isFalse); + expect(await pendingCountFor(created.id), 1); + }); + + test('delete removes the row and writes a tombstone', () async { + await seedCenter('c1'); + final created = await repository.create(note()); + + await repository.delete(created.id); + + expect(await repository.getById(created.id), isNull); + expect(await tombstoneCountFor(created.id), 1); + }); + + test('deleting the dive it was noted on keeps the note, detached', () async { + await seedCenter('c1'); + await seedDive('d1', centerId: 'c1'); + final created = await repository.create(note(diveId: 'd1')); + + await (db.delete(db.dives)..where((t) => t.id.equals('d1'))).go(); + + final stored = await repository.getById(created.id); + expect(stored, isNotNull); + expect(stored!.diveId, isNull); + }); + + test('deleting the center cascades its notes and tombstones each', () async { + await seedCenter('c1'); + final a = await repository.create(note()); + final b = await repository.create(note(type: EquipmentType.wetsuit)); + + await DiveCenterRepository().deleteDiveCenter('c1'); + + expect(await repository.getForCenter('c1'), isEmpty); + expect(await tombstoneCountFor(a.id), 1); + expect(await tombstoneCountFor(b.id), 1); + }); + + test('watchChanges emits after a write', () async { + await seedCenter('c1'); + final events = []; + final sub = repository.watchChanges().listen(events.add); + addTearDown(sub.cancel); + + await repository.create(note()); + await Future.delayed(Duration.zero); + + expect(events, isNotEmpty); + }); +} +``` + +- [ ] **Step 2: Write the failing latest-dive test** + +Create `test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart`: + +```dart +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +/// The "last time here" lookup (issue #2075): the newest real dive logged +/// with a center, skipping the dive being edited and planned dives. +void main() { + late AppDatabase db; + late DiveCenterRepository repository; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveCenterRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future seedCenter(String id) => db + .into(db.diveCenters) + .insert( + DiveCentersCompanion( + id: Value(id), + name: Value(id), + createdAt: const Value(0), + updatedAt: const Value(0), + ), + ); + + Future seedDive( + String id, { + required String centerId, + required int at, + bool planned = false, + }) => db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(at), + diveCenterId: Value(centerId), + isPlanned: Value(planned), + createdAt: const Value(0), + updatedAt: const Value(0), + ), + ); + + test('returns the newest dive at the center', () async { + await seedCenter('c1'); + await seedCenter('c2'); + await seedDive('old', centerId: 'c1', at: 1000); + await seedDive('new', centerId: 'c1', at: 3000); + await seedDive('other', centerId: 'c2', at: 9000); + + expect(await repository.latestDiveIdAtCenter('c1'), 'new'); + }); + + test('skips the dive being edited', () async { + await seedCenter('c1'); + await seedDive('old', centerId: 'c1', at: 1000); + await seedDive('new', centerId: 'c1', at: 3000); + + expect( + await repository.latestDiveIdAtCenter('c1', excludingDiveId: 'new'), + 'old', + ); + }); + + test('skips planned dives and returns null when nothing is left', () async { + await seedCenter('c1'); + await seedDive('plan', centerId: 'c1', at: 5000, planned: true); + + expect(await repository.latestDiveIdAtCenter('c1'), isNull); + }); +} +``` + +If `DivesCompanion` has no `isPlanned` field, grep `database.dart` for `is_planned` to find the Drift column name (for example `isPlanned` or `planned`) and use it. + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `flutter test test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart` +Expected: FAIL to compile: no `DiveCenterGearNoteRepository`, no `latestDiveIdAtCenter`. + +- [ ] **Step 4: Write the repository** + +Create `lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart`: + +```dart +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +/// CRUD for a dive center's rental gear notes (issue #2075). Mirrors +/// [WeightPresetRepository]: HLC-stamped writes, a change stream for the +/// providers, and per-row sync bookkeeping after the write. +class DiveCenterGearNoteRepository { + AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository = SyncRepository(); + final _uuid = const Uuid(); + final _log = LoggerService.forClass(DiveCenterGearNoteRepository); + + Stream watchChanges() => + _db.tableUpdates(TableUpdateQuery.onTable(_db.diveCenterGearNotes)); + + /// The center's notes, newest observation first. + Future> getForCenter(String centerId) async { + final rows = + await (_db.select(_db.diveCenterGearNotes) + ..where((t) => t.diveCenterId.equals(centerId)) + ..orderBy([ + (t) => OrderingTerm.desc(t.notedAt), + (t) => OrderingTerm.desc(t.createdAt), + ])) + .get(); + return rows.map(fromRow).toList(); + } + + Future getById(String id) async { + final row = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + return row == null ? null : fromRow(row); + } + + /// Insert [note]; an empty id is replaced with a fresh uuid. + Future create(DiveCenterGearNote note) async { + try { + final id = note.id.isEmpty ? _uuid.v4() : note.id; + final now = DateTime.now().millisecondsSinceEpoch; + await _db + .into(_db.diveCenterGearNotes) + .insert( + DiveCenterGearNotesCompanion( + id: Value(id), + diveCenterId: Value(note.diveCenterId), + gearType: Value(note.gearType.name), + label: Value(_trimmedOrNull(note.label)), + size: Value(_trimmedOrNull(note.size)), + verdict: Value(note.verdict.name), + leadAdjustmentKg: Value(note.leadAdjustmentKg), + volumeLiters: Value(note.volumeLiters), + note: Value(note.note.trim()), + diveId: Value(note.diveId), + notedAt: Value(note.notedAt.millisecondsSinceEpoch), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveCenterGearNotes', + recordId: id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + return (await getById(id))!; + } catch (e, stackTrace) { + _log.error( + 'Failed to create rental gear note for ${note.diveCenterId}', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future update(DiveCenterGearNote note) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(note.id))).write( + DiveCenterGearNotesCompanion( + gearType: Value(note.gearType.name), + label: Value(_trimmedOrNull(note.label)), + size: Value(_trimmedOrNull(note.size)), + verdict: Value(note.verdict.name), + leadAdjustmentKg: Value(note.leadAdjustmentKg), + volumeLiters: Value(note.volumeLiters), + note: Value(note.note.trim()), + diveId: Value(note.diveId), + notedAt: Value(note.notedAt.millisecondsSinceEpoch), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveCenterGearNotes', + recordId: note.id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to update rental gear note ${note.id}', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future delete(String id) async { + try { + await _db.transaction(() async { + await (_db.delete( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(id))).go(); + await _syncRepository.logDeletion( + entityType: 'diveCenterGearNotes', + recordId: id, + ); + }); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to delete rental gear note $id', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + static String? _trimmedOrNull(String? text) { + final trimmed = text?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; + } + + static DiveCenterGearNote fromRow(DiveCenterGearNoteRow row) => + DiveCenterGearNote( + id: row.id, + diveCenterId: row.diveCenterId, + gearType: DiveCenterGearNote.gearTypeFromName(row.gearType), + label: row.label, + size: row.size, + verdict: DiveCenterGearNote.verdictFromName(row.verdict), + leadAdjustmentKg: row.leadAdjustmentKg, + volumeLiters: row.volumeLiters, + note: row.note, + diveId: row.diveId, + notedAt: DateTime.fromMillisecondsSinceEpoch(row.notedAt, isUtc: true), + createdAt: DateTime.fromMillisecondsSinceEpoch( + row.createdAt, + isUtc: true, + ), + updatedAt: DateTime.fromMillisecondsSinceEpoch( + row.updatedAt, + isUtc: true, + ), + ); +} +``` + +- [ ] **Step 5: The center repository: latest dive, and tombstones on center delete** + +In `lib/features/dive_centers/data/repositories/dive_center_repository.dart`, after `getDiveCountForCenter` add: + +```dart + /// The newest real dive logged with [centerId], skipping planned dives + /// and [excludingDiveId] (the dive being edited), for the "last time + /// here" card (issue #2075). Null when the diver has no other dive there. + Future latestDiveIdAtCenter( + String centerId, { + String? excludingDiveId, + }) async { + final rows = await _db + .customSelect( + ''' + SELECT id FROM dives + WHERE dive_center_id = ? + AND is_planned = 0 + AND (? IS NULL OR id <> ?) + ORDER BY dive_date_time DESC, id DESC + LIMIT 1 + ''', + variables: [ + Variable.withString(centerId), + Variable(excludingDiveId), + Variable(excludingDiveId), + ], + ) + .get(); + return rows.isEmpty ? null : rows.single.read('id'); + } +``` + +Then in `deleteDiveCenter`, inside the transaction and before the `_db.delete(_db.diveCenters)` line, add the note tombstones (SQLite cascades emit no deletion-log rows, so a peer would resurrect the notes): + +```dart + final notes = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.diveCenterId.equals(id))).get(); +``` + +and after the existing `logDeletion(entityType: 'diveCenters', ...)` call, still inside the transaction: + +```dart + for (final note in notes) { + await _syncRepository.logDeletion( + entityType: 'diveCenterGearNotes', + recordId: note.id, + ); + } +``` + +Update the method's doc comment to mention that its rental gear notes are cascaded and tombstoned. + +- [ ] **Step 6: Run the repository tests** + +Run: `flutter test test/features/dive_centers/data/repositories/` +Expected: all PASS, including the pre-existing dive center repository tests. + +- [ ] **Step 7: Write the providers** + +Create `lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart`: + +```dart +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; + +final diveCenterGearNoteRepositoryProvider = + Provider((ref) { + return DiveCenterGearNoteRepository(); + }); + +/// A center's rental gear notes, newest first. Self-invalidates on any +/// write to the table, local or synced. +final diveCenterGearNotesProvider = + FutureProvider.family, String>(( + ref, + centerId, + ) async { + final repository = ref.watch(diveCenterGearNoteRepositoryProvider); + ref.invalidateSelfWhen(repository.watchChanges()); + return repository.getForCenter(centerId); + }); + +/// Which center, and which dive to leave out (the one being edited). +typedef LastDiveAtCenterQuery = ({String centerId, String? excludingDiveId}); + +/// The diver's most recent other dive at a center, fully hydrated, or null. +/// Reads the `dives` table, so it self-invalidates on dive changes. +final lastDiveAtCenterProvider = + FutureProvider.family(( + ref, + query, + ) async { + final centers = ref.watch(diveCenterRepositoryProvider); + final dives = ref.watch(diveRepositoryProvider); + ref.invalidateSelfWhen(dives.watchDivesChanges()); + final id = await centers.latestDiveIdAtCenter( + query.centerId, + excludingDiveId: query.excludingDiveId, + ); + if (id == null) return null; + final dive = await dives.getDiveById(id); + return dive == null ? null : LastDiveAtCenter.fromDive(dive); + }); +``` + +If `invalidateSelfWhen` is not reachable through `core/providers/provider.dart`, add `import 'package:submersion/core/providers/ref_invalidate_on_change.dart';`. + +- [ ] **Step 8: Analyze, format and commit** + +```bash +flutter analyze +dart format . +git add lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart lib/features/dive_centers/data/repositories/dive_center_repository.dart lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart +git commit -m "feat(dive-centers): rental gear note repository, latest-dive lookup and providers (#2075)" +``` + +--- + +### Task 5: Localized strings in all 11 locales + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb`, `app_ar.arb`, `app_de.arb`, `app_es.arb`, `app_fr.arb`, `app_he.arb`, `app_hu.arb`, `app_it.arb`, `app_nl.arb`, `app_pt.arb`, `app_zh.arb` +- Regenerate: `lib/l10n/arb/app_localizations*.dart` (12 files, committed) +- Test: `test/l10n/rental_gear_memory_strings_test.dart` (create) + +**Interfaces:** +- Produces the `AppLocalizations` getters listed in the table below. Later tasks call them through `context.l10n`. + +| Key | English | +| --- | --- | +| `diveCenters_rental_sectionTitle` | Rental gear | +| `diveCenters_rental_lastTimeAt` | Last time at {center} | +| `diveCenters_rental_lastDiveOn` | Last dive here: {date} | +| `diveCenters_rental_noHistory` | No other dives logged here yet. | +| `diveCenters_rental_empty` | No rental notes for this center yet. | +| `diveCenters_rental_addNote` | Add rental note | +| `diveCenters_rental_applyLastDive` | Apply last dive | +| `diveCenters_rental_applied` | Weights and tanks copied from your last dive here. | +| `diveCenters_rental_applyConfirmTitle` | Replace weights and tanks? | +| `diveCenters_rental_applyConfirmBody` | This dive already has weights or tanks. Replace them with the ones from your last dive here? | +| `diveCenters_rental_applyConfirmReplace` | Replace | +| `diveCenters_rental_leadTotal` | Lead: {total} | +| `diveCenters_rental_feedbackOver` | {amount} over | +| `diveCenters_rental_feedbackUnder` | {amount} under | +| `diveCenters_rental_tanksLabel` | Tanks | +| `diveCenters_rental_verdictWorked` | Worked | +| `diveCenters_rental_verdictAvoid` | Avoid | +| `diveCenters_rental_extraLead` | {amount} extra lead | +| `diveCenters_rental_lessLead` | {amount} less lead | +| `diveCenters_rental_actualCapacity` | Actual capacity {volume} | +| `diveCenters_rental_sheetTitleNew` | New rental note | +| `diveCenters_rental_sheetTitleEdit` | Edit rental note | +| `diveCenters_rental_gearTypeLabel` | Gear type | +| `diveCenters_rental_labelLabel` | Label or number | +| `diveCenters_rental_sizeLabel` | Size | +| `diveCenters_rental_leadAdjustmentLabel` | Extra lead needed ({unit}) | +| `diveCenters_rental_volumeLabel` | Actual capacity ({unit}) | +| `diveCenters_rental_noteLabel` | Note | +| `diveCenters_rental_deleteConfirm` | Delete this rental note? | + +Existing keys reused, not added: `common_action_save`, `common_action_cancel`, `common_action_delete`, `diveLog_edit_weightFeedback_correct`, `diveLog_edit_weightFeedback_over`, `diveLog_edit_weightFeedback_under`. + +- [ ] **Step 1: Write the failing strings test** + +Create `test/l10n/rental_gear_memory_strings_test.dart`: + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Every rental gear memory string (issue #2075) exists in every locale, so +/// gen-l10n never falls back to English for one of them. +void main() { + const keys = [ + 'diveCenters_rental_sectionTitle', + 'diveCenters_rental_lastTimeAt', + 'diveCenters_rental_lastDiveOn', + 'diveCenters_rental_noHistory', + 'diveCenters_rental_empty', + 'diveCenters_rental_addNote', + 'diveCenters_rental_applyLastDive', + 'diveCenters_rental_applied', + 'diveCenters_rental_applyConfirmTitle', + 'diveCenters_rental_applyConfirmBody', + 'diveCenters_rental_applyConfirmReplace', + 'diveCenters_rental_leadTotal', + 'diveCenters_rental_feedbackOver', + 'diveCenters_rental_feedbackUnder', + 'diveCenters_rental_tanksLabel', + 'diveCenters_rental_verdictWorked', + 'diveCenters_rental_verdictAvoid', + 'diveCenters_rental_extraLead', + 'diveCenters_rental_lessLead', + 'diveCenters_rental_actualCapacity', + 'diveCenters_rental_sheetTitleNew', + 'diveCenters_rental_sheetTitleEdit', + 'diveCenters_rental_gearTypeLabel', + 'diveCenters_rental_labelLabel', + 'diveCenters_rental_sizeLabel', + 'diveCenters_rental_leadAdjustmentLabel', + 'diveCenters_rental_volumeLabel', + 'diveCenters_rental_noteLabel', + 'diveCenters_rental_deleteConfirm', + ]; + const locales = ['ar', 'de', 'en', 'es', 'fr', 'he', 'hu', 'it', 'nl', 'pt', 'zh']; + + for (final locale in locales) { + test('app_$locale.arb carries every rental gear memory key', () { + final file = File('lib/l10n/arb/app_$locale.arb'); + final json = jsonDecode(file.readAsStringSync()) as Map; + final missing = [ + for (final key in keys) + if (json[key] is! String || (json[key] as String).isEmpty) key, + ]; + expect(missing, isEmpty, reason: 'missing in $locale'); + }); + } + + test('the English placeholders are declared', () { + final json = + jsonDecode(File('lib/l10n/arb/app_en.arb').readAsStringSync()) + as Map; + for (final key in const [ + 'diveCenters_rental_lastTimeAt', + 'diveCenters_rental_lastDiveOn', + 'diveCenters_rental_leadTotal', + 'diveCenters_rental_feedbackOver', + 'diveCenters_rental_feedbackUnder', + 'diveCenters_rental_extraLead', + 'diveCenters_rental_lessLead', + 'diveCenters_rental_actualCapacity', + 'diveCenters_rental_leadAdjustmentLabel', + 'diveCenters_rental_volumeLabel', + ]) { + expect(json['@$key'], isA>(), reason: key); + expect( + (json['@$key'] as Map)['placeholders'], + isNotNull, + reason: key, + ); + } + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/l10n/rental_gear_memory_strings_test.dart` +Expected: FAIL, 29 keys missing in every locale. + +- [ ] **Step 3: Append the strings with a script** + +Save this as `/tmp/add_rental_strings.py` (a scratch file, never committed) and run it with `python3.14 /tmp/add_rental_strings.py` from the worktree root. It appends each key textually before the closing brace of each ARB, adds `@` placeholder metadata to `app_en.arb` only, and proves every file still parses. + +```python +import json +from pathlib import Path + +ARB = Path("lib/l10n/arb") + +STRINGS = { + "diveCenters_rental_sectionTitle": { + "en": "Rental gear", "ar": "معدات مستأجرة", "de": "Leihausrüstung", "es": "Equipo de alquiler", + "fr": "Matériel de location", "he": "ציוד מושכר", "hu": "Bérelt felszerelés", "it": "Attrezzatura a noleggio", + "nl": "Huuruitrusting", "pt": "Equipamento alugado", "zh": "租赁装备"}, + "diveCenters_rental_lastTimeAt": { + "en": "Last time at {center}", "ar": "آخر مرة في {center}", "de": "Letztes Mal bei {center}", + "es": "La última vez en {center}", "fr": "La dernière fois chez {center}", "he": "בפעם הקודמת ב-{center}", + "hu": "Legutóbb itt: {center}", "it": "L'ultima volta da {center}", "nl": "Vorige keer bij {center}", + "pt": "Da última vez em {center}", "zh": "上次在{center}"}, + "diveCenters_rental_lastDiveOn": { + "en": "Last dive here: {date}", "ar": "آخر غوصة هنا: {date}", "de": "Letzter Tauchgang hier: {date}", + "es": "Última inmersión aquí: {date}", "fr": "Dernière plongée ici : {date}", "he": "הצלילה האחרונה כאן: {date}", + "hu": "Legutóbbi merülés itt: {date}", "it": "Ultima immersione qui: {date}", "nl": "Laatste duik hier: {date}", + "pt": "Último mergulho aqui: {date}", "zh": "上次在此潜水:{date}"}, + "diveCenters_rental_noHistory": { + "en": "No other dives logged here yet.", "ar": "لا توجد غوصات أخرى مسجلة هنا بعد.", + "de": "Hier sind noch keine weiteren Tauchgänge eingetragen.", "es": "Aún no hay otras inmersiones registradas aquí.", + "fr": "Aucune autre plongée enregistrée ici pour le moment.", "he": "עדיין לא נרשמו כאן צלילות נוספות.", + "hu": "Itt még nincs más rögzített merülés.", "it": "Nessun'altra immersione registrata qui.", + "nl": "Hier zijn nog geen andere duiken gelogd.", "pt": "Ainda não há outros mergulhos registados aqui.", + "zh": "此处尚无其他潜水记录。"}, + "diveCenters_rental_empty": { + "en": "No rental notes for this center yet.", "ar": "لا توجد ملاحظات عن المعدات المستأجرة لهذا المركز بعد.", + "de": "Noch keine Notizen zur Leihausrüstung dieses Centers.", "es": "Aún no hay notas de alquiler para este centro.", + "fr": "Aucune note de location pour ce centre pour le moment.", "he": "עדיין אין הערות על ציוד מושכר במרכז הזה.", + "hu": "Ehhez a központhoz még nincs bérlési jegyzet.", "it": "Nessuna nota sul noleggio per questo centro.", + "nl": "Nog geen huurnotities voor dit centrum.", "pt": "Ainda não há notas de aluguer para este centro.", + "zh": "此中心尚无租赁备注。"}, + "diveCenters_rental_addNote": { + "en": "Add rental note", "ar": "إضافة ملاحظة تأجير", "de": "Leihnotiz hinzufügen", "es": "Añadir nota de alquiler", + "fr": "Ajouter une note de location", "he": "הוספת הערת השכרה", "hu": "Bérlési jegyzet hozzáadása", + "it": "Aggiungi nota noleggio", "nl": "Huurnotitie toevoegen", "pt": "Adicionar nota de aluguer", "zh": "添加租赁备注"}, + "diveCenters_rental_applyLastDive": { + "en": "Apply last dive", "ar": "تطبيق آخر غوصة", "de": "Letzten Tauchgang übernehmen", "es": "Aplicar última inmersión", + "fr": "Reprendre la dernière plongée", "he": "החלת הצלילה האחרונה", "hu": "Legutóbbi merülés alkalmazása", + "it": "Applica ultima immersione", "nl": "Laatste duik toepassen", "pt": "Aplicar último mergulho", "zh": "套用上次潜水"}, + "diveCenters_rental_applied": { + "en": "Weights and tanks copied from your last dive here.", "ar": "تم نسخ الأثقال والأسطوانات من آخر غوصة لك هنا.", + "de": "Blei und Flaschen vom letzten Tauchgang hier übernommen.", "es": "Lastre y botellas copiados de tu última inmersión aquí.", + "fr": "Lest et blocs repris de votre dernière plongée ici.", "he": "המשקולות והמכלים הועתקו מהצלילה האחרונה שלך כאן.", + "hu": "Az ólom és a palackok átmásolva az itteni legutóbbi merülésedből.", "it": "Zavorra e bombole copiate dalla tua ultima immersione qui.", + "nl": "Lood en flessen overgenomen van je laatste duik hier.", "pt": "Lastro e garrafas copiados do seu último mergulho aqui.", + "zh": "已从你上次在此的潜水复制配重和气瓶。"}, + "diveCenters_rental_applyConfirmTitle": { + "en": "Replace weights and tanks?", "ar": "استبدال الأثقال والأسطوانات؟", "de": "Blei und Flaschen ersetzen?", + "es": "¿Reemplazar lastre y botellas?", "fr": "Remplacer le lest et les blocs ?", "he": "להחליף משקולות ומכלים?", + "hu": "Lecseréled az ólmot és a palackokat?", "it": "Sostituire zavorra e bombole?", "nl": "Lood en flessen vervangen?", + "pt": "Substituir lastro e garrafas?", "zh": "替换配重和气瓶?"}, + "diveCenters_rental_applyConfirmBody": { + "en": "This dive already has weights or tanks. Replace them with the ones from your last dive here?", + "ar": "تحتوي هذه الغوصة بالفعل على أثقال أو أسطوانات. هل تريد استبدالها بتلك الموجودة في آخر غوصة لك هنا؟", + "de": "Dieser Tauchgang hat bereits Blei oder Flaschen. Durch die vom letzten Tauchgang hier ersetzen?", + "es": "Esta inmersión ya tiene lastre o botellas. ¿Reemplazarlos por los de tu última inmersión aquí?", + "fr": "Cette plongée a déjà du lest ou des blocs. Les remplacer par ceux de votre dernière plongée ici ?", + "he": "לצלילה הזו כבר יש משקולות או מכלים. להחליף אותם באלה מהצלילה האחרונה שלך כאן?", + "hu": "Ehhez a merüléshez már tartozik ólom vagy palack. Lecseréled az itteni legutóbbi merülésedéire?", + "it": "Questa immersione ha già zavorra o bombole. Sostituirle con quelle della tua ultima immersione qui?", + "nl": "Deze duik heeft al lood of flessen. Vervangen door die van je laatste duik hier?", + "pt": "Este mergulho já tem lastro ou garrafas. Substituir pelos do seu último mergulho aqui?", + "zh": "此次潜水已有配重或气瓶。是否替换为你上次在此潜水的配置?"}, + "diveCenters_rental_applyConfirmReplace": { + "en": "Replace", "ar": "استبدال", "de": "Ersetzen", "es": "Reemplazar", "fr": "Remplacer", "he": "החלפה", + "hu": "Csere", "it": "Sostituisci", "nl": "Vervangen", "pt": "Substituir", "zh": "替换"}, + "diveCenters_rental_leadTotal": { + "en": "Lead: {total}", "ar": "الأثقال: {total}", "de": "Blei: {total}", "es": "Lastre: {total}", "fr": "Lest : {total}", + "he": "משקולות: {total}", "hu": "Ólom: {total}", "it": "Zavorra: {total}", "nl": "Lood: {total}", "pt": "Lastro: {total}", + "zh": "配重:{total}"}, + "diveCenters_rental_feedbackOver": { + "en": "{amount} over", "ar": "{amount} زيادة", "de": "{amount} zu viel", "es": "{amount} de más", "fr": "{amount} de trop", + "he": "{amount} יותר מדי", "hu": "{amount} túl sok", "it": "{amount} in più", "nl": "{amount} te veel", + "pt": "{amount} a mais", "zh": "超重{amount}"}, + "diveCenters_rental_feedbackUnder": { + "en": "{amount} under", "ar": "{amount} نقص", "de": "{amount} zu wenig", "es": "{amount} de menos", "fr": "{amount} de moins", + "he": "{amount} פחות מדי", "hu": "{amount} túl kevés", "it": "{amount} in meno", "nl": "{amount} te weinig", + "pt": "{amount} a menos", "zh": "欠重{amount}"}, + "diveCenters_rental_tanksLabel": { + "en": "Tanks", "ar": "الأسطوانات", "de": "Flaschen", "es": "Botellas", "fr": "Blocs", "he": "מכלים", "hu": "Palackok", + "it": "Bombole", "nl": "Flessen", "pt": "Garrafas", "zh": "气瓶"}, + "diveCenters_rental_verdictWorked": { + "en": "Worked", "ar": "مناسب", "de": "Hat gepasst", "es": "Funcionó", "fr": "Convenait", "he": "התאים", "hu": "Bevált", + "it": "Andava bene", "nl": "Beviel", "pt": "Funcionou", "zh": "好用"}, + "diveCenters_rental_verdictAvoid": { + "en": "Avoid", "ar": "تجنّب", "de": "Meiden", "es": "Evitar", "fr": "À éviter", "he": "להימנע", "hu": "Kerülendő", + "it": "Da evitare", "nl": "Vermijden", "pt": "Evitar", "zh": "避免"}, + "diveCenters_rental_extraLead": { + "en": "{amount} extra lead", "ar": "{amount} أثقال إضافية", "de": "{amount} mehr Blei", "es": "{amount} más de lastre", + "fr": "{amount} de lest en plus", "he": "{amount} משקולות נוספות", "hu": "{amount} plusz ólom", "it": "{amount} di zavorra in più", + "nl": "{amount} extra lood", "pt": "{amount} de lastro a mais", "zh": "多带{amount}配重"}, + "diveCenters_rental_lessLead": { + "en": "{amount} less lead", "ar": "{amount} أثقال أقل", "de": "{amount} weniger Blei", "es": "{amount} menos de lastre", + "fr": "{amount} de lest en moins", "he": "{amount} פחות משקולות", "hu": "{amount} kevesebb ólom", "it": "{amount} di zavorra in meno", + "nl": "{amount} minder lood", "pt": "{amount} de lastro a menos", "zh": "少带{amount}配重"}, + "diveCenters_rental_actualCapacity": { + "en": "Actual capacity {volume}", "ar": "السعة الفعلية {volume}", "de": "Tatsächliches Volumen {volume}", + "es": "Capacidad real {volume}", "fr": "Capacité réelle {volume}", "he": "קיבולת בפועל {volume}", + "hu": "Tényleges térfogat {volume}", "it": "Capacità reale {volume}", "nl": "Werkelijke inhoud {volume}", + "pt": "Capacidade real {volume}", "zh": "实际容量{volume}"}, + "diveCenters_rental_sheetTitleNew": { + "en": "New rental note", "ar": "ملاحظة تأجير جديدة", "de": "Neue Leihnotiz", "es": "Nueva nota de alquiler", + "fr": "Nouvelle note de location", "he": "הערת השכרה חדשה", "hu": "Új bérlési jegyzet", "it": "Nuova nota noleggio", + "nl": "Nieuwe huurnotitie", "pt": "Nova nota de aluguer", "zh": "新建租赁备注"}, + "diveCenters_rental_sheetTitleEdit": { + "en": "Edit rental note", "ar": "تعديل ملاحظة التأجير", "de": "Leihnotiz bearbeiten", "es": "Editar nota de alquiler", + "fr": "Modifier la note de location", "he": "עריכת הערת השכרה", "hu": "Bérlési jegyzet szerkesztése", + "it": "Modifica nota noleggio", "nl": "Huurnotitie bewerken", "pt": "Editar nota de aluguer", "zh": "编辑租赁备注"}, + "diveCenters_rental_gearTypeLabel": { + "en": "Gear type", "ar": "نوع المعدات", "de": "Ausrüstungstyp", "es": "Tipo de equipo", "fr": "Type de matériel", + "he": "סוג ציוד", "hu": "Felszerelés típusa", "it": "Tipo di attrezzatura", "nl": "Type uitrusting", "pt": "Tipo de equipamento", + "zh": "装备类型"}, + "diveCenters_rental_labelLabel": { + "en": "Label or number", "ar": "الملصق أو الرقم", "de": "Kennzeichnung oder Nummer", "es": "Etiqueta o número", + "fr": "Étiquette ou numéro", "he": "תווית או מספר", "hu": "Címke vagy szám", "it": "Etichetta o numero", + "nl": "Label of nummer", "pt": "Etiqueta ou número", "zh": "标签或编号"}, + "diveCenters_rental_sizeLabel": { + "en": "Size", "ar": "المقاس", "de": "Größe", "es": "Talla", "fr": "Taille", "he": "מידה", "hu": "Méret", "it": "Taglia", + "nl": "Maat", "pt": "Tamanho", "zh": "尺码"}, + "diveCenters_rental_leadAdjustmentLabel": { + "en": "Extra lead needed ({unit})", "ar": "الأثقال الإضافية المطلوبة ({unit})", "de": "Zusätzlich benötigtes Blei ({unit})", + "es": "Lastre adicional necesario ({unit})", "fr": "Lest supplémentaire nécessaire ({unit})", "he": "משקולות נוספות נדרשות ({unit})", + "hu": "Szükséges plusz ólom ({unit})", "it": "Zavorra aggiuntiva necessaria ({unit})", "nl": "Extra lood nodig ({unit})", + "pt": "Lastro adicional necessário ({unit})", "zh": "需额外配重({unit})"}, + "diveCenters_rental_volumeLabel": { + "en": "Actual capacity ({unit})", "ar": "السعة الفعلية ({unit})", "de": "Tatsächliches Volumen ({unit})", + "es": "Capacidad real ({unit})", "fr": "Capacité réelle ({unit})", "he": "קיבולת בפועל ({unit})", + "hu": "Tényleges térfogat ({unit})", "it": "Capacità reale ({unit})", "nl": "Werkelijke inhoud ({unit})", + "pt": "Capacidade real ({unit})", "zh": "实际容量({unit})"}, + "diveCenters_rental_noteLabel": { + "en": "Note", "ar": "ملاحظة", "de": "Notiz", "es": "Nota", "fr": "Note", "he": "הערה", "hu": "Jegyzet", "it": "Nota", + "nl": "Notitie", "pt": "Nota", "zh": "备注"}, + "diveCenters_rental_deleteConfirm": { + "en": "Delete this rental note?", "ar": "حذف ملاحظة التأجير هذه؟", "de": "Diese Leihnotiz löschen?", + "es": "¿Eliminar esta nota de alquiler?", "fr": "Supprimer cette note de location ?", "he": "למחוק את הערת ההשכרה הזו?", + "hu": "Törlöd ezt a bérlési jegyzetet?", "it": "Eliminare questa nota sul noleggio?", "nl": "Deze huurnotitie verwijderen?", + "pt": "Eliminar esta nota de aluguer?", "zh": "删除此租赁备注?"}, +} + +PLACEHOLDERS = { + "diveCenters_rental_lastTimeAt": ["center"], + "diveCenters_rental_lastDiveOn": ["date"], + "diveCenters_rental_leadTotal": ["total"], + "diveCenters_rental_feedbackOver": ["amount"], + "diveCenters_rental_feedbackUnder": ["amount"], + "diveCenters_rental_extraLead": ["amount"], + "diveCenters_rental_lessLead": ["amount"], + "diveCenters_rental_actualCapacity": ["volume"], + "diveCenters_rental_leadAdjustmentLabel": ["unit"], + "diveCenters_rental_volumeLabel": ["unit"], +} + +LOCALES = ["ar", "de", "en", "es", "fr", "he", "hu", "it", "nl", "pt", "zh"] + +for locale in LOCALES: + path = ARB / f"app_{locale}.arb" + src = path.read_text(encoding="utf-8") + existing = json.loads(src) + lines = [] + for key, values in STRINGS.items(): + if key in existing: + continue + lines.append(' %s: %s' % (json.dumps(key), json.dumps(values[locale], ensure_ascii=False))) + if locale == "en" and key in PLACEHOLDERS: + meta = {"placeholders": {p: {"type": "Object"} for p in PLACEHOLDERS[key]}} + lines.append(' %s: %s' % (json.dumps("@" + key), json.dumps(meta, ensure_ascii=False))) + if not lines: + continue + head, sep, tail = src.rstrip().rpartition("}") + assert sep == "}" and tail == "", path + head = head.rstrip() + if not head.endswith(","): + head += "," + out = head + "\n" + ",\n".join(lines) + "\n}\n" + json.loads(out) + path.write_text(out, encoding="utf-8") + print(locale, len(lines), "lines added") +``` + +Then check the diff is even across locales (English carries ten extra `@` lines): + +```bash +git diff --numstat -- lib/l10n/arb/ +``` + +Expected: `29 1` for ten locales and `39 1` for `app_en.arb` (the `1` is the previous last line losing its missing comma; if a file shows `29 0` its last key already ended with a comma, which is also fine). + +- [ ] **Step 4: Generate and verify the Dart** + +```bash +flutter gen-l10n +grep -A1 "get diveCenters_rental_verdictAvoid" lib/l10n/arb/app_localizations_de.dart +flutter test test/l10n/rental_gear_memory_strings_test.dart +``` + +Expected: the German getter returns `'Meiden'`, and the test PASSES. + +- [ ] **Step 5: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/l10n/arb/app_ar.arb lib/l10n/arb/app_de.arb lib/l10n/arb/app_en.arb lib/l10n/arb/app_es.arb lib/l10n/arb/app_fr.arb lib/l10n/arb/app_he.arb lib/l10n/arb/app_hu.arb lib/l10n/arb/app_it.arb lib/l10n/arb/app_nl.arb lib/l10n/arb/app_pt.arb lib/l10n/arb/app_zh.arb lib/l10n/arb/app_localizations.dart lib/l10n/arb/app_localizations_ar.dart lib/l10n/arb/app_localizations_de.dart lib/l10n/arb/app_localizations_en.dart lib/l10n/arb/app_localizations_es.dart lib/l10n/arb/app_localizations_fr.dart lib/l10n/arb/app_localizations_he.dart lib/l10n/arb/app_localizations_hu.dart lib/l10n/arb/app_localizations_it.dart lib/l10n/arb/app_localizations_nl.dart lib/l10n/arb/app_localizations_pt.dart lib/l10n/arb/app_localizations_zh.dart test/l10n/rental_gear_memory_strings_test.dart +git commit -m "i18n: rental gear memory strings in all locales (#2075)" +``` + +--- + +### Task 6: The rental gear note sheet + +**Files:** +- Create: `lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart` +- Test: `test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart` (create) + +**Interfaces:** +- Consumes: Task 1's entity, Task 4's `diveCenterGearNoteRepositoryProvider`, Task 5's strings, `UnitFormatter` (`lib/core/utils/unit_formatter.dart`), `parseUserDecimal` and `formatRoundedForInput` (`lib/core/utils/number_input.dart`), `equipmentTypeIcon` (`lib/features/equipment/presentation/utils/equipment_type_icon.dart`), `EquipmentTypeDisplay.localizedName` (`lib/features/equipment/presentation/utils/equipment_enum_display.dart`), `kCanonicalTypeOrder` (`lib/features/equipment/domain/constants/equipment_type_order.dart`). +- Produces: `Future showRentalGearNoteSheet(BuildContext context, {required String diveCenterId, String? diveId, DiveCenterGearNote? editing})` and `Future confirmDeleteRentalGearNote(BuildContext context, WidgetRef ref, DiveCenterGearNote note)` (returns true when deleted). + +- [ ] **Step 1: Write the failing widget test** + +Create `test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart`: + +```dart +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveCenterGearNoteRepository repo; + + setUp(() async { + db = await setUpTestDatabase(); + repo = DiveCenterGearNoteRepository(); + await db + .into(db.diveCenters) + .insert( + DiveCentersCompanion( + id: const Value('c1'), + name: const Value('Reef Divers'), + createdAt: const Value(0), + updatedAt: const Value(0), + ), + ); + }); + tearDown(tearDownTestDatabase); + + Future pumpAndOpen( + WidgetTester tester, { + DiveCenterGearNote? editing, + MockSettingsNotifier? settings, + }) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith( + (ref) => settings ?? MockSettingsNotifier(), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showRentalGearNoteSheet( + context, + diveCenterId: 'c1', + diveId: 'd1', + editing: editing, + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Finder fieldLabelled(String label) => find.ancestor( + of: find.text(label), + matching: find.byType(TextField), + ); + + testWidgets('creates a note with the typed fields converted to metric', ( + tester, + ) async { + final imperial = MockSettingsNotifier(); + await imperial.setImperial(); + await pumpAndOpen(tester, settings: imperial); + expect(find.text('New rental note'), findsOneWidget); + // The volume field only shows for tanks. + expect(find.text('Actual capacity (cuft)'), findsNothing); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('BCD').last); + await tester.pumpAndSettle(); + await tester.enterText(fieldLabelled('Label or number'), 'blue 3'); + await tester.enterText(fieldLabelled('Size'), 'L'); + await tester.tap(find.text('Avoid')); + await tester.enterText(fieldLabelled('Extra lead needed (lbs)'), '4.4'); + await tester.enterText(fieldLabelled('Note'), 'inflator sticks'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.gearType, EquipmentType.bcd); + expect(stored.label, 'blue 3'); + expect(stored.size, 'L'); + expect(stored.verdict, RentalVerdict.avoid); + expect(stored.leadAdjustmentKg, closeTo(2.0, 0.01)); + expect(stored.volumeLiters, isNull); + expect(stored.note, 'inflator sticks'); + expect(stored.diveId, 'd1'); + // The sheet closed. + expect(find.text('New rental note'), findsNothing); + }); + + testWidgets('a tank note shows and stores the actual capacity', ( + tester, + ) async { + await pumpAndOpen(tester); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Tank').last); + await tester.pumpAndSettle(); + expect(find.text('Actual capacity (L)'), findsOneWidget); + await tester.enterText(fieldLabelled('Label or number'), 'AL80'); + await tester.enterText(fieldLabelled('Actual capacity (L)'), '11.1'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.gearType, EquipmentType.tank); + expect(stored.volumeLiters, 11.1); + expect(stored.verdict, RentalVerdict.worked); + }); + + testWidgets('editing seeds the fields and saves over the row', ( + tester, + ) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + verdict: RentalVerdict.avoid, + leadAdjustmentKg: 1.5, + note: 'wet', + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + expect(find.text('Edit rental note'), findsOneWidget); + expect(find.text('14'), findsOneWidget); + expect(find.text('1.5'), findsOneWidget); + expect(find.text('wet'), findsOneWidget); + + await tester.enterText(fieldLabelled('Note'), 'fine after service'); + await tester.tap(find.text('Worked')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.id, existing.id); + expect(stored.verdict, RentalVerdict.worked); + expect(stored.note, 'fine after service'); + expect(stored.leadAdjustmentKg, 1.5); + }); + + testWidgets('delete asks first, then removes the note', (tester) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.wetsuit, + size: 'L', + verdict: RentalVerdict.avoid, + note: 'runs small', + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + await tester.tap(find.widgetWithText(TextButton, 'Delete')); + await tester.pumpAndSettle(); + expect(find.text('Delete this rental note?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(await repo.getForCenter('c1'), isEmpty); + expect(find.text('Edit rental note'), findsNothing); + }); +} +``` + +`MockSettingsNotifier` (`test/helpers/mock_providers.dart`) defaults to metric; `setImperial()` switches it to pounds (`lbs`) and cubic feet (`cuft`), which is what the first test's field labels assert. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart` +Expected: FAIL to compile: no `showRentalGearNoteSheet`. + +- [ ] **Step 3: Write the sheet** + +Create `lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/number_input.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/equipment/domain/constants/equipment_type_order.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_enum_display.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Opens the rental gear note editor (issue #2075) for a center. With +/// [editing], the sheet edits that note; otherwise it creates one for +/// [diveCenterId], remembering [diveId] as where it was noticed. +Future showRentalGearNoteSheet( + BuildContext context, { + required String diveCenterId, + String? diveId, + DiveCenterGearNote? editing, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _RentalGearNoteSheet( + diveCenterId: diveCenterId, + diveId: diveId, + editing: editing, + ), + ); +} + +/// Asks before deleting [note], then deletes it. Returns true when the +/// note is gone. Shared by the sheet and the detail page section. +Future confirmDeleteRentalGearNote( + BuildContext context, + WidgetRef ref, + DiveCenterGearNote note, +) async { + final l10n = context.l10n; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(l10n.diveCenters_rental_deleteConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.common_action_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.common_action_delete), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + await ref.read(diveCenterGearNoteRepositoryProvider).delete(note.id); + return true; +} + +class _RentalGearNoteSheet extends ConsumerStatefulWidget { + final String diveCenterId; + final String? diveId; + final DiveCenterGearNote? editing; + + const _RentalGearNoteSheet({ + required this.diveCenterId, + this.diveId, + this.editing, + }); + + @override + ConsumerState<_RentalGearNoteSheet> createState() => + _RentalGearNoteSheetState(); +} + +class _RentalGearNoteSheetState extends ConsumerState<_RentalGearNoteSheet> { + late EquipmentType _gearType; + late RentalVerdict _verdict; + late final TextEditingController _label; + late final TextEditingController _size; + late final TextEditingController _lead; + late final TextEditingController _volume; + late final TextEditingController _note; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final existing = widget.editing; + final units = UnitFormatter(ref.read(settingsProvider)); + _gearType = existing?.gearType ?? EquipmentType.other; + _verdict = existing?.verdict ?? RentalVerdict.worked; + _label = TextEditingController(text: existing?.label ?? ''); + _size = TextEditingController(text: existing?.size ?? ''); + _lead = TextEditingController( + text: existing?.leadAdjustmentKg == null + ? '' + : formatRoundedForInput( + units.convertWeight(existing!.leadAdjustmentKg!), + 2, + ), + ); + _volume = TextEditingController( + text: existing?.volumeLiters == null + ? '' + : formatRoundedForInput( + units.convertVolume(existing!.volumeLiters!), + 2, + ), + ); + _note = TextEditingController(text: existing?.note ?? ''); + } + + @override + void dispose() { + _label.dispose(); + _size.dispose(); + _lead.dispose(); + _volume.dispose(); + _note.dispose(); + super.dispose(); + } + + Future _save() async { + if (_saving) return; + final l10n = context.l10n; + final units = UnitFormatter(ref.read(settingsProvider)); + final leadText = _lead.text.trim(); + final volumeText = _volume.text.trim(); + final lead = leadText.isEmpty ? null : parseUserDecimal(leadText); + final volume = volumeText.isEmpty ? null : parseUserDecimal(volumeText); + // Unreadable text is refused rather than silently dropped. + if ((leadText.isNotEmpty && lead == null) || + (_gearType == EquipmentType.tank && + volumeText.isNotEmpty && + volume == null)) { + setState(() => _error = l10n.numberInput_invalidValue); + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + final repo = ref.read(diveCenterGearNoteRepositoryProvider); + final existing = widget.editing; + final leadKg = lead == null ? null : units.weightToKg(lead); + final volumeL = _gearType == EquipmentType.tank && volume != null + ? units.volumeToLiters(volume) + : null; + if (existing == null) { + final now = DateTime.now().toUtc(); + await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: widget.diveCenterId, + gearType: _gearType, + label: _label.text, + size: _size.text, + verdict: _verdict, + leadAdjustmentKg: leadKg, + volumeLiters: volumeL, + note: _note.text, + diveId: widget.diveId, + notedAt: now, + createdAt: now, + updatedAt: now, + ), + ); + } else { + await repo.update( + existing.copyWith( + gearType: _gearType, + label: _label.text, + clearLabel: _label.text.trim().isEmpty, + size: _size.text, + clearSize: _size.text.trim().isEmpty, + verdict: _verdict, + leadAdjustmentKg: leadKg, + clearLeadAdjustment: leadKg == null, + volumeLiters: volumeL, + clearVolume: volumeL == null, + note: _note.text, + ), + ); + } + if (mounted) Navigator.of(context).pop(); + } catch (_) { + // A concurrent sync can delete the center mid-save. Say so and keep + // the editor open to try again. + if (mounted) setState(() => _error = l10n.common_error_tryAgain); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final note = widget.editing; + if (note == null) return; + final deleted = await confirmDeleteRentalGearNote(context, ref, note); + if (deleted && mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final units = UnitFormatter(ref.watch(settingsProvider)); + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.editing == null + ? l10n.diveCenters_rental_sheetTitleNew + : l10n.diveCenters_rental_sheetTitleEdit, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _gearType, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_gearTypeLabel, + ), + isExpanded: true, + items: [ + for (final type in kCanonicalTypeOrder) + DropdownMenuItem( + value: type, + child: Text(type.localizedName(l10n)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _gearType = value); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _label, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_labelLabel, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _size, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_sizeLabel, + ), + ), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + ButtonSegment( + value: RentalVerdict.worked, + label: Text(l10n.diveCenters_rental_verdictWorked), + ), + ButtonSegment( + value: RentalVerdict.avoid, + label: Text(l10n.diveCenters_rental_verdictAvoid), + ), + ], + selected: {_verdict}, + onSelectionChanged: (selection) => + setState(() => _verdict = selection.first), + ), + const SizedBox(height: 12), + TextField( + controller: _lead, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_leadAdjustmentLabel( + units.weightSymbol, + ), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + if (_gearType == EquipmentType.tank) ...[ + const SizedBox(height: 12), + TextField( + controller: _volume, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_volumeLabel( + units.volumeSymbol, + ), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _note, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_noteLabel, + ), + maxLines: 3, + ), + if (_error case final error?) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + if (widget.editing != null) + TextButton( + onPressed: _saving ? null : _delete, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(l10n.common_action_delete), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.common_action_cancel), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _saving ? null : _save, + child: Text(l10n.common_action_save), + ), + ], + ), + ], + ), + ), + ); + } +} +``` + +`numberInput_invalidValue` and `common_error_tryAgain` already exist in the ARBs (the first is the last key of `app_en.arb`, the second is used by the observation sheet). If `common_error_tryAgain` is named differently, grep `app_en.arb` for `tryAgain` and use that key. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart` +Expected: 4 tests PASS. If the dropdown tap finds two `BCD` texts (the closed field and the open menu), the test already uses `.last`; if it finds none because the localized label differs, use the string `EquipmentType.bcd.localizedName` yields in English from `enum_equipmentType_bcd` in `app_en.arb`. + +- [ ] **Step 5: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart +git commit -m "feat(dive-centers): rental gear note sheet (#2075)" +``` + +--- + +### Task 7: The "Last time here" card and the trip section slot + +**Files:** +- Create: `lib/features/dive_centers/presentation/widgets/rental_memory_card.dart` +- Modify: `lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart` +- Test: `test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart` (create) +- Test: `test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart` (modify) + +**Interfaces:** +- Consumes: Task 4's `diveCenterGearNotesProvider`, `lastDiveAtCenterProvider`, `LastDiveAtCenterQuery`; Task 1's `LastDiveAtCenter`, `DiveCenterGearNote`; Task 6's `showRentalGearNoteSheet`; Task 5's strings. +- Produces: `class RentalMemoryCard extends ConsumerWidget` with `const RentalMemoryCard({required DiveCenter center, String? currentDiveId, required void Function(LastDiveAtCenter last) onApplyLastDive})`; `TripSection` gains `final Widget? centerChild`. + +The card is pure presentation: it never touches the edit form's state. Applying is the page's job through `onApplyLastDive`, which is where the confirm dialog lives (Task 8), because only the page knows whether the form already holds weights or tanks. + +- [ ] **Step 1: Write the failing card test** + +Create `test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + final center = DiveCenter( + id: 'c1', + name: 'Reef Divers', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + final last = LastDiveAtCenter( + diveId: 'd0', + dateTime: DateTime(2026, 3, 12), + weights: const [ + DiveWeight( + id: 'w1', + diveId: 'd0', + weightType: WeightType.belt, + amountKg: 6, + ), + DiveWeight( + id: 'w2', + diveId: 'd0', + weightType: WeightType.trimWeights, + amountKg: 2, + ), + ], + weightingFeedback: WeightingFeedback.overweighted, + weightingFeedbackKg: 1, + tanks: const [ + DiveTank(id: 't1', volume: 11.1, workingPressure: 207, presetName: 'al80'), + ], + ); + + final notes = [ + DiveCenterGearNote( + id: 'n1', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + verdict: RentalVerdict.avoid, + note: 'breathes wet', + notedAt: DateTime(2026, 3, 12), + createdAt: DateTime(2026, 3, 12), + updatedAt: DateTime(2026, 3, 12), + ), + DiveCenterGearNote( + id: 'n2', + diveCenterId: 'c1', + gearType: EquipmentType.bcd, + size: 'L', + verdict: RentalVerdict.worked, + leadAdjustmentKg: 2, + notedAt: DateTime(2026, 1, 1), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + ]; + + Future pump( + WidgetTester tester, { + required LastDiveAtCenter? lastDive, + required List withNotes, + void Function(LastDiveAtCenter)? onApply, + }) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + diveCenterGearNotesProvider( + 'c1', + ).overrideWith((ref) async => withNotes), + lastDiveAtCenterProvider(( + centerId: 'c1', + excludingDiveId: 'd1', + )).overrideWith((ref) async => lastDive), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: RentalMemoryCard( + center: center, + currentDiveId: 'd1', + onApplyLastDive: onApply ?? (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows the last dive facts and the notes', (tester) async { + await pump(tester, lastDive: last, withNotes: notes); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.textContaining('Last dive here:'), findsOneWidget); + expect(find.text('Lead: 8.0 kg'), findsOneWidget); + expect(find.text('1.0 kg over'), findsOneWidget); + expect(find.textContaining('11 L'), findsOneWidget); + expect(find.text('Apply last dive'), findsOneWidget); + // Notes, newest first, with their verdicts and numbers. + expect(find.text('14'), findsOneWidget); + expect(find.text('breathes wet'), findsOneWidget); + expect(find.text('Avoid'), findsOneWidget); + expect(find.text('L'), findsOneWidget); + expect(find.text('Worked'), findsOneWidget); + expect(find.text('2.0 kg extra lead'), findsOneWidget); + expect(find.text('Add rental note'), findsOneWidget); + }); + + testWidgets('apply hands the last dive to the page', (tester) async { + LastDiveAtCenter? applied; + await pump( + tester, + lastDive: last, + withNotes: const [], + onApply: (l) => applied = l, + ); + await tester.tap(find.text('Apply last dive')); + await tester.pump(); + expect(applied, same(last)); + }); + + testWidgets('with no history and no notes only the add button shows', ( + tester, + ) async { + await pump(tester, lastDive: null, withNotes: const []); + expect(find.text('Add rental note'), findsOneWidget); + expect(find.text('Apply last dive'), findsNothing); + expect(find.text('Last time at Reef Divers'), findsNothing); + expect(find.textContaining('Lead:'), findsNothing); + }); + + testWidgets('notes without history still show under the header', ( + tester, + ) async { + await pump(tester, lastDive: null, withNotes: notes); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.text('No other dives logged here yet.'), findsOneWidget); + expect(find.text('breathes wet'), findsOneWidget); + expect(find.text('Apply last dive'), findsNothing); + }); +} +``` + +- [ ] **Step 2: Extend the trip section test** + +Append this test to `test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart`, inside `main()`: + +```dart + testWidgets('the center child renders under the dive center row', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: TripSection( + expanded: true, + onToggle: () {}, + summary: 'Reef Divers', + isEmpty: false, + tripName: null, + onPickTrip: () {}, + onClearTrip: () {}, + diveCenterName: 'Reef Divers', + onPickDiveCenter: () {}, + onClearDiveCenter: () {}, + centerChild: const Text('CENTER_CHILD'), + ), + ), + ), + ), + ); + expect(find.text('CENTER_CHILD'), findsOneWidget); + final child = tester.getTopLeft(find.text('CENTER_CHILD')); + final row = tester.getTopLeft(find.text('Reef Divers').last); + expect(child.dy, greaterThan(row.dy)); + }); +``` + +- [ ] **Step 3: Run both tests to verify they fail** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart` +Expected: FAIL to compile: no `RentalMemoryCard`, no `centerChild` parameter. + +- [ ] **Step 4: Add the slot** + +In `lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart`: + +Add the constructor parameter after `this.centerCaption,`: + +```dart + this.centerChild, +``` + +Add the field after the `centerCaption` field: + +```dart + /// Rendered under the dive center row when a center is selected: the + /// "last time here" rental memory card (issue #2075). + final Widget? centerChild; +``` + +In `build`, inside the second `Column`, after `if (centerCaption != null) _caption(context, centerCaption!),` add: + +```dart + if (centerChild != null) + Padding( + padding: const EdgeInsets.fromLTRB( + FormStyle.groupRadius, + 0, + FormStyle.groupRadius, + 10, + ), + child: centerChild, + ), +``` + +- [ ] **Step 5: Write the card** + +Create `lib/features/dive_centers/presentation/widgets/rental_memory_card.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_enum_display.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_type_icon.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/weight_planner/presentation/widgets/weight_enum_display.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// "Last time at {center}" (issue #2075): the diver's most recent other +/// dive at the selected center (lead, how it felt, cylinders) and their +/// rental gear notes, with a way to add a note and to copy that dive's +/// weights and tanks into the form. +/// +/// Shown under the dive center row of the dive edit form. The card only +/// reports; [onApplyLastDive] hands the last dive to the page, which owns +/// the form state and the confirm dialog. +class RentalMemoryCard extends ConsumerWidget { + final DiveCenter center; + + /// The dive being edited, left out of the "last dive" lookup. Null while + /// creating a dive. + final String? currentDiveId; + final void Function(LastDiveAtCenter last) onApplyLastDive; + + const RentalMemoryCard({ + super.key, + required this.center, + this.currentDiveId, + required this.onApplyLastDive, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + final notes = + ref.watch(diveCenterGearNotesProvider(center.id)).value ?? + const []; + final lastAsync = ref.watch( + lastDiveAtCenterProvider(( + centerId: center.id, + excludingDiveId: currentDiveId, + )), + ); + final last = lastAsync.value; + + void addNote() => showRentalGearNoteSheet( + context, + diveCenterId: center.id, + diveId: currentDiveId, + ); + + if (last == null && notes.isEmpty) { + if (lastAsync.isLoading) return const SizedBox.shrink(); + return Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: addNote, + icon: const Icon(Icons.note_add_outlined), + label: Text(l10n.diveCenters_rental_addNote), + ), + ); + } + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.diveCenters_rental_lastTimeAt(center.name), + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + if (last == null) + Text( + l10n.diveCenters_rental_noHistory, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + _LastDiveRows(last: last, units: units), + if (notes.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final note in notes) + RentalNoteTile( + note: note, + units: units, + onTap: () => showRentalGearNoteSheet( + context, + diveCenterId: center.id, + diveId: currentDiveId, + editing: note, + ), + ), + ], + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: addNote, + icon: const Icon(Icons.note_add_outlined), + label: Text(l10n.diveCenters_rental_addNote), + ), + if (last != null) + FilledButton.tonalIcon( + onPressed: () => onApplyLastDive(last), + icon: const Icon(Icons.history), + label: Text(l10n.diveCenters_rental_applyLastDive), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _LastDiveRows extends StatelessWidget { + final LastDiveAtCenter last; + final UnitFormatter units; + + const _LastDiveRows({required this.last, required this.units}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.diveCenters_rental_lastDiveOn(units.formatDate(last.dateTime)), + style: muted, + ), + if (last.weights.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + l10n.diveCenters_rental_leadTotal( + units.formatWeight(last.totalLeadKg), + ), + style: theme.textTheme.bodyMedium, + ), + for (final w in last.weights) + Text( + '${w.weightType.localizedName(l10n)} ' + '${units.formatWeight(w.amountKg)}', + style: muted, + ), + if (feedbackLabel(l10n, units) case final label?) + Chip( + label: Text(label), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ], + if (last.tanks.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(l10n.diveCenters_rental_tanksLabel, style: muted), + for (final t in last.tanks) + Text( + [ + if (t.presetName case final p?) p.toUpperCase() + else if (t.name case final n?) n, + if (t.volume case final v?) units.formatVolume(v), + if (t.workingPressure case final p?) units.formatPressure(p), + ].join(' · '), + style: theme.textTheme.bodyMedium, + ), + ], + ], + ); + } + + String? feedbackLabel(AppLocalizations l10n, UnitFormatter units) { + final kg = last.weightingFeedbackKg; + return switch (last.weightingFeedback) { + null => null, + WeightingFeedback.correct => l10n.diveLog_edit_weightFeedback_correct, + WeightingFeedback.overweighted => + kg == null + ? l10n.diveLog_edit_weightFeedback_over + : l10n.diveCenters_rental_feedbackOver(units.formatWeight(kg)), + WeightingFeedback.underweighted => + kg == null + ? l10n.diveLog_edit_weightFeedback_under + : l10n.diveCenters_rental_feedbackUnder(units.formatWeight(kg)), + }; + } +} + +/// One rental note: icon, label and size, verdict, its number, its text. +/// Public because the dive center detail page section reuses it. +class RentalNoteTile extends StatelessWidget { + final DiveCenterGearNote note; + final UnitFormatter units; + final VoidCallback onTap; + + const RentalNoteTile({ + super.key, + required this.note, + required this.units, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final avoid = note.verdict == RentalVerdict.avoid; + final details = [ + if (note.leadAdjustmentKg case final kg? when kg != 0) + kg > 0 + ? l10n.diveCenters_rental_extraLead(units.formatWeight(kg)) + : l10n.diveCenters_rental_lessLead(units.formatWeight(-kg)), + if (note.volumeLiters case final v?) + l10n.diveCenters_rental_actualCapacity( + units.formatVolume(v, decimals: 1), + ), + ]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(equipmentTypeIcon(note.gearType)), + title: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(note.gearType.localizedName(l10n)), + if (note.label case final label?) Text(label), + if (note.size case final size?) Text(size), + Chip( + label: Text( + avoid + ? l10n.diveCenters_rental_verdictAvoid + : l10n.diveCenters_rental_verdictWorked, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: avoid + ? theme.colorScheme.errorContainer + : theme.colorScheme.secondaryContainer, + ), + ], + ), + subtitle: details.isEmpty && note.note.isEmpty + ? null + : Text([...details, if (note.note.isNotEmpty) note.note].join('\n')), + onTap: onTap, + ); + } +} +``` + +The formatter names used are verified: `formatDate(DateTime?)` (no l10n argument), `formatPressure(double?, {int decimals = 0})`, `formatVolume(double?, {int decimals = 0})`, `formatWeight(double?, {int decimals = 1})`. `WeightType.localizedName` comes from `lib/features/weight_planner/presentation/widgets/weight_enum_display.dart`, imported above. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart` +Expected: all PASS. The `11 L` and `8.0 kg` expectations depend on the default formatter decimals (`formatVolume` defaults to 0, `formatWeight` to 1); if a text differs by decimals only, align the test with the formatter's output rather than changing the formatter. + +- [ ] **Step 7: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_centers/presentation/widgets/rental_memory_card.dart lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart +git commit -m "feat(dive-log): last-time-here rental memory card under the dive center row (#2075)" +``` + +--- + +### Task 8: The edit page applies the last dive + +**Files:** +- Modify: `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (`_buildTripGroupSection` ~line 2390; a new `_applyLastDiveAtCenter` method next to `_applyWeightPreset` ~line 4524; imports) +- Test: `test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart` (create) + +**Interfaces:** +- Consumes: Task 7's `RentalMemoryCard`, Task 1's `LastDiveAtCenter.weightsForNewDive` and `tanksForNewDive`, the page's `_weights`, `_tanks`, `_tanksDirty`, `_markDirty`, `_uuid`, `_selectedDiveCenter`, `widget.diveId`. +- Produces: nothing new for later tasks. + +- [ ] **Step 1: Write the failing page test** + +Create `test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart`, modelled on `dive_edit_weight_preset_test.dart` (read that file first and copy its `pumpEditor` helper exactly, including the seeding of the current diver after pumping): + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_edit_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// Issue #2075: applying the last dive at a center copies its weights and +/// tanks into the dive being edited. +void main() { + late DiveRepository dives; + late DiveCenter center; + late String diverId; + + setUp(() async { + await setUpTestDatabase(); + dives = DiveRepository(); + final now = DateTime.now(); + diverId = (await DiverRepository().createDiver( + Diver(id: '', name: 'Tester', createdAt: now, updatedAt: now), + )).id; + center = await DiveCenterRepository().createDiveCenter( + DiveCenter( + id: '', + diverId: diverId, + name: 'Reef Divers', + createdAt: now, + updatedAt: now, + ), + ); + // The earlier dive at the center: 6 kg belt, one AL80. + await dives.createDive( + Dive( + id: 'earlier', + diverId: diverId, + dateTime: DateTime(2026, 3, 12, 9), + diveCenter: center, + weights: const [ + DiveWeight( + id: 'w-earlier', + diveId: 'earlier', + weightType: WeightType.belt, + amountKg: 6, + ), + ], + tanks: const [ + DiveTank( + id: 't-earlier', + volume: 11.1, + workingPressure: 207, + startPressure: 200, + endPressure: 60, + presetName: 'al80', + ), + ], + ), + ); + // The dive being edited: same center, no weights, no tanks. + await dives.createDive( + Dive( + id: 'current', + diverId: diverId, + dateTime: DateTime(2026, 9, 18, 9), + diveCenter: center, + ), + ); + }); + + tearDown(() async => tearDownTestDatabase()); + + Future pumpEditor(WidgetTester tester, String diveId) async { + tester.view.physicalSize = const Size(1000, 6000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final base = await getBaseOverrides(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base.cast(), + diveRepositoryProvider.overrideWithValue(dives), + diveListNotifierProvider.overrideWith( + (ref) => DiveListNotifier(dives, ref), + ), + customTankPresetsProvider.overrideWith((ref) async => []), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: DiveEditPage(diveId: diveId, embedded: true)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future expandTrip(WidgetTester tester) async { + await tester.tap(find.text('Trip')); + await tester.pumpAndSettle(); + } + + testWidgets('apply copies the earlier weights and tanks into the form', ( + tester, + ) async { + await pumpEditor(tester, 'current'); + await expandTrip(tester); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.text('Lead: 6.0 kg'), findsOneWidget); + + await tester.tap(find.text('Apply last dive')); + await tester.pumpAndSettle(); + expect(find.textContaining('Weights and tanks copied'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final saved = (await dives.getDiveById('current'))!; + expect(saved.weights.single.weightType, WeightType.belt); + expect(saved.weights.single.amountKg, 6); + expect(saved.weights.single.id, isNot('w-earlier')); + expect(saved.tanks.single.volume, 11.1); + expect(saved.tanks.single.presetName, 'al80'); + expect(saved.tanks.single.id, isNot('t-earlier')); + // The pressures are the defaults for a new tank, not the old dive's. + expect(saved.tanks.single.endPressure, 50); + expect(saved.tanks.single.startPressure, isNot(200)); + }); + + testWidgets('apply on a form that already has weights asks first', ( + tester, + ) async { + await dives.updateDive( + (await dives.getDiveById('current'))!.copyWith( + weights: const [ + DiveWeight( + id: 'w-current', + diveId: 'current', + weightType: WeightType.integrated, + amountKg: 4, + ), + ], + ), + ); + await pumpEditor(tester, 'current'); + await expandTrip(tester); + + await tester.tap(find.text('Apply last dive')); + await tester.pumpAndSettle(); + expect(find.text('Replace weights and tanks?'), findsOneWidget); + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + final saved = (await dives.getDiveById('current'))!; + expect(saved.weights.single.weightType, WeightType.belt); + expect(saved.weights.single.amountKg, 6); + }); +} +``` + +If `Dive` has required constructor parameters beyond those used, or `createDive` / `updateDive` / `Dive.copyWith` are named differently, read `dive_repository_impl.dart` and `dive.dart` and adjust the test's calls, keeping the assertions. If the save button's text is not `Save` in embedded mode, copy the tap the weight preset test uses. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart` +Expected: FAIL at `find.text('Last time at Reef Divers')` (nothing rendered under the center row yet). + +- [ ] **Step 3: Wire the card and the apply** + +In `lib/features/dive_log/presentation/pages/dive_edit_page.dart` add the imports: + +```dart +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; +``` + +In `_buildTripGroupSection`, after `centerCaption: _selectedDiveCenter?.displayLocation,` add: + +```dart + centerChild: _selectedDiveCenter == null || widget.isBulk + ? null + : RentalMemoryCard( + center: _selectedDiveCenter!, + currentDiveId: widget.diveId, + onApplyLastDive: _applyLastDiveAtCenter, + ), +``` + +Next to `_applyWeightPreset` add: + +```dart + /// Copies the weights and tanks of the diver's last dive at the selected + /// center into the form (issue #2075). Asks first when the form already + /// holds any, because the copy replaces them. + Future _applyLastDiveAtCenter(LastDiveAtCenter last) async { + final l10n = context.l10n; + if (_weights.any((w) => w.amountKg > 0) || _tanks.isNotEmpty) { + final replace = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveCenters_rental_applyConfirmTitle), + content: Text(l10n.diveCenters_rental_applyConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.common_action_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveCenters_rental_applyConfirmReplace), + ), + ], + ), + ); + if (replace != true || !mounted) return; + } + final settings = ref.read(settingsProvider); + setState(() { + _markDirty(); + _tanksDirty = true; + _weights = last.weightsForNewDive( + diveId: widget.diveId ?? '', + newId: _uuid.v4, + ); + _tanks + ..clear() + ..addAll( + last.tanksForNewDive( + newId: _uuid.v4, + startPressure: settings.defaultStartPressure.toDouble(), + endPressure: 50.0, + ), + ); + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.diveCenters_rental_applied))); + } +``` + +The `50.0` end pressure and `settings.defaultStartPressure` are exactly what `_addTank` uses for a new tank; keep them identical so an applied tank and a hand-added tank start the same. + +- [ ] **Step 4: Run the page tests** + +Run: `flutter test test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart test/features/dive_log/presentation/pages/dive_edit_weight_preset_test.dart test/features/dive_log/presentation/pages/dive_edit_page_test.dart test/features/dive_log/presentation/pages/dive_edit_save_field_census_test.dart` +Expected: all PASS. If the `Trip` group is collapsed by default and `find.text('Trip')` matches more than one widget, tap `find.text('Trip').first`. If a pre-existing test fails on the database service for the new providers, that test now renders the center row with a selected center; add `diveCenterGearNotesProvider().overrideWith((ref) async => [])` and `lastDiveAtCenterProvider(...)` overrides to it as the card test does. + +- [ ] **Step 5: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_log/presentation/pages/dive_edit_page.dart test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart +git commit -m "feat(dive-log): apply the last dive at a center to the dive being edited (#2075)" +``` + +--- + +### Task 9: The dive center detail page section + +**Files:** +- Create: `lib/features/dive_centers/presentation/widgets/rental_gear_section.dart` +- Modify: `lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart` (body composition ~line 96) +- Test: `test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart` (create) + +**Interfaces:** +- Consumes: Task 4's `diveCenterGearNotesProvider`, Task 6's `showRentalGearNoteSheet`, Task 7's `RentalNoteTile`, `kCanonicalTypeOrder` and `equipmentTypeRank` from `equipment_type_order.dart`. +- Produces: `class RentalGearSection extends ConsumerWidget` with `const RentalGearSection({required String centerId})`. + +- [ ] **Step 1: Write the failing section test** + +Create `test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_section.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + DiveCenterGearNote note(String id, EquipmentType type, {String? label}) => + DiveCenterGearNote( + id: id, + diveCenterId: 'c1', + gearType: type, + label: label, + verdict: RentalVerdict.worked, + notedAt: DateTime(2026, 1, 1), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Future pump( + WidgetTester tester, + List notes, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + diveCenterGearNotesProvider('c1').overrideWith((ref) async => notes), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView( + child: RentalGearSection(centerId: 'c1'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('groups notes by gear type in the canonical order', ( + tester, + ) async { + await pump(tester, [ + note('n1', EquipmentType.fins, label: 'fins'), + note('n2', EquipmentType.regulator, label: 'reg 14'), + note('n3', EquipmentType.regulator, label: 'reg 9'), + ]); + expect(find.text('Rental gear'), findsOneWidget); + final reg14 = tester.getTopLeft(find.text('reg 14')); + final reg9 = tester.getTopLeft(find.text('reg 9')); + final fins = tester.getTopLeft(find.text('fins')); + // Regulators come before fins in the canonical order. + expect(reg14.dy, lessThan(fins.dy)); + expect(reg9.dy, lessThan(fins.dy)); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('with no notes shows the empty line and the add button', ( + tester, + ) async { + await pump(tester, const []); + expect(find.text('Rental gear'), findsOneWidget); + expect(find.text('No rental notes for this center yet.'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('the add button opens the sheet', (tester) async { + await pump(tester, const []); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + expect(find.text('New rental note'), findsOneWidget); + }); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart` +Expected: FAIL to compile: no `RentalGearSection`. + +- [ ] **Step 3: Write the section** + +Create `lib/features/dive_centers/presentation/widgets/rental_gear_section.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; +import 'package:submersion/features/equipment/domain/constants/equipment_type_order.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The dive center detail page's rental gear notes (issue #2075), grouped +/// by gear type in the canonical equipment order, with add on the header +/// and edit on tap. Matches the page's other sections: a titled column with +/// a 16 px gutter, no card. +class RentalGearSection extends ConsumerWidget { + final String centerId; + + const RentalGearSection({super.key, required this.centerId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + final notes = + ref.watch(diveCenterGearNotesProvider(centerId)).value ?? + const []; + final ordered = [...notes] + ..sort((a, b) { + final byType = + equipmentTypeRank(a.gearType, kCanonicalTypeOrder) - + equipmentTypeRank(b.gearType, kCanonicalTypeOrder); + if (byType != 0) return byType; + return b.notedAt.compareTo(a.notedAt); + }); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.diveCenters_rental_sectionTitle, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.add), + tooltip: l10n.diveCenters_rental_addNote, + onPressed: () => + showRentalGearNoteSheet(context, diveCenterId: centerId), + ), + ], + ), + if (ordered.isEmpty) + Text( + l10n.diveCenters_rental_empty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + for (final note in ordered) + RentalNoteTile( + note: note, + units: units, + onTap: () => showRentalGearNoteSheet( + context, + diveCenterId: centerId, + editing: note, + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 4: Mount it on the page** + +In `lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart`, add the import: + +```dart +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_section.dart'; +``` + +In the body `Column` (about line 99), between the notes block and `const Divider(height: 32),` that precedes `_DivesSection`, insert: + +```dart + const Divider(height: 32), + RentalGearSection(centerId: widget.centerId), +``` + +so the order reads: map, header, divider, contact, (divider, notes), divider, rental gear, divider, dives. + +- [ ] **Step 5: Run the section and page tests** + +Run: `flutter test test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart test/features/dive_centers/presentation/pages/dive_center_detail_page_test.dart` +Expected: all PASS. If a detail page test fails because the section's provider reaches the database, add `diveCenterGearNotesProvider(center.id).overrideWith((ref) async => [])` to that test's overrides next to its `diveCenterDiveCountProvider` override. + +- [ ] **Step 6: Format, analyze and commit** + +```bash +dart format . +flutter analyze +git add lib/features/dive_centers/presentation/widgets/rental_gear_section.dart lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart +git commit -m "feat(dive-centers): rental gear section on the dive center page (#2075)" +``` + +--- + +### Task 10: Whole-project verification and the PR + +**Files:** +- No new files. Fixes only where a check below fails. + +- [ ] **Step 1: Architecture guards** + +New files under `lib/` were added in Tasks 1, 4, 6, 7 and 9; the guard suite scans all of `lib/` and never runs in an affected-directory run. + +Run: `flutter test test/architecture/` +Expected: PASS. A failure names the file and the rule; fix the file. + +- [ ] **Step 2: Re-check the schema rung** + +Run the two commands from Global Constraints once more. If main moved to 220 or 221 while this branch was built, merge `origin/main` into the branch, renumber this rung to the next free number in `database.dart` (the constant, the `migrationVersions` entry and comment, both `from < 221` guards, the backstop comment), rename and update `migration_v221_dive_center_gear_notes_test.dart`, and re-run Task 2's tests. + +- [ ] **Step 3: Format and analyze the whole project** + +```bash +dart format . +flutter analyze +``` + +Expected: `dart format` reports 0 changed files; `flutter analyze` prints `No issues found!`. Infos are fatal in CI, so fix them too. + +- [ ] **Step 4: Regenerate l10n and confirm nothing changes** + +```bash +flutter gen-l10n +git status --porcelain -- lib/l10n/arb/ +``` + +Expected: empty output. Any line means an ARB edit was never generated; commit the generated files with an `i18n:` commit. + +- [ ] **Step 5: The full test suite, once** + +Do not start it while another `flutter test` is running on this machine (`pgrep -fl "flutter test"` must print nothing). + +Run: `flutter test` +Expected: every test passes. Read the exit status directly, never through a pipe. A failure in a file this branch never touched means main is red; check `gh run list --branch main --limit 3` before blaming the branch. + +- [ ] **Step 6: Commit any fixes, then push and open the PR** + +```bash +git status --short +``` + +Stage and commit only files this branch changed. Then: + +```bash +git push -u origin ericgriffin/rental-gear-memory-2db80e +``` + +The pre-push hook runs format, analyze, the l10n staleness check and tests; let it run. Then open the PR against `main` with this body, written to a scratch file and passed with `--body-file`: + +```markdown +Closes #2075 + +Remember what worked with a dive center's rental gear, and surface it on a return visit. + +- A new synced child table of dive centers, `dive_center_gear_notes` (schema v221), holds typed notes: gear type, the operator's label, size, a worked/avoid verdict, an optional lead adjustment, an optional true tank capacity, free text, and the dive it was noticed on. +- In the dive edit form, picking a dive center shows "Last time at {center}": the lead, weighting feedback and tanks of the most recent other dive there, plus the notes. "Apply last dive" copies those weights and tanks (fresh ids, default pressures) into the form, asking first when the form already has any. "Add rental note" opens the shared sheet. +- The dive center detail page gains a "Rental gear" section grouped by gear type, with add and tap-to-edit. +- Notes sync as a parent-gated child of dive centers; deleting a center tombstones its notes so peers do not resurrect them. Deleting a dive detaches its notes rather than removing them. +- All values respect the diver's weight and volume units. Strings in all 11 locales. + +Design: `docs/superpowers/specs/2026-09-18-rental-gear-memory-design.md`. Plan: `docs/superpowers/plans/2026-09-18-rental-gear-memory.md`. +``` + +Title: `feat(dive-centers): remember what worked with an operator's rental gear` + +```bash +gh pr create --base main --title "feat(dive-centers): remember what worked with an operator's rental gear" --body-file /tmp/rental_pr_body.md +``` + +- [ ] **Step 7: Watch CI** + +Use the desktop app's PR tools (`bind_pr`, then `get_status`) rather than polling `gh`. A single shard failing on a libsqlcipher hash mismatch or a pdfium fetch reset is a transient download; rerun the failed jobs after the run completes. Every shard failing the same way is real. diff --git a/docs/superpowers/specs/2026-09-18-rental-gear-memory-design.md b/docs/superpowers/specs/2026-09-18-rental-gear-memory-design.md new file mode 100644 index 0000000000..cb24b04bf9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-rental-gear-memory-design.md @@ -0,0 +1,242 @@ +# Rental Gear Memory + +Date: 2026-09-18 +Status: approved design, implementation plan pending +Branch: ericgriffin/rental-gear-memory-2db80e +Issue: #2075 (the PR body must say `Closes #2075`) + +## Problem + +A diver who rents gear from an operator learns things that matter on the +next trip and forgets them by then: the operator's size-L wetsuit ran small, +its "AL80" cylinders actually held a different volume, its BCD needed 2 kg +more lead than usual, and regulator number 14 breathed poorly. Submersion +already records the dive center, the lead used, the weighting feedback and +the tank volumes of every dive, but never connects them back to the operator +on a return visit, and has no place to keep the judgements ("ran small", +"avoid"). + +## Decisions + +These were taken during brainstorming and are fixed for this spec. + +- Surfaces: the dive edit form (a "Last time here" card under the dive + center row) and the dive center detail page (a "Rental gear" section). +- Anchor: the existing `DiveCenters` entity through `dives.diveCenterId` + only. Trips, liveaboard records and the legacy free-text `dives.diveOperator` + column are ignored. +- Representation: a separate per-center notes table. Rental items are never + `equipment` rows, so the owned-gear list, service reminders, the condition + engine and the weight prediction engine are untouched. +- Capture: notes are written from both surfaces through one shared sheet. +- Note shape: typed fields (gear type, label, size, verdict, optional lead + adjustment, optional tank volume) plus a free-text note and an optional + link to the dive it was noted on. Verdict is a two-way "worked" or "avoid". +- Derived facts: the card also shows the most recent dive at the center + (lead breakdown, weighting feedback, tanks) computed on demand, and an + "Apply last dive" action copies that dive's weights and tanks into the + form. Nothing derived is stored. + +## Data model + +One new table, `dive_center_gear_notes`, entity type `diveCenterGearNotes`, +a child of `dive_centers`. + +| Column | Type | Meaning | +| --- | --- | --- | +| id | text, primary key | uuid | +| dive_center_id | text, FK dive_centers.id, cascade | the operator | +| gear_type | text | `EquipmentType.name` (regulator, bcd, wetsuit, tank, weights...) | +| label | text, nullable | the operator's mark: "14", "AL80" | +| size | text, nullable | "L", "42" | +| verdict | text | `worked` or `avoid` | +| lead_adjustment_kg | real, nullable | signed, "needed 2 kg more" is +2.0 | +| volume_liters | real, nullable | the cylinder's true capacity, tank notes only | +| note | text, not null, default '' | free text | +| dive_id | text, FK dives.id, set null, nullable | the dive it was noted on | +| noted_at | datetime | when the diver made the observation | +| created_at, updated_at | datetime | row timestamps | +| hlc | text, nullable | sync clock | + +Two explicit numeric columns rather than one polymorphic value column, so +display and units never depend on the type. Both are stored metric and shown +in the active diver's weight and volume units. + +Domain entity `DiveCenterGearNote` in +`lib/features/dive_centers/domain/entities/dive_center_gear_note.dart` with +`copyWith`, a `RentalVerdict { worked, avoid }` enum, and a `gearType` +typed as `EquipmentType`. Unknown stored type names (from a newer peer) +map to `EquipmentType.other` on read rather than throwing. + +No `diver_id` column: the center already carries `diver_id`, and the note +is meaningless without its center, matching `dive_weights` and +`weight_preset_entries`. + +### Schema rung + +Main is at 219. Three open PRs (#2040, #1980, #1978) each claim 220, so +this feature claims **221** and re-checks the ladder (open PR diffs and +bodies, plus main) right before the plan is written and again before push. +The migration is an inline `if (from < 221)` block in `database.dart` +creating the table, followed by `reportProgress()`, with the version added +to `migrationVersions`. There is no `migrations/` directory; the two docs +that describe one are stale. + +### Sync registration + +The table is a parent-gated child of `diveCenters`. Every one of these +must be touched, and the existing pinning tests fail until they are: + +- `SyncRepository.hlcTargets`: `'diveCenterGearNotes': (table: 'dive_center_gear_notes', pk: 'id')`. +- `SyncDataSerializer.parentGatedChildEntities` and `parentGatedTables`; + the record id is the plain `id`, so no key-column entry. +- Serializer payload field, `toJson` and `fromJson`, the export table list, + and each `case 'diveCenterGearNotes':` switch. +- `SyncService` merge dispatch and FK gating: + `(field: 'diveCenterId', parent: 'diveCenters', nullable: false)` and + `(field: 'diveId', parent: 'dives', nullable: true)`. +- `conflict_reference.dart` FK-to-entity labels. +- The repository calls `markRecordPending` after every insert or update + (outside any `batch` closure) and `logDeletion` on delete. + +## Derived facts + +`RentalMemoryResolver` in +`lib/features/dive_centers/domain/services/rental_memory_resolver.dart` is +pure Dart. Input: a list of hydrated dives at the center and the id of the +dive being edited (nullable). Output `LastDiveAtCenter?`: the most recent +dive by `diveDateTime` that is not the current dive, with its date, typed +weights, `weightingFeedback` and `weightingFeedbackKg`, and tanks (volume, +working pressure, preset name, material, gas mix, role). Null when there is +no other dive. + +The repository gains `latestDiveIdAtCenter(centerId, {excludingDiveId})` +returning at most one id by `ORDER BY diveDateTime DESC LIMIT 1`, and the +provider hydrates it through the full `getDiveById` path. The lean analysis +path leaves weights empty and must not be used here (the buoyancy twin was +bitten by exactly this). + +## Dive edit form + +`TripSection` gains an optional `centerChild` slot rendered under the dive +center caption, mirroring the existing `tripSuggestion` slot. + +The slot holds `RentalMemoryCard`, given the selected center id and the +current dive id: + +- Header: "Last time at {center}" with the derived dive's date, or the + center name alone when there is no derived dive. +- Derived rows (when a derived dive exists): lead per weight type and the + total, a weighting feedback chip ("Felt correct", "2 kg over", + "1 kg under"), and one line per tank ("AL80, 11.1 L at 207 bar"). +- Notes list: gear icon by type, label and size, verdict chip, lead + adjustment or volume when present, note text. Tapping a note opens the + sheet in edit mode. +- "Add rental note" opens the sheet in create mode with the center id and, + when editing a saved dive, that dive's id. A new dive has no id yet, so + the note saves with `dive_id` null. +- "Apply last dive" (only when a derived dive exists) replaces the form's + `_weights` and `_tanks` with copies carrying fresh ids. Weight copies keep + type, amount and notes. Tank copies keep volume, working pressure, preset + name, material, gas mix, role and order, and take the same default start + and end pressures a newly added tank gets. The form is marked dirty and + `_tanksDirty` set, then a snackbar confirms. When the form already holds + any weights or tanks, a confirm dialog precedes the replace. +- With no center selected the slot renders nothing. With a center but no + derived dive and no notes, only the "Add rental note" button shows. + +Notes save immediately to the center, independent of the dive's own save, +because the note belongs to the operator and must survive a discarded dive. +The card watches the notes provider so a save is reflected at once. + +## Dive center detail page + +A new `_RentalGearSection` card sits between the Notes card and the Dives +card. It lists the center's notes grouped by gear type in the equipment +display order, each row showing label, size, verdict chip and numbers, with +the note text beneath. An add icon button in the card header opens the sheet +in create mode; tapping a row opens it in edit mode, where a delete action +is available. When the center has no notes the card collapses to a single +compact row holding the add button. + +The center delete-usage text is unchanged; notes cascade with the center. + +## The note sheet + +`RentalGearNoteSheet` is a modal bottom sheet shared by both surfaces. +Fields, top to bottom: + +- Gear type dropdown in the equipment display order, default `other`. +- Label and size text fields. +- Verdict as a two-way segmented control, default `worked`. +- Lead adjustment, a signed number in the diver's weight unit, optional. +- Tank volume in the diver's volume unit, optional, shown only when the + type is `tank`. +- Note, multi-line. +- Save; Delete when editing, behind a confirm dialog. + +Numbers are parsed with the app's locale-aware input helpers and converted +to metric on save. Empty numeric fields save null. + +## Files + +New, all under `lib/features/dive_centers/`: + +- `domain/entities/dive_center_gear_note.dart` +- `domain/services/rental_memory_resolver.dart` +- `data/repositories/dive_center_gear_note_repository.dart` +- `presentation/providers/dive_center_gear_note_providers.dart` (notes by + center, last dive at center) +- `presentation/widgets/rental_gear_note_sheet.dart` +- `presentation/widgets/rental_memory_card.dart` +- `presentation/widgets/rental_gear_section.dart` + +Touched: + +- `lib/core/database/database.dart` (table, rung, version list) and the + regenerated `database.g.dart` +- `lib/core/data/repositories/sync_repository.dart`, + `lib/core/services/sync/sync_data_serializer.dart`, + `lib/core/services/sync/sync_service.dart`, + `lib/core/services/sync/conflict_reference.dart` +- `lib/features/dive_centers/data/repositories/dive_center_repository.dart` + (latest dive at center) +- `lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart` + (slot) +- `lib/features/dive_log/presentation/pages/dive_edit_page.dart` (slot + wiring and apply, roughly sixty lines) +- `lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart` +- all eleven ARB files under `lib/l10n/arb/` and the regenerated + localizations + +## Testing + +Tests are written before the code they cover. + +- Repository: create, update, delete, list by center ordered by `noted_at` + descending, cascade on center delete, `dive_id` set null on dive delete, + pending mark after write, deletion log on delete. +- Resolver: picks the latest dive, excludes the current dive, returns null + for an empty center, tolerates a dive with no weights or tanks. +- Sync: the HLC target and parent-gated registration pin tests, a + serializer round trip of a note, a merge whose center is missing is + deferred not dropped. +- Migration: a v221 test asserting the table and its columns, and the + latest-version tripwire moved to 221. +- Widgets: the card renders derived rows and notes; apply replaces weights + and tanks with fresh ids and default pressures and marks the form dirty; + the confirm dialog appears only when the form is non-empty; the sheet + shows the volume field only for tanks and converts imperial input to + metric; the detail section groups by type and opens the sheet. +- The architecture guard suite (`test/architecture/`) runs once after the + new lib files exist, because affected-directory runs never include it. +- Widget tests pin `Locale('en')`. + +## Out of scope + +- Rental items as `equipment` rows, and any ownership flag on equipment. +- Trips or liveaboard records as an anchor. +- Matching the legacy free-text `dives.diveOperator` to centers. +- Feeding notes into the weight prediction engine or the condition engine. +- Import and export of notes (UDDF, CSV, backup formats beyond the sync + changeset). diff --git a/lib/core/data/repositories/sync_repository.dart b/lib/core/data/repositories/sync_repository.dart index f342536fee..15703d11ed 100644 --- a/lib/core/data/repositories/sync_repository.dart +++ b/lib/core/data/repositories/sync_repository.dart @@ -140,6 +140,7 @@ class SyncRepository { 'diveTags': (table: 'dive_tags', pk: 'id'), 'diveDiveTypes': (table: 'dive_dive_types', pk: 'id'), 'weightPresetEntries': (table: 'weight_preset_entries', pk: 'id'), + 'diveCenterGearNotes': (table: 'dive_center_gear_notes', pk: 'id'), 'tideRecords': (table: 'tide_records', pk: 'id'), 'sightings': (table: 'sightings', pk: 'id'), 'diveCustomFields': (table: 'dive_custom_fields', pk: 'id'), diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 0df1b485d0..0d91daa1ae 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -1419,6 +1419,49 @@ class WeightPresetEntries extends Table { TextColumn get hlc => text().nullable()(); } +/// Rental gear memory (v221, issue #2075): what a diver learned about a dive +/// center's rental gear, kept per center so it surfaces on a return visit. +/// The note is the diver's judgement; the numbers of the last dive at the +/// center (lead, feedback, tanks) are read off that dive, never copied here. +@DataClassName('DiveCenterGearNoteRow') +class DiveCenterGearNotes extends Table { + TextColumn get id => text()(); + TextColumn get diveCenterId => + text().references(DiveCenters, #id, onDelete: KeyAction.cascade)(); + + /// EquipmentType.name of the rental item. + TextColumn get gearType => text()(); + + /// The operator's mark for the item: "14", "AL80". + TextColumn get label => text().nullable()(); + TextColumn get size => text().nullable()(); + + /// RentalVerdict.name: worked or avoid. + TextColumn get verdict => text()(); + + /// Signed kg: lead needed beyond the diver's usual with this gear. + RealColumn get leadAdjustmentKg => real().nullable()(); + + /// The cylinder's true capacity, for tank notes. + RealColumn get volumeLiters => real().nullable()(); + TextColumn get note => text().withDefault(const Constant(''))(); + + /// The dive the note was written on, if any; the note outlives it. + TextColumn get diveId => + text().nullable().references(Dives, #id, onDelete: KeyAction.setNull)(); + IntColumn get notedAt => integer()(); + IntColumn get createdAt => integer()(); + IntColumn get updatedAt => integer()(); + + @override + Set get primaryKey => {id}; + + /// This child's own clock, stamped when it is marked pending, so the merge + /// refuses a remote copy strictly older than the local one + /// (SyncDataSerializer.parentGatedChildEntities). + TextColumn get hlc => text().nullable()(); +} + /// Data-quality findings produced by the Data Quality Assistant detectors. /// One row per (dive, detector, discriminator). Ids are deterministic /// UUIDv5 values so independent scans on two devices converge on the same @@ -4207,6 +4250,8 @@ String legacyDataSourceId(String diveId) => '$kLegacyDataSourceIdPrefix$diveId'; ServiceSchedules, CylinderConfigs, CylinderConfigItems, + // Rental gear memory (v221, issue #2075) + DiveCenterGearNotes, ], ) class AppDatabase extends _$AppDatabase { @@ -4216,7 +4261,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 = 220; + static const int currentSchemaVersion = 221; /// The oldest schema whose reader can apply this build's sync payloads /// without loss or misinterpretation (the compatibility floor). @@ -4831,6 +4876,11 @@ class AppDatabase extends _$AppDatabase { // Additive column, default off. Renumbered from 219: #1964 (equipment // tags) shipped first and claimed it. 220, + // v221: rental gear memory (issue #2075). dive_center_gear_notes, a + // child of dive_centers. Table-only rung, no backfill, so the + // compatibility floor stays. Sits above v220 (#1980), which shipped + // while this was in review. + 221, ]; /// Idempotent DDL for the v106 connector-suggestion columns (Lightroom @@ -5275,6 +5325,7 @@ class AppDatabase extends _$AppDatabase { 'dive_safety_reviews', 'dive_safety_findings', 'gas_switches', + 'dive_center_gear_notes', ]) { await _addColumnIfMissing(table, 'hlc', 'TEXT'); } @@ -8301,6 +8352,23 @@ class AppDatabase extends _$AppDatabase { await assertEquipmentTagUniqueness(this); } + /// Idempotent creation of the v221 `dive_center_gear_notes` table (issue + /// #2075). Called from the v221 rung and the beforeOpen backstop. + /// + /// Skipped on a partial migration-test fixture that lacks either parent + /// table, so a fixture written for an older rung does not gain a table + /// whose foreign keys point nowhere. + Future _assertDiveCenterGearNotesSchema() async { + for (final parent in const ['dive_centers', 'dives']) { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + variables: [Variable(parent)], + ).get(); + if (rows.isEmpty) return; + } + await createMigrator().createTable(diveCenterGearNotes); + } + /// Idempotent DDL for the v174 dive_types.show_in_detail_header and /// dive_types.show_in_list_view columns: per-type toggles for which /// badge rows a diver's types appear in (issue #1269 follow-up). Both @@ -12293,6 +12361,12 @@ class AppDatabase extends _$AppDatabase { await _assertEquipmentSetComputerAutoApplyColumn(); } if (from < 220) await reportProgress(); + // v221: rental gear memory (issue #2075). Table-only rung, no + // backfill. + if (from < 221) { + await _assertDiveCenterGearNotesSchema(); + } + if (from < 221) await reportProgress(); }, beforeOpen: (details) async { // v220 backstop: the computer-set auto-apply opt-in column. @@ -12417,6 +12491,10 @@ class AppDatabase extends _$AppDatabase { // (parallel-branch version-collision self-heal; all idempotent). await _assertEquipmentTagSchema(); + // v221 backstop: the rental gear notes table (parallel-branch + // version-collision self-heal; createTable is idempotent). + await _assertDiveCenterGearNotesSchema(); + // v122 backstop: re-assert service ledger schema + built-in kinds. // The legacy backfill is NOT here (onUpgrade only) -- re-running it // would resurrect user-deleted schedules. diff --git a/lib/core/services/sync/sync_data_serializer.dart b/lib/core/services/sync/sync_data_serializer.dart index ac0de51e74..73a8706732 100644 --- a/lib/core/services/sync/sync_data_serializer.dart +++ b/lib/core/services/sync/sync_data_serializer.dart @@ -282,6 +282,7 @@ class SyncData { final List> serviceKinds; final List> serviceSchedules; final List> diveCenters; + final List> diveCenterGearNotes; final List> trips; final List> liveaboardDetails; final List> itineraryDays; @@ -377,6 +378,7 @@ class SyncData { this.serviceKinds = const [], this.serviceSchedules = const [], this.diveCenters = const [], + this.diveCenterGearNotes = const [], this.trips = const [], this.liveaboardDetails = const [], this.itineraryDays = const [], @@ -467,6 +469,7 @@ class SyncData { 'serviceKinds': serviceKinds, 'serviceSchedules': serviceSchedules, 'diveCenters': diveCenters, + 'diveCenterGearNotes': diveCenterGearNotes, 'trips': trips, 'liveaboardDetails': liveaboardDetails, 'itineraryDays': itineraryDays, @@ -558,6 +561,7 @@ class SyncData { serviceKinds: _parseList(json['serviceKinds']), serviceSchedules: _parseList(json['serviceSchedules']), diveCenters: _parseList(json['diveCenters']), + diveCenterGearNotes: _parseList(json['diveCenterGearNotes']), trips: _parseList(json['trips']), liveaboardDetails: _parseList(json['liveaboardDetails']), itineraryDays: _parseList(json['itineraryDays']), @@ -892,6 +896,12 @@ class SyncDataSerializer { full: null, ), (key: 'diveCenters', table: _db.diveCenters, blob: false, full: null), + ( + key: 'diveCenterGearNotes', + table: _db.diveCenterGearNotes, + blob: false, + full: null, + ), (key: 'trips', table: _db.trips, blob: false, full: null), ( key: 'liveaboardDetails', @@ -1459,6 +1469,7 @@ class SyncDataSerializer { 'siteTags', 'equipmentTags', 'weightPresetEntries', + 'diveCenterGearNotes', 'tideRecords', 'sightings', 'diveCustomFields', @@ -1515,6 +1526,7 @@ class SyncDataSerializer { 'equipmentTags': 'equipment_tags', 'diveDiveTypes': 'dive_dive_types', 'weightPresetEntries': 'weight_preset_entries', + 'diveCenterGearNotes': 'dive_center_gear_notes', 'tideRecords': 'tide_records', 'sightings': 'sightings', 'diveCustomFields': 'dive_custom_fields', @@ -1777,6 +1789,14 @@ class SyncDataSerializer { 'diveCenters', () => _exportDiveCenters(hlcSince), ), + diveCenterGearNotes: await _safeExport( + 'diveCenterGearNotes', + () async => _withPendingChildren( + 'diveCenterGearNotes', + await _exportDiveCenterGearNotes(hlcSince), + pendingChildren, + ), + ), trips: await _safeExport('trips', () => _exportTrips(hlcSince)), liveaboardDetails: await _safeExport( 'liveaboardDetails', @@ -2341,6 +2361,11 @@ class SyncDataSerializer { _db.diveCenters, )..where((t) => t.id.equals(recordId))).getSingleOrNull(); return row?.toJson(); + case 'diveCenterGearNotes': + final row = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(recordId))).getSingleOrNull(); + return row?.toJson(); case 'trips': final row = await (_db.select( _db.trips, @@ -2743,6 +2768,11 @@ class SyncDataSerializer { _db.diveCenters, )..where((t) => t.id.isIn(idList))).get(); return {for (final r in rows) r.id: r.toJson()}; + case 'diveCenterGearNotes': + final rows = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.isIn(idList))).get(); + return {for (final r in rows) r.id: r.toJson()}; case 'trips': final rows = await (_db.select( _db.trips, @@ -3634,6 +3664,11 @@ class SyncDataSerializer { DiveCenter.fromJson(data).toCompanion(false), ); return; + case 'diveCenterGearNotes': + await _db + .into(_db.diveCenterGearNotes) + .insertOnConflictUpdate(DiveCenterGearNoteRow.fromJson(data)); + return; case 'trips': await _db .into(_db.trips) @@ -4487,6 +4522,14 @@ class SyncDataSerializer { ), ); return; + case 'diveCenterGearNotes': + await _db.batch( + (b) => b.insertAllOnConflictUpdate( + _db.diveCenterGearNotes, + records.map((r) => DiveCenterGearNoteRow.fromJson(r)).toList(), + ), + ); + return; case 'trips': await _db.batch( (b) => b.insertAllOnConflictUpdate( @@ -5211,6 +5254,8 @@ class SyncDataSerializer { return plain(_db.mediaSubscriptions, _db.mediaSubscriptions.id); case 'diveCenters': return plain(_db.diveCenters, _db.diveCenters.id); + case 'diveCenterGearNotes': + return plain(_db.diveCenterGearNotes, _db.diveCenterGearNotes.id); case 'trips': return plain(_db.trips, _db.trips.id); case 'liveaboardDetails': @@ -5595,6 +5640,8 @@ class SyncDataSerializer { return _db.mediaSubscriptions; case 'diveCenters': return _db.diveCenters; + case 'diveCenterGearNotes': + return _db.diveCenterGearNotes; case 'trips': return _db.trips; case 'liveaboardDetails': @@ -5943,6 +5990,11 @@ class SyncDataSerializer { _db.diveCenters, )..where((t) => t.id.equals(recordId))).go(); return; + case 'diveCenterGearNotes': + await (_db.delete( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(recordId))).go(); + return; case 'trips': await (_db.delete(_db.trips)..where((t) => t.id.equals(recordId))).go(); return; @@ -6677,6 +6729,28 @@ class SyncDataSerializer { return rows.map((r) => r.toJson()).toList(); } + /// Rental gear notes ride their center: an incremental export re-sends + /// the whole note set of every center whose hlc moved, mirroring + /// [_exportWeightPresetEntries]. A note edited on its own reaches peers + /// through the pending-children path. + Future>> _exportDiveCenterGearNotes( + String? hlcSince, + ) async { + if (hlcSince != null) { + final modified = await (_db.select( + _db.diveCenters, + )..where((t) => t.hlc.isBiggerThanValue(hlcSince))).get(); + final ids = modified.map((c) => c.id).toSet(); + if (ids.isEmpty) return []; + final rows = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.diveCenterId.isIn(ids))).get(); + return rows.map((r) => r.toJson()).toList(); + } + final rows = await _db.select(_db.diveCenterGearNotes).get(); + return rows.map((r) => r.toJson()).toList(); + } + Future>> _exportTrips(String? hlcSince) async { final query = _db.select(_db.trips); if (hlcSince != null) { diff --git a/lib/core/services/sync/sync_service.dart b/lib/core/services/sync/sync_service.dart index 0fdc5e4010..0a4e32d498 100644 --- a/lib/core/services/sync/sync_service.dart +++ b/lib/core/services/sync/sync_service.dart @@ -1423,6 +1423,13 @@ class SyncService { // so a future read of this list still tells the dependency story.) (type: 'courses', records: data.courses, hasUpdatedAt: true), (type: 'dives', records: data.dives, hasUpdatedAt: true), + // Child of diveCenters, and of dives when noted on one; applied + // after both so the deferred-FK commit sees the parent rows. + ( + type: 'diveCenterGearNotes', + records: data.diveCenterGearNotes, + hasUpdatedAt: false, + ), // Quality findings FK dives + diveComputers (both applied above); // deferred FKs cover ordering, but keep the logical sequence. ( @@ -2295,6 +2302,7 @@ class SyncService { 'diverSettings': true, 'buddies': true, 'diveCenters': true, + 'diveCenterGearNotes': false, 'trips': true, 'liveaboardDetails': true, 'itineraryDays': true, @@ -2433,6 +2441,10 @@ class SyncService { (field: 'computerId', parent: 'diveComputers', nullable: true), (field: 'diveCenterId', parent: 'diveCenters', nullable: true), ], + 'diveCenterGearNotes': [ + (field: 'diveCenterId', parent: 'diveCenters', nullable: false), + (field: 'diveId', parent: 'dives', nullable: true), + ], 'qualityFindings': [ (field: 'diveId', parent: 'dives', nullable: false), (field: 'relatedDiveId', parent: 'dives', nullable: true), diff --git a/lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart b/lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart new file mode 100644 index 0000000000..108febcbd5 --- /dev/null +++ b/lib/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart @@ -0,0 +1,163 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/database_service.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/core/services/sync/sync_event_bus.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +/// CRUD for a dive center's rental gear notes (issue #2075). Mirrors +/// WeightPresetRepository: HLC-stamped writes, a change stream for the +/// providers, and per-row sync bookkeeping after the write. +class DiveCenterGearNoteRepository { + AppDatabase get _db => DatabaseService.instance.database; + final SyncRepository _syncRepository = SyncRepository(); + final _uuid = const Uuid(); + final _log = LoggerService.forClass(DiveCenterGearNoteRepository); + + Stream watchChanges() => + _db.tableUpdates(TableUpdateQuery.onTable(_db.diveCenterGearNotes)); + + /// The center's notes, newest observation first. + Future> getForCenter(String centerId) async { + final rows = + await (_db.select(_db.diveCenterGearNotes) + ..where((t) => t.diveCenterId.equals(centerId)) + ..orderBy([ + (t) => OrderingTerm.desc(t.notedAt), + (t) => OrderingTerm.desc(t.createdAt), + ])) + .get(); + return rows.map(fromRow).toList(); + } + + Future getById(String id) async { + final row = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + return row == null ? null : fromRow(row); + } + + /// Insert [note]; an empty id is replaced with a fresh uuid. + Future create(DiveCenterGearNote note) async { + try { + final id = note.id.isEmpty ? _uuid.v4() : note.id; + final now = DateTime.now().millisecondsSinceEpoch; + await _db + .into(_db.diveCenterGearNotes) + .insert( + DiveCenterGearNotesCompanion( + id: Value(id), + diveCenterId: Value(note.diveCenterId), + gearType: Value(note.gearType.name), + label: Value(_trimmedOrNull(note.label)), + size: Value(_trimmedOrNull(note.size)), + verdict: Value(note.verdict.name), + leadAdjustmentKg: Value(note.leadAdjustmentKg), + volumeLiters: Value(note.volumeLiters), + note: Value(note.note.trim()), + diveId: Value(note.diveId), + notedAt: Value(note.notedAt.millisecondsSinceEpoch), + createdAt: Value(now), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveCenterGearNotes', + recordId: id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + return (await getById(id))!; + } catch (e, stackTrace) { + _log.error( + 'Failed to create rental gear note for ${note.diveCenterId}', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future update(DiveCenterGearNote note) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(note.id))).write( + DiveCenterGearNotesCompanion( + gearType: Value(note.gearType.name), + label: Value(_trimmedOrNull(note.label)), + size: Value(_trimmedOrNull(note.size)), + verdict: Value(note.verdict.name), + leadAdjustmentKg: Value(note.leadAdjustmentKg), + volumeLiters: Value(note.volumeLiters), + note: Value(note.note.trim()), + diveId: Value(note.diveId), + notedAt: Value(note.notedAt.millisecondsSinceEpoch), + updatedAt: Value(now), + ), + ); + await _syncRepository.markRecordPending( + entityType: 'diveCenterGearNotes', + recordId: note.id, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to update rental gear note ${note.id}', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + Future delete(String id) async { + try { + await _db.transaction(() async { + await (_db.delete( + _db.diveCenterGearNotes, + )..where((t) => t.id.equals(id))).go(); + await _syncRepository.logDeletion( + entityType: 'diveCenterGearNotes', + recordId: id, + ); + }); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to delete rental gear note $id', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + static String? _trimmedOrNull(String? text) { + final trimmed = text?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; + } + + static DiveCenterGearNote fromRow( + DiveCenterGearNoteRow row, + ) => DiveCenterGearNote( + id: row.id, + diveCenterId: row.diveCenterId, + gearType: DiveCenterGearNote.gearTypeFromName(row.gearType), + label: row.label, + size: row.size, + verdict: DiveCenterGearNote.verdictFromName(row.verdict), + leadAdjustmentKg: row.leadAdjustmentKg, + volumeLiters: row.volumeLiters, + note: row.note, + diveId: row.diveId, + notedAt: DateTime.fromMillisecondsSinceEpoch(row.notedAt, isUtc: true), + createdAt: DateTime.fromMillisecondsSinceEpoch(row.createdAt, isUtc: true), + updatedAt: DateTime.fromMillisecondsSinceEpoch(row.updatedAt, isUtc: true), + ); +} diff --git a/lib/features/dive_centers/data/repositories/dive_center_repository.dart b/lib/features/dive_centers/data/repositories/dive_center_repository.dart index 8668845720..12fd54b05b 100644 --- a/lib/features/dive_centers/data/repositories/dive_center_repository.dart +++ b/lib/features/dive_centers/data/repositories/dive_center_repository.dart @@ -217,12 +217,18 @@ class DiveCenterRepository { /// Delete a dive center. The dives logged with it survive with the center /// cleared: `dives.dive_center_id` has no ON DELETE action, so a dive still - /// pointing at the center would fail the delete (issue #1952). One - /// transaction, so a failed delete leaves the dives linked. + /// pointing at the center would fail the delete (issue #1952). Its rental + /// gear notes cascade, and each is tombstoned by hand: SQLite cascades + /// write no deletion-log rows, so a peer would otherwise resurrect them + /// (issue #2075). One transaction, so a failed delete leaves the dives + /// linked and the notes in place. Future deleteDiveCenter(String id) async { try { _log.info('Deleting dive center: $id'); await _db.transaction(() async { + final notes = await (_db.select( + _db.diveCenterGearNotes, + )..where((t) => t.diveCenterId.equals(id))).get(); await clearDiveCenterLinks(_db, _syncRepository, [ id, ], now: DateTime.now().millisecondsSinceEpoch); @@ -231,6 +237,12 @@ class DiveCenterRepository { entityType: 'diveCenters', recordId: id, ); + for (final note in notes) { + await _syncRepository.logDeletion( + entityType: 'diveCenterGearNotes', + recordId: note.id, + ); + } }); SyncEventBus.notifyLocalChange(); _log.info('Deleted dive center: $id'); @@ -272,6 +284,35 @@ class DiveCenterRepository { return result.data['count'] as int? ?? 0; } + /// The newest real dive logged with [centerId], skipping planned dives + /// and [excludingDiveId] (the dive being edited), for the "last time + /// here" card (issue #2075). Null when the diver has no other dive there. + /// stats-scope-exempt: a displayed lookup, not a statistic; a dive the + /// diver excluded from their numbers still tells them what they wore. + Future latestDiveIdAtCenter( + String centerId, { + String? excludingDiveId, + }) async { + final rows = await _db + .customSelect( + ''' + SELECT id FROM dives + WHERE dive_center_id = ? + AND is_planned = 0 + AND (? IS NULL OR id <> ?) + ORDER BY dive_date_time DESC, id DESC + LIMIT 1 + ''', + variables: [ + Variable.withString(centerId), + Variable(excludingDiveId), + Variable(excludingDiveId), + ], + ) + .get(); + return rows.isEmpty ? null : rows.single.read('id'); + } + /// Get all unique countries Future> getCountries({String? diverId}) async { final diverFilter = diverId != null ? 'AND diver_id = ?' : ''; diff --git a/lib/features/dive_centers/domain/entities/dive_center_gear_note.dart b/lib/features/dive_centers/domain/entities/dive_center_gear_note.dart new file mode 100644 index 0000000000..5548089032 --- /dev/null +++ b/lib/features/dive_centers/domain/entities/dive_center_gear_note.dart @@ -0,0 +1,116 @@ +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; + +/// Whether a piece of an operator's rental gear is worth asking for again. +enum RentalVerdict { worked, avoid } + +/// One thing a diver learned about a dive center's rental gear (issue +/// #2075): "their size-L wetsuit runs small", "regulator 14 breathes wet", +/// "their AL80 really holds 11.1 L", "I needed 2 kg more with their BCD". +/// +/// A note belongs to the center, not to a dive: it survives the dive it was +/// written on and shows on every later visit. [diveId] only records where it +/// was noticed. +class DiveCenterGearNote extends Equatable { + final String id; + final String diveCenterId; + final EquipmentType gearType; + + /// The operator's own mark for the item: "14", "AL80", "blue". + final String? label; + final String? size; + final RentalVerdict verdict; + + /// Signed, in kg: how much more (positive) or less lead this gear needed + /// than the diver's usual. + final double? leadAdjustmentKg; + + /// The cylinder's true capacity, for tank notes. + final double? volumeLiters; + final String note; + final String? diveId; + final DateTime notedAt; + final DateTime createdAt; + final DateTime updatedAt; + + const DiveCenterGearNote({ + required this.id, + required this.diveCenterId, + required this.gearType, + this.label, + this.size, + this.verdict = RentalVerdict.worked, + this.leadAdjustmentKg, + this.volumeLiters, + this.note = '', + this.diveId, + required this.notedAt, + required this.createdAt, + required this.updatedAt, + }); + + /// A stored verdict name, tolerating a value this build does not know. + static RentalVerdict verdictFromName(String? name) => RentalVerdict.values + .firstWhere((v) => v.name == name, orElse: () => RentalVerdict.worked); + + /// A stored gear type name; a type added by a newer peer reads as other. + static EquipmentType gearTypeFromName(String? name) => EquipmentType.values + .firstWhere((t) => t.name == name, orElse: () => EquipmentType.other); + + DiveCenterGearNote copyWith({ + String? id, + String? diveCenterId, + EquipmentType? gearType, + String? label, + bool clearLabel = false, + String? size, + bool clearSize = false, + RentalVerdict? verdict, + double? leadAdjustmentKg, + bool clearLeadAdjustment = false, + double? volumeLiters, + bool clearVolume = false, + String? note, + String? diveId, + bool clearDiveId = false, + DateTime? notedAt, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return DiveCenterGearNote( + id: id ?? this.id, + diveCenterId: diveCenterId ?? this.diveCenterId, + gearType: gearType ?? this.gearType, + label: clearLabel ? null : (label ?? this.label), + size: clearSize ? null : (size ?? this.size), + verdict: verdict ?? this.verdict, + leadAdjustmentKg: clearLeadAdjustment + ? null + : (leadAdjustmentKg ?? this.leadAdjustmentKg), + volumeLiters: clearVolume ? null : (volumeLiters ?? this.volumeLiters), + note: note ?? this.note, + diveId: clearDiveId ? null : (diveId ?? this.diveId), + notedAt: notedAt ?? this.notedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [ + id, + diveCenterId, + gearType, + label, + size, + verdict, + leadAdjustmentKg, + volumeLiters, + note, + diveId, + notedAt, + createdAt, + updatedAt, + ]; +} diff --git a/lib/features/dive_centers/domain/services/rental_memory_resolver.dart b/lib/features/dive_centers/domain/services/rental_memory_resolver.dart new file mode 100644 index 0000000000..3eda217893 --- /dev/null +++ b/lib/features/dive_centers/domain/services/rental_memory_resolver.dart @@ -0,0 +1,95 @@ +import 'package:equatable/equatable.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; + +/// What the diver's most recent dive at a center says about the rental rig +/// they used (issue #2075): the lead they carried, how it felt, and the +/// cylinders. Nothing here is stored; it is read off the hydrated dive every +/// time, so it can never drift from the dive itself. +/// +/// Build it only from the fully hydrated dive (`DiveRepository.getDiveById`). +/// The lean analysis hydration leaves `weights` empty, which would make every +/// last dive look weightless. +class LastDiveAtCenter extends Equatable { + final String diveId; + final DateTime dateTime; + final List weights; + final WeightingFeedback? weightingFeedback; + final double? weightingFeedbackKg; + final List tanks; + + const LastDiveAtCenter({ + required this.diveId, + required this.dateTime, + required this.weights, + required this.weightingFeedback, + required this.weightingFeedbackKg, + required this.tanks, + }); + + factory LastDiveAtCenter.fromDive(Dive dive) => LastDiveAtCenter( + diveId: dive.id, + dateTime: dive.dateTime, + weights: List.unmodifiable(dive.weights), + weightingFeedback: dive.weightingFeedback, + weightingFeedbackKg: dive.weightingFeedbackKg, + tanks: List.unmodifiable(dive.tanks), + ); + + double get totalLeadKg => weights.fold(0.0, (sum, w) => sum + w.amountKg); + + /// The weights as rows for a new dive, under fresh ids so the copies are + /// independent of the source dive once applied. + List weightsForNewDive({ + required String diveId, + required String Function() newId, + }) => [ + for (final w in weights) + DiveWeight( + id: newId(), + diveId: diveId, + weightType: w.weightType, + amountKg: w.amountKg, + notes: w.notes, + ), + ]; + + /// The cylinders as rows for a new dive: the rig (size, rating, preset, + /// material, mix, role, order) travels; the pressures do not, because the + /// new dive has not happened yet, and neither do the computer, transmitter + /// and owned-gear links, which describe the old dive's hardware. + List tanksForNewDive({ + required String Function() newId, + required double startPressure, + required double endPressure, + }) => [ + for (final t in tanks) + DiveTank( + id: newId(), + name: t.name, + volume: t.volume, + workingPressure: t.workingPressure, + startPressure: startPressure, + endPressure: endPressure, + gasMix: t.gasMix, + role: t.role, + material: t.material, + order: t.order, + presetName: t.presetName, + decoSwitchDepth: t.decoSwitchDepth, + isTravelGas: t.isTravelGas, + ), + ]; + + @override + List get props => [ + diveId, + dateTime, + weights, + weightingFeedback, + weightingFeedbackKg, + tanks, + ]; +} diff --git a/lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart b/lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart index 3c4d4c8355..f971c9a947 100644 --- a/lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart +++ b/lib/features/dive_centers/presentation/pages/dive_center_detail_page.dart @@ -15,6 +15,7 @@ import 'package:submersion/shared/widgets/master_detail/responsive_breakpoints.d import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_section.dart'; import 'package:submersion/features/dive_centers/presentation/widgets/dive_center_delete_usage.dart'; import 'package:submersion/features/maps/presentation/providers/map_tile_providers.dart'; import 'package:submersion/features/maps/presentation/widgets/map_attribution.dart'; @@ -107,6 +108,8 @@ class _DiveCenterDetailPageState extends ConsumerState { _NotesSection(notes: center.notes), ], const Divider(height: 32), + RentalGearSection(centerId: widget.centerId), + const Divider(height: 32), _DivesSection(centerId: widget.centerId), const SizedBox(height: 32), ], diff --git a/lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart b/lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart new file mode 100644 index 0000000000..cbf86e3ff7 --- /dev/null +++ b/lib/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart @@ -0,0 +1,45 @@ +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_repository_provider.dart'; + +final diveCenterGearNoteRepositoryProvider = + Provider((ref) { + return DiveCenterGearNoteRepository(); + }); + +/// A center's rental gear notes, newest first. Self-invalidates on any +/// write to the table, local or synced. +final diveCenterGearNotesProvider = + FutureProvider.family, String>(( + ref, + centerId, + ) async { + final repository = ref.watch(diveCenterGearNoteRepositoryProvider); + ref.invalidateSelfWhen(repository.watchChanges()); + return repository.getForCenter(centerId); + }); + +/// Which center, and which dive to leave out (the one being edited). +typedef LastDiveAtCenterQuery = ({String centerId, String? excludingDiveId}); + +/// The diver's most recent other dive at a center, fully hydrated, or null. +/// Reads the `dives` table, so it self-invalidates on dive changes. +final lastDiveAtCenterProvider = + FutureProvider.family(( + ref, + query, + ) async { + final centers = ref.watch(diveCenterRepositoryProvider); + final dives = ref.watch(diveRepositoryProvider); + ref.invalidateSelfWhen(dives.watchDivesChanges()); + final id = await centers.latestDiveIdAtCenter( + query.centerId, + excludingDiveId: query.excludingDiveId, + ); + if (id == null) return null; + final dive = await dives.getDiveById(id); + return dive == null ? null : LastDiveAtCenter.fromDive(dive); + }); diff --git a/lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart b/lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart new file mode 100644 index 0000000000..a25bea24b6 --- /dev/null +++ b/lib/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/number_input.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/equipment/domain/constants/equipment_type_order.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_enum_display.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// Opens the rental gear note editor (issue #2075) for a center. With +/// [editing], the sheet edits that note; otherwise it creates one for +/// [diveCenterId], remembering [diveId] as where it was noticed. +Future showRentalGearNoteSheet( + BuildContext context, { + required String diveCenterId, + String? diveId, + DiveCenterGearNote? editing, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _RentalGearNoteSheet( + diveCenterId: diveCenterId, + diveId: diveId, + editing: editing, + ), + ); +} + +/// Asks before deleting [note], then deletes it. Returns true when the +/// note is gone. Shared by the sheet and the detail page section. +Future confirmDeleteRentalGearNote( + BuildContext context, + WidgetRef ref, + DiveCenterGearNote note, +) async { + final l10n = context.l10n; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(l10n.diveCenters_rental_deleteConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.common_action_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.common_action_delete), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + await ref.read(diveCenterGearNoteRepositoryProvider).delete(note.id); + return true; +} + +class _RentalGearNoteSheet extends ConsumerStatefulWidget { + final String diveCenterId; + final String? diveId; + final DiveCenterGearNote? editing; + + const _RentalGearNoteSheet({ + required this.diveCenterId, + this.diveId, + this.editing, + }); + + @override + ConsumerState<_RentalGearNoteSheet> createState() => + _RentalGearNoteSheetState(); +} + +class _RentalGearNoteSheetState extends ConsumerState<_RentalGearNoteSheet> { + late EquipmentType _gearType; + late RentalVerdict _verdict; + late final TextEditingController _label; + late final TextEditingController _size; + late final TextEditingController _lead; + late final TextEditingController _volume; + late final TextEditingController _note; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final existing = widget.editing; + final units = UnitFormatter(ref.read(settingsProvider)); + _gearType = existing?.gearType ?? EquipmentType.other; + _verdict = existing?.verdict ?? RentalVerdict.worked; + _label = TextEditingController(text: existing?.label ?? ''); + _size = TextEditingController(text: existing?.size ?? ''); + _lead = TextEditingController( + text: existing?.leadAdjustmentKg == null + ? '' + : formatRoundedForInput( + units.convertWeight(existing!.leadAdjustmentKg!), + 2, + ), + ); + _volume = TextEditingController( + text: existing?.volumeLiters == null + ? '' + : formatRoundedForInput( + units.convertVolume(existing!.volumeLiters!), + 2, + ), + ); + _note = TextEditingController(text: existing?.note ?? ''); + } + + @override + void dispose() { + _label.dispose(); + _size.dispose(); + _lead.dispose(); + _volume.dispose(); + _note.dispose(); + super.dispose(); + } + + Future _save() async { + if (_saving) return; + final l10n = context.l10n; + final units = UnitFormatter(ref.read(settingsProvider)); + final leadText = _lead.text.trim(); + final volumeText = _volume.text.trim(); + final lead = leadText.isEmpty ? null : parseUserDecimal(leadText); + final volume = volumeText.isEmpty ? null : parseUserDecimal(volumeText); + // Unreadable text is refused rather than silently dropped. + if ((leadText.isNotEmpty && lead == null) || + (_gearType == EquipmentType.tank && + volumeText.isNotEmpty && + volume == null)) { + setState(() => _error = l10n.numberInput_invalidValue); + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + final repo = ref.read(diveCenterGearNoteRepositoryProvider); + final existing = widget.editing; + final leadKg = lead == null ? null : units.weightToKg(lead); + final volumeL = _gearType == EquipmentType.tank && volume != null + ? units.volumeToLiters(volume) + : null; + if (existing == null) { + final now = DateTime.now().toUtc(); + await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: widget.diveCenterId, + gearType: _gearType, + label: _label.text, + size: _size.text, + verdict: _verdict, + leadAdjustmentKg: leadKg, + volumeLiters: volumeL, + note: _note.text, + diveId: widget.diveId, + notedAt: now, + createdAt: now, + updatedAt: now, + ), + ); + } else { + await repo.update( + existing.copyWith( + gearType: _gearType, + label: _label.text, + clearLabel: _label.text.trim().isEmpty, + size: _size.text, + clearSize: _size.text.trim().isEmpty, + verdict: _verdict, + leadAdjustmentKg: leadKg, + clearLeadAdjustment: leadKg == null, + volumeLiters: volumeL, + clearVolume: volumeL == null, + note: _note.text, + ), + ); + } + if (mounted) Navigator.of(context).pop(); + } catch (_) { + // A concurrent sync can delete the center mid-save. Say so and keep + // the editor open to try again. + if (mounted) setState(() => _error = l10n.common_error_tryAgain); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final note = widget.editing; + if (note == null) return; + final deleted = await confirmDeleteRentalGearNote(context, ref, note); + if (deleted && mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final units = UnitFormatter(ref.watch(settingsProvider)); + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.viewInsetsOf(context).bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.editing == null + ? l10n.diveCenters_rental_sheetTitleNew + : l10n.diveCenters_rental_sheetTitleEdit, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _gearType, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_gearTypeLabel, + ), + isExpanded: true, + items: [ + for (final type in kCanonicalTypeOrder) + DropdownMenuItem( + value: type, + child: Text(type.localizedName(l10n)), + ), + ], + onChanged: (value) { + if (value != null) setState(() => _gearType = value); + }, + ), + const SizedBox(height: 12), + TextField( + controller: _label, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_labelLabel, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _size, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_sizeLabel, + ), + ), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + ButtonSegment( + value: RentalVerdict.worked, + label: Text(l10n.diveCenters_rental_verdictWorked), + ), + ButtonSegment( + value: RentalVerdict.avoid, + label: Text(l10n.diveCenters_rental_verdictAvoid), + ), + ], + selected: {_verdict}, + onSelectionChanged: (selection) => + setState(() => _verdict = selection.first), + ), + const SizedBox(height: 12), + TextField( + controller: _lead, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_leadAdjustmentLabel( + units.weightSymbol, + ), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + ), + if (_gearType == EquipmentType.tank) ...[ + const SizedBox(height: 12), + TextField( + controller: _volume, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_volumeLabel( + units.volumeSymbol, + ), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _note, + decoration: InputDecoration( + labelText: l10n.diveCenters_rental_noteLabel, + ), + maxLines: 3, + ), + if (_error case final error?) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + error, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + if (widget.editing != null) + TextButton( + onPressed: _saving ? null : _delete, + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + child: Text(l10n.common_action_delete), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.common_action_cancel), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _saving ? null : _save, + child: Text(l10n.common_action_save), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dive_centers/presentation/widgets/rental_gear_section.dart b/lib/features/dive_centers/presentation/widgets/rental_gear_section.dart new file mode 100644 index 0000000000..c56eb4a4e8 --- /dev/null +++ b/lib/features/dive_centers/presentation/widgets/rental_gear_section.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; +import 'package:submersion/features/equipment/domain/constants/equipment_type_order.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// The dive center detail page's rental gear notes (issue #2075), grouped +/// by gear type in the canonical equipment order, with add on the header +/// and edit on tap. Matches the page's other sections: a titled column with +/// a 16 px gutter, no card. +class RentalGearSection extends ConsumerWidget { + final String centerId; + + const RentalGearSection({super.key, required this.centerId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + final notes = + ref.watch(diveCenterGearNotesProvider(centerId)).value ?? + const []; + final ordered = [...notes] + ..sort((a, b) { + final byType = + equipmentTypeRank(a.gearType, kCanonicalTypeOrder) - + equipmentTypeRank(b.gearType, kCanonicalTypeOrder); + if (byType != 0) return byType; + return b.notedAt.compareTo(a.notedAt); + }); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.diveCenters_rental_sectionTitle, + style: theme.textTheme.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.add), + tooltip: l10n.diveCenters_rental_addNote, + onPressed: () => + showRentalGearNoteSheet(context, diveCenterId: centerId), + ), + ], + ), + if (ordered.isEmpty) + Text( + l10n.diveCenters_rental_empty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + for (final note in ordered) + RentalNoteTile( + note: note, + units: units, + onTap: () => showRentalGearNoteSheet( + context, + diveCenterId: centerId, + editing: note, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/dive_centers/presentation/widgets/rental_memory_card.dart b/lib/features/dive_centers/presentation/widgets/rental_memory_card.dart new file mode 100644 index 0000000000..5baf7f3921 --- /dev/null +++ b/lib/features/dive_centers/presentation/widgets/rental_memory_card.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/utils/unit_formatter.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_enum_display.dart'; +import 'package:submersion/features/equipment/presentation/utils/equipment_type_icon.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/features/weight_planner/presentation/widgets/weight_enum_display.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// "Last time at {center}" (issue #2075): the diver's most recent other +/// dive at the selected center (lead, how it felt, cylinders) and their +/// rental gear notes, with a way to add a note and to copy that dive's +/// weights and tanks into the form. +/// +/// Shown under the dive center row of the dive edit form. The card only +/// reports; [onApplyLastDive] hands the last dive to the page, which owns +/// the form state and the confirm dialog. +class RentalMemoryCard extends ConsumerWidget { + final DiveCenter center; + + /// The dive being edited, left out of the "last dive" lookup. Null while + /// creating a dive. + final String? currentDiveId; + final void Function(LastDiveAtCenter last) onApplyLastDive; + + const RentalMemoryCard({ + super.key, + required this.center, + this.currentDiveId, + required this.onApplyLastDive, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final units = UnitFormatter(ref.watch(settingsProvider)); + final notes = + ref.watch(diveCenterGearNotesProvider(center.id)).value ?? + const []; + final lastAsync = ref.watch( + lastDiveAtCenterProvider(( + centerId: center.id, + excludingDiveId: currentDiveId, + )), + ); + final last = lastAsync.value; + + void addNote() => showRentalGearNoteSheet( + context, + diveCenterId: center.id, + diveId: currentDiveId, + ); + + if (last == null && notes.isEmpty) { + if (lastAsync.isLoading) return const SizedBox.shrink(); + return Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: addNote, + icon: const Icon(Icons.note_add_outlined), + label: Text(l10n.diveCenters_rental_addNote), + ), + ); + } + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.diveCenters_rental_lastTimeAt(center.name), + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + if (last == null) + Text( + l10n.diveCenters_rental_noHistory, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + _LastDiveRows(last: last, units: units), + if (notes.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final note in notes) + RentalNoteTile( + note: note, + units: units, + onTap: () => showRentalGearNoteSheet( + context, + diveCenterId: center.id, + diveId: currentDiveId, + editing: note, + ), + ), + ], + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: addNote, + icon: const Icon(Icons.note_add_outlined), + label: Text(l10n.diveCenters_rental_addNote), + ), + if (last != null) + FilledButton.tonalIcon( + onPressed: () => onApplyLastDive(last), + icon: const Icon(Icons.history), + label: Text(l10n.diveCenters_rental_applyLastDive), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _LastDiveRows extends StatelessWidget { + final LastDiveAtCenter last; + final UnitFormatter units; + + const _LastDiveRows({required this.last, required this.units}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + final feedback = feedbackLabel(l10n, units); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.diveCenters_rental_lastDiveOn(units.formatDate(last.dateTime)), + style: muted, + ), + if (last.weights.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + l10n.diveCenters_rental_leadTotal( + units.formatWeight(last.totalLeadKg), + ), + style: theme.textTheme.bodyMedium, + ), + for (final w in last.weights) + Text( + '${w.weightType.localizedName(l10n)} ' + '${units.formatWeight(w.amountKg)}', + style: muted, + ), + if (feedback != null) + Chip( + label: Text(feedback), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ], + if (last.tanks.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(l10n.diveCenters_rental_tanksLabel, style: muted), + for (final t in last.tanks) + Text( + [ + ?(t.presetName?.toUpperCase() ?? t.name), + if (t.volume case final v?) units.formatVolume(v), + if (t.workingPressure case final p?) units.formatPressure(p), + ].join(' · '), + style: theme.textTheme.bodyMedium, + ), + ], + ], + ); + } + + String? feedbackLabel(AppLocalizations l10n, UnitFormatter units) { + final kg = last.weightingFeedbackKg; + return switch (last.weightingFeedback) { + null => null, + WeightingFeedback.correct => l10n.diveLog_edit_weightFeedback_correct, + WeightingFeedback.overweighted => + kg == null + ? l10n.diveLog_edit_weightFeedback_over + : l10n.diveCenters_rental_feedbackOver(units.formatWeight(kg)), + WeightingFeedback.underweighted => + kg == null + ? l10n.diveLog_edit_weightFeedback_under + : l10n.diveCenters_rental_feedbackUnder(units.formatWeight(kg)), + }; + } +} + +/// One rental note: icon, label and size, verdict, its number, its text. +/// Public because the dive center detail page section reuses it. +class RentalNoteTile extends StatelessWidget { + final DiveCenterGearNote note; + final UnitFormatter units; + final VoidCallback onTap; + + const RentalNoteTile({ + super.key, + required this.note, + required this.units, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final avoid = note.verdict == RentalVerdict.avoid; + final details = [ + if (note.leadAdjustmentKg case final kg? when kg != 0) + kg > 0 + ? l10n.diveCenters_rental_extraLead(units.formatWeight(kg)) + : l10n.diveCenters_rental_lessLead(units.formatWeight(-kg)), + if (note.volumeLiters case final v?) + l10n.diveCenters_rental_actualCapacity( + units.formatVolume(v, decimals: 1), + ), + ]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon(equipmentTypeIcon(note.gearType)), + title: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(note.gearType.localizedName(l10n)), + if (note.label case final label?) Text(label), + if (note.size case final size?) Text(size), + Chip( + label: Text( + avoid + ? l10n.diveCenters_rental_verdictAvoid + : l10n.diveCenters_rental_verdictWorked, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: avoid + ? theme.colorScheme.errorContainer + : theme.colorScheme.secondaryContainer, + ), + ], + ), + subtitle: details.isEmpty && note.note.isEmpty + ? null + : Text([...details, if (note.note.isNotEmpty) note.note].join('\n')), + onTap: onTap, + ); + } +} 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 bdee8a28ad..cd5888d3f3 100644 --- a/lib/features/dive_log/presentation/pages/dive_edit_page.dart +++ b/lib/features/dive_log/presentation/pages/dive_edit_page.dart @@ -46,8 +46,10 @@ import 'package:submersion/features/dive_log/presentation/widgets/site_suggestio import 'package:submersion/features/marine_life/domain/entities/species.dart'; import 'package:submersion/features/marine_life/presentation/providers/species_providers.dart'; import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; import 'package:submersion/features/dive_centers/presentation/providers/dive_center_providers.dart'; import 'package:submersion/features/dive_centers/presentation/widgets/dive_center_picker.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; import 'package:submersion/features/tags/domain/entities/tag.dart'; import 'package:submersion/features/tags/presentation/providers/tag_providers.dart'; import 'package:submersion/features/dive_types/presentation/dive_type_display.dart'; @@ -167,6 +169,15 @@ String _seedWeight(double displayValue) => /// [parseUserInt]. Grouping is off, so this is digit-only text. String _seedInt(int value) => _seedDecimal(value.toDouble(), 0); +/// Whether "Apply last dive" must ask before replacing the form's weights +/// and tanks (issue #2075): whenever the form holds any. A weight row with +/// no amount yet still carries its type and notes, so it counts. +@visibleForTesting +bool applyLastDiveNeedsConfirm({ + required List weights, + required List tanks, +}) => weights.isNotEmpty || tanks.isNotEmpty; + class DiveEditPage extends ConsumerStatefulWidget { final String? diveId; @@ -2423,6 +2434,13 @@ class _DiveEditPageState extends ConsumerState { : null, diveCenterName: _selectedDiveCenter?.name, centerCaption: _selectedDiveCenter?.displayLocation, + centerChild: _selectedDiveCenter == null || widget.isBulk + ? null + : RentalMemoryCard( + center: _selectedDiveCenter!, + currentDiveId: widget.diveId, + onApplyLastDive: _applyLastDiveAtCenter, + ), onPickDiveCenter: _showDiveCenterPicker, onClearDiveCenter: () { _markDirty(); @@ -4521,6 +4539,54 @@ class _DiveEditPageState extends ConsumerState { /// Apply a saved weight preset (issue #1609): opens the picker and replaces /// the current weight rows with editable copies of the preset's entries. + /// Copies the weights and tanks of the diver's last dive at the selected + /// center into the form (issue #2075). Asks first when the form already + /// holds any, because the copy replaces them. + Future _applyLastDiveAtCenter(LastDiveAtCenter last) async { + final l10n = context.l10n; + if (applyLastDiveNeedsConfirm(weights: _weights, tanks: _tanks)) { + final replace = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.diveCenters_rental_applyConfirmTitle), + content: Text(l10n.diveCenters_rental_applyConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(l10n.common_action_cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(l10n.diveCenters_rental_applyConfirmReplace), + ), + ], + ), + ); + if (replace != true || !mounted) return; + } + final settings = ref.read(settingsProvider); + setState(() { + _markDirty(); + _tanksDirty = true; + _weights = last.weightsForNewDive( + diveId: widget.diveId ?? '', + newId: _uuid.v4, + ); + _tanks + ..clear() + ..addAll( + last.tanksForNewDive( + newId: _uuid.v4, + startPressure: settings.defaultStartPressure.toDouble(), + endPressure: 50.0, + ), + ); + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.diveCenters_rental_applied))); + } + Future _applyWeightPreset() async { final preset = await showModalBottomSheet( context: context, diff --git a/lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart b/lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart index febaa16322..ba78f7416e 100644 --- a/lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart +++ b/lib/features/dive_log/presentation/widgets/edit_sections/trip_section.dart @@ -23,6 +23,7 @@ class TripSection extends StatelessWidget { this.tripCaption, this.tripSuggestion, this.centerCaption, + this.centerChild, }); final bool expanded; @@ -45,6 +46,10 @@ class TripSection extends StatelessWidget { /// Dive center location, shown under the dive center row. final String? centerCaption; + /// Rendered under the dive center row when a center is selected: the + /// "last time here" rental memory card (issue #2075). + final Widget? centerChild; + Widget _caption(BuildContext context, String text) { final theme = Theme.of(context); return Padding( @@ -101,6 +106,16 @@ class TripSection extends StatelessWidget { onClear: diveCenterName == null ? null : onClearDiveCenter, ), if (centerCaption != null) _caption(context, centerCaption!), + if (centerChild != null) + Padding( + padding: const EdgeInsets.fromLTRB( + FormStyle.groupRadius, + 0, + FormStyle.groupRadius, + 10, + ), + child: centerChild, + ), ], ), ], diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index fdecde1f46..86703dec5b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "مستخدم: {reading}", "plannerCanvas_gas_endReading": "النهاية: {reading}", - "numberInput_invalidValue": "أدخل رقمًا صالحًا" + "numberInput_invalidValue": "أدخل رقمًا صالحًا", + "diveCenters_rental_sectionTitle": "معدات مستأجرة", + "diveCenters_rental_lastTimeAt": "آخر مرة في {center}", + "diveCenters_rental_lastDiveOn": "آخر غوصة هنا: {date}", + "diveCenters_rental_noHistory": "لا توجد غوصات أخرى مسجلة هنا بعد.", + "diveCenters_rental_empty": "لا توجد ملاحظات عن المعدات المستأجرة لهذا المركز بعد.", + "diveCenters_rental_addNote": "إضافة ملاحظة تأجير", + "diveCenters_rental_applyLastDive": "تطبيق آخر غوصة", + "diveCenters_rental_applied": "تم نسخ الأثقال والأسطوانات من آخر غوصة لك هنا.", + "diveCenters_rental_applyConfirmTitle": "استبدال الأثقال والأسطوانات؟", + "diveCenters_rental_applyConfirmBody": "تحتوي هذه الغوصة بالفعل على أثقال أو أسطوانات. هل تريد استبدالها بتلك الموجودة في آخر غوصة لك هنا؟", + "diveCenters_rental_applyConfirmReplace": "استبدال", + "diveCenters_rental_leadTotal": "الأثقال: {total}", + "diveCenters_rental_feedbackOver": "{amount} زيادة", + "diveCenters_rental_feedbackUnder": "{amount} نقص", + "diveCenters_rental_tanksLabel": "الأسطوانات", + "diveCenters_rental_verdictWorked": "مناسب", + "diveCenters_rental_verdictAvoid": "تجنّب", + "diveCenters_rental_extraLead": "{amount} أثقال إضافية", + "diveCenters_rental_lessLead": "{amount} أثقال أقل", + "diveCenters_rental_actualCapacity": "السعة الفعلية {volume}", + "diveCenters_rental_sheetTitleNew": "ملاحظة تأجير جديدة", + "diveCenters_rental_sheetTitleEdit": "تعديل ملاحظة التأجير", + "diveCenters_rental_gearTypeLabel": "نوع المعدات", + "diveCenters_rental_labelLabel": "الملصق أو الرقم", + "diveCenters_rental_sizeLabel": "المقاس", + "diveCenters_rental_leadAdjustmentLabel": "الأثقال الإضافية المطلوبة ({unit})", + "diveCenters_rental_volumeLabel": "السعة الفعلية ({unit})", + "diveCenters_rental_noteLabel": "ملاحظة", + "diveCenters_rental_deleteConfirm": "حذف ملاحظة التأجير هذه؟" } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 23e2798496..d57ec3da7a 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "verbraucht: {reading}", "plannerCanvas_gas_endReading": "ende: {reading}", - "numberInput_invalidValue": "Geben Sie eine gültige Zahl ein" + "numberInput_invalidValue": "Geben Sie eine gültige Zahl ein", + "diveCenters_rental_sectionTitle": "Leihausrüstung", + "diveCenters_rental_lastTimeAt": "Letztes Mal bei {center}", + "diveCenters_rental_lastDiveOn": "Letzter Tauchgang hier: {date}", + "diveCenters_rental_noHistory": "Hier sind noch keine weiteren Tauchgänge eingetragen.", + "diveCenters_rental_empty": "Noch keine Notizen zur Leihausrüstung dieses Centers.", + "diveCenters_rental_addNote": "Leihnotiz hinzufügen", + "diveCenters_rental_applyLastDive": "Letzten Tauchgang übernehmen", + "diveCenters_rental_applied": "Blei und Flaschen vom letzten Tauchgang hier übernommen.", + "diveCenters_rental_applyConfirmTitle": "Blei und Flaschen ersetzen?", + "diveCenters_rental_applyConfirmBody": "Dieser Tauchgang hat bereits Blei oder Flaschen. Durch die vom letzten Tauchgang hier ersetzen?", + "diveCenters_rental_applyConfirmReplace": "Ersetzen", + "diveCenters_rental_leadTotal": "Blei: {total}", + "diveCenters_rental_feedbackOver": "{amount} zu viel", + "diveCenters_rental_feedbackUnder": "{amount} zu wenig", + "diveCenters_rental_tanksLabel": "Flaschen", + "diveCenters_rental_verdictWorked": "Hat gepasst", + "diveCenters_rental_verdictAvoid": "Meiden", + "diveCenters_rental_extraLead": "{amount} mehr Blei", + "diveCenters_rental_lessLead": "{amount} weniger Blei", + "diveCenters_rental_actualCapacity": "Tatsächliches Volumen {volume}", + "diveCenters_rental_sheetTitleNew": "Neue Leihnotiz", + "diveCenters_rental_sheetTitleEdit": "Leihnotiz bearbeiten", + "diveCenters_rental_gearTypeLabel": "Ausrüstungstyp", + "diveCenters_rental_labelLabel": "Kennzeichnung oder Nummer", + "diveCenters_rental_sizeLabel": "Größe", + "diveCenters_rental_leadAdjustmentLabel": "Zusätzlich benötigtes Blei ({unit})", + "diveCenters_rental_volumeLabel": "Tatsächliches Volumen ({unit})", + "diveCenters_rental_noteLabel": "Notiz", + "diveCenters_rental_deleteConfirm": "Diese Leihnotiz löschen?" } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 24faa064e1..155eb0c49a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -25147,5 +25147,44 @@ "plannerCanvas_compare_planned": "Plan", "plannerCanvas_compare_actual": "Actual", "plannerCanvas_compare_showOnChart": "Show on chart", - "numberInput_invalidValue": "Enter a valid number" + "numberInput_invalidValue": "Enter a valid number", + "diveCenters_rental_sectionTitle": "Rental gear", + "diveCenters_rental_lastTimeAt": "Last time at {center}", + "@diveCenters_rental_lastTimeAt": {"placeholders": {"center": {"type": "Object"}}}, + "diveCenters_rental_lastDiveOn": "Last dive here: {date}", + "@diveCenters_rental_lastDiveOn": {"placeholders": {"date": {"type": "Object"}}}, + "diveCenters_rental_noHistory": "No other dives logged here yet.", + "diveCenters_rental_empty": "No rental notes for this center yet.", + "diveCenters_rental_addNote": "Add rental note", + "diveCenters_rental_applyLastDive": "Apply last dive", + "diveCenters_rental_applied": "Weights and tanks copied from your last dive here.", + "diveCenters_rental_applyConfirmTitle": "Replace weights and tanks?", + "diveCenters_rental_applyConfirmBody": "This dive already has weights or tanks. Replace them with the ones from your last dive here?", + "diveCenters_rental_applyConfirmReplace": "Replace", + "diveCenters_rental_leadTotal": "Lead: {total}", + "@diveCenters_rental_leadTotal": {"placeholders": {"total": {"type": "Object"}}}, + "diveCenters_rental_feedbackOver": "{amount} over", + "@diveCenters_rental_feedbackOver": {"placeholders": {"amount": {"type": "Object"}}}, + "diveCenters_rental_feedbackUnder": "{amount} under", + "@diveCenters_rental_feedbackUnder": {"placeholders": {"amount": {"type": "Object"}}}, + "diveCenters_rental_tanksLabel": "Tanks", + "diveCenters_rental_verdictWorked": "Worked", + "diveCenters_rental_verdictAvoid": "Avoid", + "diveCenters_rental_extraLead": "{amount} extra lead", + "@diveCenters_rental_extraLead": {"placeholders": {"amount": {"type": "Object"}}}, + "diveCenters_rental_lessLead": "{amount} less lead", + "@diveCenters_rental_lessLead": {"placeholders": {"amount": {"type": "Object"}}}, + "diveCenters_rental_actualCapacity": "Actual capacity {volume}", + "@diveCenters_rental_actualCapacity": {"placeholders": {"volume": {"type": "Object"}}}, + "diveCenters_rental_sheetTitleNew": "New rental note", + "diveCenters_rental_sheetTitleEdit": "Edit rental note", + "diveCenters_rental_gearTypeLabel": "Gear type", + "diveCenters_rental_labelLabel": "Label or number", + "diveCenters_rental_sizeLabel": "Size", + "diveCenters_rental_leadAdjustmentLabel": "Extra lead needed ({unit})", + "@diveCenters_rental_leadAdjustmentLabel": {"placeholders": {"unit": {"type": "Object"}}}, + "diveCenters_rental_volumeLabel": "Actual capacity ({unit})", + "@diveCenters_rental_volumeLabel": {"placeholders": {"unit": {"type": "Object"}}}, + "diveCenters_rental_noteLabel": "Note", + "diveCenters_rental_deleteConfirm": "Delete this rental note?" } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 16d5bea17b..ca781def5c 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "usado: {reading}", "plannerCanvas_gas_endReading": "final: {reading}", - "numberInput_invalidValue": "Ingresa un número válido" + "numberInput_invalidValue": "Ingresa un número válido", + "diveCenters_rental_sectionTitle": "Equipo de alquiler", + "diveCenters_rental_lastTimeAt": "La última vez en {center}", + "diveCenters_rental_lastDiveOn": "Última inmersión aquí: {date}", + "diveCenters_rental_noHistory": "Aún no hay otras inmersiones registradas aquí.", + "diveCenters_rental_empty": "Aún no hay notas de alquiler para este centro.", + "diveCenters_rental_addNote": "Añadir nota de alquiler", + "diveCenters_rental_applyLastDive": "Aplicar última inmersión", + "diveCenters_rental_applied": "Lastre y botellas copiados de tu última inmersión aquí.", + "diveCenters_rental_applyConfirmTitle": "¿Reemplazar lastre y botellas?", + "diveCenters_rental_applyConfirmBody": "Esta inmersión ya tiene lastre o botellas. ¿Reemplazarlos por los de tu última inmersión aquí?", + "diveCenters_rental_applyConfirmReplace": "Reemplazar", + "diveCenters_rental_leadTotal": "Lastre: {total}", + "diveCenters_rental_feedbackOver": "{amount} de más", + "diveCenters_rental_feedbackUnder": "{amount} de menos", + "diveCenters_rental_tanksLabel": "Botellas", + "diveCenters_rental_verdictWorked": "Funcionó", + "diveCenters_rental_verdictAvoid": "Evitar", + "diveCenters_rental_extraLead": "{amount} más de lastre", + "diveCenters_rental_lessLead": "{amount} menos de lastre", + "diveCenters_rental_actualCapacity": "Capacidad real {volume}", + "diveCenters_rental_sheetTitleNew": "Nueva nota de alquiler", + "diveCenters_rental_sheetTitleEdit": "Editar nota de alquiler", + "diveCenters_rental_gearTypeLabel": "Tipo de equipo", + "diveCenters_rental_labelLabel": "Etiqueta o número", + "diveCenters_rental_sizeLabel": "Talla", + "diveCenters_rental_leadAdjustmentLabel": "Lastre adicional necesario ({unit})", + "diveCenters_rental_volumeLabel": "Capacidad real ({unit})", + "diveCenters_rental_noteLabel": "Nota", + "diveCenters_rental_deleteConfirm": "¿Eliminar esta nota de alquiler?" } diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 3908e34381..23c32eee4f 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "utilisé : {reading}", "plannerCanvas_gas_endReading": "fin : {reading}", - "numberInput_invalidValue": "Saisissez un nombre valide" + "numberInput_invalidValue": "Saisissez un nombre valide", + "diveCenters_rental_sectionTitle": "Matériel de location", + "diveCenters_rental_lastTimeAt": "La dernière fois chez {center}", + "diveCenters_rental_lastDiveOn": "Dernière plongée ici : {date}", + "diveCenters_rental_noHistory": "Aucune autre plongée enregistrée ici pour le moment.", + "diveCenters_rental_empty": "Aucune note de location pour ce centre pour le moment.", + "diveCenters_rental_addNote": "Ajouter une note de location", + "diveCenters_rental_applyLastDive": "Reprendre la dernière plongée", + "diveCenters_rental_applied": "Lest et blocs repris de votre dernière plongée ici.", + "diveCenters_rental_applyConfirmTitle": "Remplacer le lest et les blocs ?", + "diveCenters_rental_applyConfirmBody": "Cette plongée a déjà du lest ou des blocs. Les remplacer par ceux de votre dernière plongée ici ?", + "diveCenters_rental_applyConfirmReplace": "Remplacer", + "diveCenters_rental_leadTotal": "Lest : {total}", + "diveCenters_rental_feedbackOver": "{amount} de trop", + "diveCenters_rental_feedbackUnder": "{amount} de moins", + "diveCenters_rental_tanksLabel": "Blocs", + "diveCenters_rental_verdictWorked": "Convenait", + "diveCenters_rental_verdictAvoid": "À éviter", + "diveCenters_rental_extraLead": "{amount} de lest en plus", + "diveCenters_rental_lessLead": "{amount} de lest en moins", + "diveCenters_rental_actualCapacity": "Capacité réelle {volume}", + "diveCenters_rental_sheetTitleNew": "Nouvelle note de location", + "diveCenters_rental_sheetTitleEdit": "Modifier la note de location", + "diveCenters_rental_gearTypeLabel": "Type de matériel", + "diveCenters_rental_labelLabel": "Étiquette ou numéro", + "diveCenters_rental_sizeLabel": "Taille", + "diveCenters_rental_leadAdjustmentLabel": "Lest supplémentaire nécessaire ({unit})", + "diveCenters_rental_volumeLabel": "Capacité réelle ({unit})", + "diveCenters_rental_noteLabel": "Note", + "diveCenters_rental_deleteConfirm": "Supprimer cette note de location ?" } diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index b16de16e65..57ed4056ad 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "בשימוש: {reading}", "plannerCanvas_gas_endReading": "סיום: {reading}", - "numberInput_invalidValue": "הזן מספר תקין" + "numberInput_invalidValue": "הזן מספר תקין", + "diveCenters_rental_sectionTitle": "ציוד מושכר", + "diveCenters_rental_lastTimeAt": "בפעם הקודמת ב-{center}", + "diveCenters_rental_lastDiveOn": "הצלילה האחרונה כאן: {date}", + "diveCenters_rental_noHistory": "עדיין לא נרשמו כאן צלילות נוספות.", + "diveCenters_rental_empty": "עדיין אין הערות על ציוד מושכר במרכז הזה.", + "diveCenters_rental_addNote": "הוספת הערת השכרה", + "diveCenters_rental_applyLastDive": "החלת הצלילה האחרונה", + "diveCenters_rental_applied": "המשקולות והמכלים הועתקו מהצלילה האחרונה שלך כאן.", + "diveCenters_rental_applyConfirmTitle": "להחליף משקולות ומכלים?", + "diveCenters_rental_applyConfirmBody": "לצלילה הזו כבר יש משקולות או מכלים. להחליף אותם באלה מהצלילה האחרונה שלך כאן?", + "diveCenters_rental_applyConfirmReplace": "החלפה", + "diveCenters_rental_leadTotal": "משקולות: {total}", + "diveCenters_rental_feedbackOver": "{amount} יותר מדי", + "diveCenters_rental_feedbackUnder": "{amount} פחות מדי", + "diveCenters_rental_tanksLabel": "מכלים", + "diveCenters_rental_verdictWorked": "התאים", + "diveCenters_rental_verdictAvoid": "להימנע", + "diveCenters_rental_extraLead": "{amount} משקולות נוספות", + "diveCenters_rental_lessLead": "{amount} פחות משקולות", + "diveCenters_rental_actualCapacity": "קיבולת בפועל {volume}", + "diveCenters_rental_sheetTitleNew": "הערת השכרה חדשה", + "diveCenters_rental_sheetTitleEdit": "עריכת הערת השכרה", + "diveCenters_rental_gearTypeLabel": "סוג ציוד", + "diveCenters_rental_labelLabel": "תווית או מספר", + "diveCenters_rental_sizeLabel": "מידה", + "diveCenters_rental_leadAdjustmentLabel": "משקולות נוספות נדרשות ({unit})", + "diveCenters_rental_volumeLabel": "קיבולת בפועל ({unit})", + "diveCenters_rental_noteLabel": "הערה", + "diveCenters_rental_deleteConfirm": "למחוק את הערת ההשכרה הזו?" } diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 685a0180a3..424cc37b86 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "felhasználva: {reading}", "plannerCanvas_gas_endReading": "vég: {reading}", - "numberInput_invalidValue": "Adjon meg érvényes számot" + "numberInput_invalidValue": "Adjon meg érvényes számot", + "diveCenters_rental_sectionTitle": "Bérelt felszerelés", + "diveCenters_rental_lastTimeAt": "Legutóbb itt: {center}", + "diveCenters_rental_lastDiveOn": "Legutóbbi merülés itt: {date}", + "diveCenters_rental_noHistory": "Itt még nincs más rögzített merülés.", + "diveCenters_rental_empty": "Ehhez a központhoz még nincs bérlési jegyzet.", + "diveCenters_rental_addNote": "Bérlési jegyzet hozzáadása", + "diveCenters_rental_applyLastDive": "Legutóbbi merülés alkalmazása", + "diveCenters_rental_applied": "Az ólom és a palackok átmásolva az itteni legutóbbi merülésedből.", + "diveCenters_rental_applyConfirmTitle": "Lecseréled az ólmot és a palackokat?", + "diveCenters_rental_applyConfirmBody": "Ehhez a merüléshez már tartozik ólom vagy palack. Lecseréled az itteni legutóbbi merülésedéire?", + "diveCenters_rental_applyConfirmReplace": "Csere", + "diveCenters_rental_leadTotal": "Ólom: {total}", + "diveCenters_rental_feedbackOver": "{amount} túl sok", + "diveCenters_rental_feedbackUnder": "{amount} túl kevés", + "diveCenters_rental_tanksLabel": "Palackok", + "diveCenters_rental_verdictWorked": "Bevált", + "diveCenters_rental_verdictAvoid": "Kerülendő", + "diveCenters_rental_extraLead": "{amount} plusz ólom", + "diveCenters_rental_lessLead": "{amount} kevesebb ólom", + "diveCenters_rental_actualCapacity": "Tényleges térfogat {volume}", + "diveCenters_rental_sheetTitleNew": "Új bérlési jegyzet", + "diveCenters_rental_sheetTitleEdit": "Bérlési jegyzet szerkesztése", + "diveCenters_rental_gearTypeLabel": "Felszerelés típusa", + "diveCenters_rental_labelLabel": "Címke vagy szám", + "diveCenters_rental_sizeLabel": "Méret", + "diveCenters_rental_leadAdjustmentLabel": "Szükséges plusz ólom ({unit})", + "diveCenters_rental_volumeLabel": "Tényleges térfogat ({unit})", + "diveCenters_rental_noteLabel": "Jegyzet", + "diveCenters_rental_deleteConfirm": "Törlöd ezt a bérlési jegyzetet?" } diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index eaf7713636..c902887032 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "usati: {reading}", "plannerCanvas_gas_endReading": "fine: {reading}", - "numberInput_invalidValue": "Inserisci un numero valido" + "numberInput_invalidValue": "Inserisci un numero valido", + "diveCenters_rental_sectionTitle": "Attrezzatura a noleggio", + "diveCenters_rental_lastTimeAt": "L'ultima volta da {center}", + "diveCenters_rental_lastDiveOn": "Ultima immersione qui: {date}", + "diveCenters_rental_noHistory": "Nessun'altra immersione registrata qui.", + "diveCenters_rental_empty": "Nessuna nota sul noleggio per questo centro.", + "diveCenters_rental_addNote": "Aggiungi nota noleggio", + "diveCenters_rental_applyLastDive": "Applica ultima immersione", + "diveCenters_rental_applied": "Zavorra e bombole copiate dalla tua ultima immersione qui.", + "diveCenters_rental_applyConfirmTitle": "Sostituire zavorra e bombole?", + "diveCenters_rental_applyConfirmBody": "Questa immersione ha già zavorra o bombole. Sostituirle con quelle della tua ultima immersione qui?", + "diveCenters_rental_applyConfirmReplace": "Sostituisci", + "diveCenters_rental_leadTotal": "Zavorra: {total}", + "diveCenters_rental_feedbackOver": "{amount} in più", + "diveCenters_rental_feedbackUnder": "{amount} in meno", + "diveCenters_rental_tanksLabel": "Bombole", + "diveCenters_rental_verdictWorked": "Andava bene", + "diveCenters_rental_verdictAvoid": "Da evitare", + "diveCenters_rental_extraLead": "{amount} di zavorra in più", + "diveCenters_rental_lessLead": "{amount} di zavorra in meno", + "diveCenters_rental_actualCapacity": "Capacità reale {volume}", + "diveCenters_rental_sheetTitleNew": "Nuova nota noleggio", + "diveCenters_rental_sheetTitleEdit": "Modifica nota noleggio", + "diveCenters_rental_gearTypeLabel": "Tipo di attrezzatura", + "diveCenters_rental_labelLabel": "Etichetta o numero", + "diveCenters_rental_sizeLabel": "Taglia", + "diveCenters_rental_leadAdjustmentLabel": "Zavorra aggiuntiva necessaria ({unit})", + "diveCenters_rental_volumeLabel": "Capacità reale ({unit})", + "diveCenters_rental_noteLabel": "Nota", + "diveCenters_rental_deleteConfirm": "Eliminare questa nota sul noleggio?" } diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index f2fe614164..b3d3414954 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -66153,6 +66153,180 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Enter a valid number'** String get numberInput_invalidValue; + + /// No description provided for @diveCenters_rental_sectionTitle. + /// + /// In en, this message translates to: + /// **'Rental gear'** + String get diveCenters_rental_sectionTitle; + + /// No description provided for @diveCenters_rental_lastTimeAt. + /// + /// In en, this message translates to: + /// **'Last time at {center}'** + String diveCenters_rental_lastTimeAt(Object center); + + /// No description provided for @diveCenters_rental_lastDiveOn. + /// + /// In en, this message translates to: + /// **'Last dive here: {date}'** + String diveCenters_rental_lastDiveOn(Object date); + + /// No description provided for @diveCenters_rental_noHistory. + /// + /// In en, this message translates to: + /// **'No other dives logged here yet.'** + String get diveCenters_rental_noHistory; + + /// No description provided for @diveCenters_rental_empty. + /// + /// In en, this message translates to: + /// **'No rental notes for this center yet.'** + String get diveCenters_rental_empty; + + /// No description provided for @diveCenters_rental_addNote. + /// + /// In en, this message translates to: + /// **'Add rental note'** + String get diveCenters_rental_addNote; + + /// No description provided for @diveCenters_rental_applyLastDive. + /// + /// In en, this message translates to: + /// **'Apply last dive'** + String get diveCenters_rental_applyLastDive; + + /// No description provided for @diveCenters_rental_applied. + /// + /// In en, this message translates to: + /// **'Weights and tanks copied from your last dive here.'** + String get diveCenters_rental_applied; + + /// No description provided for @diveCenters_rental_applyConfirmTitle. + /// + /// In en, this message translates to: + /// **'Replace weights and tanks?'** + String get diveCenters_rental_applyConfirmTitle; + + /// No description provided for @diveCenters_rental_applyConfirmBody. + /// + /// In en, this message translates to: + /// **'This dive already has weights or tanks. Replace them with the ones from your last dive here?'** + String get diveCenters_rental_applyConfirmBody; + + /// No description provided for @diveCenters_rental_applyConfirmReplace. + /// + /// In en, this message translates to: + /// **'Replace'** + String get diveCenters_rental_applyConfirmReplace; + + /// No description provided for @diveCenters_rental_leadTotal. + /// + /// In en, this message translates to: + /// **'Lead: {total}'** + String diveCenters_rental_leadTotal(Object total); + + /// No description provided for @diveCenters_rental_feedbackOver. + /// + /// In en, this message translates to: + /// **'{amount} over'** + String diveCenters_rental_feedbackOver(Object amount); + + /// No description provided for @diveCenters_rental_feedbackUnder. + /// + /// In en, this message translates to: + /// **'{amount} under'** + String diveCenters_rental_feedbackUnder(Object amount); + + /// No description provided for @diveCenters_rental_tanksLabel. + /// + /// In en, this message translates to: + /// **'Tanks'** + String get diveCenters_rental_tanksLabel; + + /// No description provided for @diveCenters_rental_verdictWorked. + /// + /// In en, this message translates to: + /// **'Worked'** + String get diveCenters_rental_verdictWorked; + + /// No description provided for @diveCenters_rental_verdictAvoid. + /// + /// In en, this message translates to: + /// **'Avoid'** + String get diveCenters_rental_verdictAvoid; + + /// No description provided for @diveCenters_rental_extraLead. + /// + /// In en, this message translates to: + /// **'{amount} extra lead'** + String diveCenters_rental_extraLead(Object amount); + + /// No description provided for @diveCenters_rental_lessLead. + /// + /// In en, this message translates to: + /// **'{amount} less lead'** + String diveCenters_rental_lessLead(Object amount); + + /// No description provided for @diveCenters_rental_actualCapacity. + /// + /// In en, this message translates to: + /// **'Actual capacity {volume}'** + String diveCenters_rental_actualCapacity(Object volume); + + /// No description provided for @diveCenters_rental_sheetTitleNew. + /// + /// In en, this message translates to: + /// **'New rental note'** + String get diveCenters_rental_sheetTitleNew; + + /// No description provided for @diveCenters_rental_sheetTitleEdit. + /// + /// In en, this message translates to: + /// **'Edit rental note'** + String get diveCenters_rental_sheetTitleEdit; + + /// No description provided for @diveCenters_rental_gearTypeLabel. + /// + /// In en, this message translates to: + /// **'Gear type'** + String get diveCenters_rental_gearTypeLabel; + + /// No description provided for @diveCenters_rental_labelLabel. + /// + /// In en, this message translates to: + /// **'Label or number'** + String get diveCenters_rental_labelLabel; + + /// No description provided for @diveCenters_rental_sizeLabel. + /// + /// In en, this message translates to: + /// **'Size'** + String get diveCenters_rental_sizeLabel; + + /// No description provided for @diveCenters_rental_leadAdjustmentLabel. + /// + /// In en, this message translates to: + /// **'Extra lead needed ({unit})'** + String diveCenters_rental_leadAdjustmentLabel(Object unit); + + /// No description provided for @diveCenters_rental_volumeLabel. + /// + /// In en, this message translates to: + /// **'Actual capacity ({unit})'** + String diveCenters_rental_volumeLabel(Object unit); + + /// No description provided for @diveCenters_rental_noteLabel. + /// + /// In en, this message translates to: + /// **'Note'** + String get diveCenters_rental_noteLabel; + + /// No description provided for @diveCenters_rental_deleteConfirm. + /// + /// In en, this message translates to: + /// **'Delete this rental note?'** + String get diveCenters_rental_deleteConfirm; } class _AppLocalizationsDelegate diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 66e52e5a86..a6a6d3b789 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -40846,4 +40846,116 @@ class AppLocalizationsAr extends AppLocalizations { @override String get numberInput_invalidValue => 'أدخل رقمًا صالحًا'; + + @override + String get diveCenters_rental_sectionTitle => 'معدات مستأجرة'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'آخر مرة في $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'آخر غوصة هنا: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'لا توجد غوصات أخرى مسجلة هنا بعد.'; + + @override + String get diveCenters_rental_empty => + 'لا توجد ملاحظات عن المعدات المستأجرة لهذا المركز بعد.'; + + @override + String get diveCenters_rental_addNote => 'إضافة ملاحظة تأجير'; + + @override + String get diveCenters_rental_applyLastDive => 'تطبيق آخر غوصة'; + + @override + String get diveCenters_rental_applied => + 'تم نسخ الأثقال والأسطوانات من آخر غوصة لك هنا.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'استبدال الأثقال والأسطوانات؟'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'تحتوي هذه الغوصة بالفعل على أثقال أو أسطوانات. هل تريد استبدالها بتلك الموجودة في آخر غوصة لك هنا؟'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'استبدال'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'الأثقال: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount زيادة'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount نقص'; + } + + @override + String get diveCenters_rental_tanksLabel => 'الأسطوانات'; + + @override + String get diveCenters_rental_verdictWorked => 'مناسب'; + + @override + String get diveCenters_rental_verdictAvoid => 'تجنّب'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount أثقال إضافية'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount أثقال أقل'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'السعة الفعلية $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'ملاحظة تأجير جديدة'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'تعديل ملاحظة التأجير'; + + @override + String get diveCenters_rental_gearTypeLabel => 'نوع المعدات'; + + @override + String get diveCenters_rental_labelLabel => 'الملصق أو الرقم'; + + @override + String get diveCenters_rental_sizeLabel => 'المقاس'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'الأثقال الإضافية المطلوبة ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'السعة الفعلية ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'ملاحظة'; + + @override + String get diveCenters_rental_deleteConfirm => 'حذف ملاحظة التأجير هذه؟'; } diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index b33e4d49f3..54740525c3 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -41084,4 +41084,116 @@ class AppLocalizationsDe extends AppLocalizations { @override String get numberInput_invalidValue => 'Geben Sie eine gültige Zahl ein'; + + @override + String get diveCenters_rental_sectionTitle => 'Leihausrüstung'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'Letztes Mal bei $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Letzter Tauchgang hier: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Hier sind noch keine weiteren Tauchgänge eingetragen.'; + + @override + String get diveCenters_rental_empty => + 'Noch keine Notizen zur Leihausrüstung dieses Centers.'; + + @override + String get diveCenters_rental_addNote => 'Leihnotiz hinzufügen'; + + @override + String get diveCenters_rental_applyLastDive => 'Letzten Tauchgang übernehmen'; + + @override + String get diveCenters_rental_applied => + 'Blei und Flaschen vom letzten Tauchgang hier übernommen.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Blei und Flaschen ersetzen?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Dieser Tauchgang hat bereits Blei oder Flaschen. Durch die vom letzten Tauchgang hier ersetzen?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Ersetzen'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Blei: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount zu viel'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount zu wenig'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Flaschen'; + + @override + String get diveCenters_rental_verdictWorked => 'Hat gepasst'; + + @override + String get diveCenters_rental_verdictAvoid => 'Meiden'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount mehr Blei'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount weniger Blei'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Tatsächliches Volumen $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Neue Leihnotiz'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Leihnotiz bearbeiten'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Ausrüstungstyp'; + + @override + String get diveCenters_rental_labelLabel => 'Kennzeichnung oder Nummer'; + + @override + String get diveCenters_rental_sizeLabel => 'Größe'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Zusätzlich benötigtes Blei ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Tatsächliches Volumen ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Notiz'; + + @override + String get diveCenters_rental_deleteConfirm => 'Diese Leihnotiz löschen?'; } diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 5f80cc6276..8651bb7af8 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -40555,4 +40555,114 @@ class AppLocalizationsEn extends AppLocalizations { @override String get numberInput_invalidValue => 'Enter a valid number'; + + @override + String get diveCenters_rental_sectionTitle => 'Rental gear'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'Last time at $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Last dive here: $date'; + } + + @override + String get diveCenters_rental_noHistory => 'No other dives logged here yet.'; + + @override + String get diveCenters_rental_empty => 'No rental notes for this center yet.'; + + @override + String get diveCenters_rental_addNote => 'Add rental note'; + + @override + String get diveCenters_rental_applyLastDive => 'Apply last dive'; + + @override + String get diveCenters_rental_applied => + 'Weights and tanks copied from your last dive here.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Replace weights and tanks?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'This dive already has weights or tanks. Replace them with the ones from your last dive here?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Replace'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Lead: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount over'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount under'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Tanks'; + + @override + String get diveCenters_rental_verdictWorked => 'Worked'; + + @override + String get diveCenters_rental_verdictAvoid => 'Avoid'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount extra lead'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount less lead'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Actual capacity $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'New rental note'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Edit rental note'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Gear type'; + + @override + String get diveCenters_rental_labelLabel => 'Label or number'; + + @override + String get diveCenters_rental_sizeLabel => 'Size'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Extra lead needed ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Actual capacity ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Note'; + + @override + String get diveCenters_rental_deleteConfirm => 'Delete this rental note?'; } diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 028efa007f..954feacfc1 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -41211,4 +41211,117 @@ class AppLocalizationsEs extends AppLocalizations { @override String get numberInput_invalidValue => 'Ingresa un número válido'; + + @override + String get diveCenters_rental_sectionTitle => 'Equipo de alquiler'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'La última vez en $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Última inmersión aquí: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Aún no hay otras inmersiones registradas aquí.'; + + @override + String get diveCenters_rental_empty => + 'Aún no hay notas de alquiler para este centro.'; + + @override + String get diveCenters_rental_addNote => 'Añadir nota de alquiler'; + + @override + String get diveCenters_rental_applyLastDive => 'Aplicar última inmersión'; + + @override + String get diveCenters_rental_applied => + 'Lastre y botellas copiados de tu última inmersión aquí.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + '¿Reemplazar lastre y botellas?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Esta inmersión ya tiene lastre o botellas. ¿Reemplazarlos por los de tu última inmersión aquí?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Reemplazar'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Lastre: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount de más'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount de menos'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Botellas'; + + @override + String get diveCenters_rental_verdictWorked => 'Funcionó'; + + @override + String get diveCenters_rental_verdictAvoid => 'Evitar'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount más de lastre'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount menos de lastre'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Capacidad real $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Nueva nota de alquiler'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Editar nota de alquiler'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Tipo de equipo'; + + @override + String get diveCenters_rental_labelLabel => 'Etiqueta o número'; + + @override + String get diveCenters_rental_sizeLabel => 'Talla'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Lastre adicional necesario ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Capacidad real ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Nota'; + + @override + String get diveCenters_rental_deleteConfirm => + '¿Eliminar esta nota de alquiler?'; } diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 3a087890e2..a545e5d4ba 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -41273,4 +41273,119 @@ class AppLocalizationsFr extends AppLocalizations { @override String get numberInput_invalidValue => 'Saisissez un nombre valide'; + + @override + String get diveCenters_rental_sectionTitle => 'Matériel de location'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'La dernière fois chez $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Dernière plongée ici : $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Aucune autre plongée enregistrée ici pour le moment.'; + + @override + String get diveCenters_rental_empty => + 'Aucune note de location pour ce centre pour le moment.'; + + @override + String get diveCenters_rental_addNote => 'Ajouter une note de location'; + + @override + String get diveCenters_rental_applyLastDive => + 'Reprendre la dernière plongée'; + + @override + String get diveCenters_rental_applied => + 'Lest et blocs repris de votre dernière plongée ici.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Remplacer le lest et les blocs ?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Cette plongée a déjà du lest ou des blocs. Les remplacer par ceux de votre dernière plongée ici ?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Remplacer'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Lest : $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount de trop'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount de moins'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Blocs'; + + @override + String get diveCenters_rental_verdictWorked => 'Convenait'; + + @override + String get diveCenters_rental_verdictAvoid => 'À éviter'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount de lest en plus'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount de lest en moins'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Capacité réelle $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Nouvelle note de location'; + + @override + String get diveCenters_rental_sheetTitleEdit => + 'Modifier la note de location'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Type de matériel'; + + @override + String get diveCenters_rental_labelLabel => 'Étiquette ou numéro'; + + @override + String get diveCenters_rental_sizeLabel => 'Taille'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Lest supplémentaire nécessaire ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Capacité réelle ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Note'; + + @override + String get diveCenters_rental_deleteConfirm => + 'Supprimer cette note de location ?'; } diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 8eef41d66b..a61e81ed6d 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -40351,4 +40351,115 @@ class AppLocalizationsHe extends AppLocalizations { @override String get numberInput_invalidValue => 'הזן מספר תקין'; + + @override + String get diveCenters_rental_sectionTitle => 'ציוד מושכר'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'בפעם הקודמת ב-$center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'הצלילה האחרונה כאן: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'עדיין לא נרשמו כאן צלילות נוספות.'; + + @override + String get diveCenters_rental_empty => + 'עדיין אין הערות על ציוד מושכר במרכז הזה.'; + + @override + String get diveCenters_rental_addNote => 'הוספת הערת השכרה'; + + @override + String get diveCenters_rental_applyLastDive => 'החלת הצלילה האחרונה'; + + @override + String get diveCenters_rental_applied => + 'המשקולות והמכלים הועתקו מהצלילה האחרונה שלך כאן.'; + + @override + String get diveCenters_rental_applyConfirmTitle => 'להחליף משקולות ומכלים?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'לצלילה הזו כבר יש משקולות או מכלים. להחליף אותם באלה מהצלילה האחרונה שלך כאן?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'החלפה'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'משקולות: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount יותר מדי'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount פחות מדי'; + } + + @override + String get diveCenters_rental_tanksLabel => 'מכלים'; + + @override + String get diveCenters_rental_verdictWorked => 'התאים'; + + @override + String get diveCenters_rental_verdictAvoid => 'להימנע'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount משקולות נוספות'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount פחות משקולות'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'קיבולת בפועל $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'הערת השכרה חדשה'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'עריכת הערת השכרה'; + + @override + String get diveCenters_rental_gearTypeLabel => 'סוג ציוד'; + + @override + String get diveCenters_rental_labelLabel => 'תווית או מספר'; + + @override + String get diveCenters_rental_sizeLabel => 'מידה'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'משקולות נוספות נדרשות ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'קיבולת בפועל ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'הערה'; + + @override + String get diveCenters_rental_deleteConfirm => 'למחוק את הערת ההשכרה הזו?'; } diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 1aae86d525..85e21b4ff0 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -41010,4 +41010,119 @@ class AppLocalizationsHu extends AppLocalizations { @override String get numberInput_invalidValue => 'Adjon meg érvényes számot'; + + @override + String get diveCenters_rental_sectionTitle => 'Bérelt felszerelés'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'Legutóbb itt: $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Legutóbbi merülés itt: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Itt még nincs más rögzített merülés.'; + + @override + String get diveCenters_rental_empty => + 'Ehhez a központhoz még nincs bérlési jegyzet.'; + + @override + String get diveCenters_rental_addNote => 'Bérlési jegyzet hozzáadása'; + + @override + String get diveCenters_rental_applyLastDive => + 'Legutóbbi merülés alkalmazása'; + + @override + String get diveCenters_rental_applied => + 'Az ólom és a palackok átmásolva az itteni legutóbbi merülésedből.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Lecseréled az ólmot és a palackokat?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Ehhez a merüléshez már tartozik ólom vagy palack. Lecseréled az itteni legutóbbi merülésedéire?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Csere'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Ólom: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount túl sok'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount túl kevés'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Palackok'; + + @override + String get diveCenters_rental_verdictWorked => 'Bevált'; + + @override + String get diveCenters_rental_verdictAvoid => 'Kerülendő'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount plusz ólom'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount kevesebb ólom'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Tényleges térfogat $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Új bérlési jegyzet'; + + @override + String get diveCenters_rental_sheetTitleEdit => + 'Bérlési jegyzet szerkesztése'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Felszerelés típusa'; + + @override + String get diveCenters_rental_labelLabel => 'Címke vagy szám'; + + @override + String get diveCenters_rental_sizeLabel => 'Méret'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Szükséges plusz ólom ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Tényleges térfogat ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Jegyzet'; + + @override + String get diveCenters_rental_deleteConfirm => + 'Törlöd ezt a bérlési jegyzetet?'; } diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 355037a83a..ca5b2c1e68 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -41168,4 +41168,117 @@ class AppLocalizationsIt extends AppLocalizations { @override String get numberInput_invalidValue => 'Inserisci un numero valido'; + + @override + String get diveCenters_rental_sectionTitle => 'Attrezzatura a noleggio'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'L\'ultima volta da $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Ultima immersione qui: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Nessun\'altra immersione registrata qui.'; + + @override + String get diveCenters_rental_empty => + 'Nessuna nota sul noleggio per questo centro.'; + + @override + String get diveCenters_rental_addNote => 'Aggiungi nota noleggio'; + + @override + String get diveCenters_rental_applyLastDive => 'Applica ultima immersione'; + + @override + String get diveCenters_rental_applied => + 'Zavorra e bombole copiate dalla tua ultima immersione qui.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Sostituire zavorra e bombole?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Questa immersione ha già zavorra o bombole. Sostituirle con quelle della tua ultima immersione qui?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Sostituisci'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Zavorra: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount in più'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount in meno'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Bombole'; + + @override + String get diveCenters_rental_verdictWorked => 'Andava bene'; + + @override + String get diveCenters_rental_verdictAvoid => 'Da evitare'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount di zavorra in più'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount di zavorra in meno'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Capacità reale $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Nuova nota noleggio'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Modifica nota noleggio'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Tipo di attrezzatura'; + + @override + String get diveCenters_rental_labelLabel => 'Etichetta o numero'; + + @override + String get diveCenters_rental_sizeLabel => 'Taglia'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Zavorra aggiuntiva necessaria ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Capacità reale ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Nota'; + + @override + String get diveCenters_rental_deleteConfirm => + 'Eliminare questa nota sul noleggio?'; } diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 76fc24ed72..d31014fba2 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -40895,4 +40895,117 @@ class AppLocalizationsNl extends AppLocalizations { @override String get numberInput_invalidValue => 'Voer een geldig getal in'; + + @override + String get diveCenters_rental_sectionTitle => 'Huuruitrusting'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'Vorige keer bij $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Laatste duik hier: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Hier zijn nog geen andere duiken gelogd.'; + + @override + String get diveCenters_rental_empty => + 'Nog geen huurnotities voor dit centrum.'; + + @override + String get diveCenters_rental_addNote => 'Huurnotitie toevoegen'; + + @override + String get diveCenters_rental_applyLastDive => 'Laatste duik toepassen'; + + @override + String get diveCenters_rental_applied => + 'Lood en flessen overgenomen van je laatste duik hier.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Lood en flessen vervangen?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Deze duik heeft al lood of flessen. Vervangen door die van je laatste duik hier?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Vervangen'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Lood: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount te veel'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount te weinig'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Flessen'; + + @override + String get diveCenters_rental_verdictWorked => 'Beviel'; + + @override + String get diveCenters_rental_verdictAvoid => 'Vermijden'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount extra lood'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount minder lood'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Werkelijke inhoud $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Nieuwe huurnotitie'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Huurnotitie bewerken'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Type uitrusting'; + + @override + String get diveCenters_rental_labelLabel => 'Label of nummer'; + + @override + String get diveCenters_rental_sizeLabel => 'Maat'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Extra lood nodig ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Werkelijke inhoud ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Notitie'; + + @override + String get diveCenters_rental_deleteConfirm => + 'Deze huurnotitie verwijderen?'; } diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 07304e6485..9b7d051324 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -41181,4 +41181,117 @@ class AppLocalizationsPt extends AppLocalizations { @override String get numberInput_invalidValue => 'Insira um número válido'; + + @override + String get diveCenters_rental_sectionTitle => 'Equipamento alugado'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return 'Da última vez em $center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return 'Último mergulho aqui: $date'; + } + + @override + String get diveCenters_rental_noHistory => + 'Ainda não há outros mergulhos registados aqui.'; + + @override + String get diveCenters_rental_empty => + 'Ainda não há notas de aluguer para este centro.'; + + @override + String get diveCenters_rental_addNote => 'Adicionar nota de aluguer'; + + @override + String get diveCenters_rental_applyLastDive => 'Aplicar último mergulho'; + + @override + String get diveCenters_rental_applied => + 'Lastro e garrafas copiados do seu último mergulho aqui.'; + + @override + String get diveCenters_rental_applyConfirmTitle => + 'Substituir lastro e garrafas?'; + + @override + String get diveCenters_rental_applyConfirmBody => + 'Este mergulho já tem lastro ou garrafas. Substituir pelos do seu último mergulho aqui?'; + + @override + String get diveCenters_rental_applyConfirmReplace => 'Substituir'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return 'Lastro: $total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '$amount a mais'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '$amount a menos'; + } + + @override + String get diveCenters_rental_tanksLabel => 'Garrafas'; + + @override + String get diveCenters_rental_verdictWorked => 'Funcionou'; + + @override + String get diveCenters_rental_verdictAvoid => 'Evitar'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '$amount de lastro a mais'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '$amount de lastro a menos'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return 'Capacidade real $volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => 'Nova nota de aluguer'; + + @override + String get diveCenters_rental_sheetTitleEdit => 'Editar nota de aluguer'; + + @override + String get diveCenters_rental_gearTypeLabel => 'Tipo de equipamento'; + + @override + String get diveCenters_rental_labelLabel => 'Etiqueta ou número'; + + @override + String get diveCenters_rental_sizeLabel => 'Tamanho'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return 'Lastro adicional necessário ($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return 'Capacidade real ($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => 'Nota'; + + @override + String get diveCenters_rental_deleteConfirm => + 'Eliminar esta nota de aluguer?'; } diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 8850fa1154..5e3a30470f 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -38772,4 +38772,112 @@ class AppLocalizationsZh extends AppLocalizations { @override String get numberInput_invalidValue => '请输入有效的数字'; + + @override + String get diveCenters_rental_sectionTitle => '租赁装备'; + + @override + String diveCenters_rental_lastTimeAt(Object center) { + return '上次在$center'; + } + + @override + String diveCenters_rental_lastDiveOn(Object date) { + return '上次在此潜水:$date'; + } + + @override + String get diveCenters_rental_noHistory => '此处尚无其他潜水记录。'; + + @override + String get diveCenters_rental_empty => '此中心尚无租赁备注。'; + + @override + String get diveCenters_rental_addNote => '添加租赁备注'; + + @override + String get diveCenters_rental_applyLastDive => '套用上次潜水'; + + @override + String get diveCenters_rental_applied => '已从你上次在此的潜水复制配重和气瓶。'; + + @override + String get diveCenters_rental_applyConfirmTitle => '替换配重和气瓶?'; + + @override + String get diveCenters_rental_applyConfirmBody => + '此次潜水已有配重或气瓶。是否替换为你上次在此潜水的配置?'; + + @override + String get diveCenters_rental_applyConfirmReplace => '替换'; + + @override + String diveCenters_rental_leadTotal(Object total) { + return '配重:$total'; + } + + @override + String diveCenters_rental_feedbackOver(Object amount) { + return '超重$amount'; + } + + @override + String diveCenters_rental_feedbackUnder(Object amount) { + return '欠重$amount'; + } + + @override + String get diveCenters_rental_tanksLabel => '气瓶'; + + @override + String get diveCenters_rental_verdictWorked => '好用'; + + @override + String get diveCenters_rental_verdictAvoid => '避免'; + + @override + String diveCenters_rental_extraLead(Object amount) { + return '多带$amount配重'; + } + + @override + String diveCenters_rental_lessLead(Object amount) { + return '少带$amount配重'; + } + + @override + String diveCenters_rental_actualCapacity(Object volume) { + return '实际容量$volume'; + } + + @override + String get diveCenters_rental_sheetTitleNew => '新建租赁备注'; + + @override + String get diveCenters_rental_sheetTitleEdit => '编辑租赁备注'; + + @override + String get diveCenters_rental_gearTypeLabel => '装备类型'; + + @override + String get diveCenters_rental_labelLabel => '标签或编号'; + + @override + String get diveCenters_rental_sizeLabel => '尺码'; + + @override + String diveCenters_rental_leadAdjustmentLabel(Object unit) { + return '需额外配重($unit)'; + } + + @override + String diveCenters_rental_volumeLabel(Object unit) { + return '实际容量($unit)'; + } + + @override + String get diveCenters_rental_noteLabel => '备注'; + + @override + String get diveCenters_rental_deleteConfirm => '删除此租赁备注?'; } diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index f8c29251d9..d2848d846b 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "verbruikt: {reading}", "plannerCanvas_gas_endReading": "einde: {reading}", - "numberInput_invalidValue": "Voer een geldig getal in" + "numberInput_invalidValue": "Voer een geldig getal in", + "diveCenters_rental_sectionTitle": "Huuruitrusting", + "diveCenters_rental_lastTimeAt": "Vorige keer bij {center}", + "diveCenters_rental_lastDiveOn": "Laatste duik hier: {date}", + "diveCenters_rental_noHistory": "Hier zijn nog geen andere duiken gelogd.", + "diveCenters_rental_empty": "Nog geen huurnotities voor dit centrum.", + "diveCenters_rental_addNote": "Huurnotitie toevoegen", + "diveCenters_rental_applyLastDive": "Laatste duik toepassen", + "diveCenters_rental_applied": "Lood en flessen overgenomen van je laatste duik hier.", + "diveCenters_rental_applyConfirmTitle": "Lood en flessen vervangen?", + "diveCenters_rental_applyConfirmBody": "Deze duik heeft al lood of flessen. Vervangen door die van je laatste duik hier?", + "diveCenters_rental_applyConfirmReplace": "Vervangen", + "diveCenters_rental_leadTotal": "Lood: {total}", + "diveCenters_rental_feedbackOver": "{amount} te veel", + "diveCenters_rental_feedbackUnder": "{amount} te weinig", + "diveCenters_rental_tanksLabel": "Flessen", + "diveCenters_rental_verdictWorked": "Beviel", + "diveCenters_rental_verdictAvoid": "Vermijden", + "diveCenters_rental_extraLead": "{amount} extra lood", + "diveCenters_rental_lessLead": "{amount} minder lood", + "diveCenters_rental_actualCapacity": "Werkelijke inhoud {volume}", + "diveCenters_rental_sheetTitleNew": "Nieuwe huurnotitie", + "diveCenters_rental_sheetTitleEdit": "Huurnotitie bewerken", + "diveCenters_rental_gearTypeLabel": "Type uitrusting", + "diveCenters_rental_labelLabel": "Label of nummer", + "diveCenters_rental_sizeLabel": "Maat", + "diveCenters_rental_leadAdjustmentLabel": "Extra lood nodig ({unit})", + "diveCenters_rental_volumeLabel": "Werkelijke inhoud ({unit})", + "diveCenters_rental_noteLabel": "Notitie", + "diveCenters_rental_deleteConfirm": "Deze huurnotitie verwijderen?" } diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index a2584eb7c3..e389e9e4f2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "usado: {reading}", "plannerCanvas_gas_endReading": "fim: {reading}", - "numberInput_invalidValue": "Insira um número válido" + "numberInput_invalidValue": "Insira um número válido", + "diveCenters_rental_sectionTitle": "Equipamento alugado", + "diveCenters_rental_lastTimeAt": "Da última vez em {center}", + "diveCenters_rental_lastDiveOn": "Último mergulho aqui: {date}", + "diveCenters_rental_noHistory": "Ainda não há outros mergulhos registados aqui.", + "diveCenters_rental_empty": "Ainda não há notas de aluguer para este centro.", + "diveCenters_rental_addNote": "Adicionar nota de aluguer", + "diveCenters_rental_applyLastDive": "Aplicar último mergulho", + "diveCenters_rental_applied": "Lastro e garrafas copiados do seu último mergulho aqui.", + "diveCenters_rental_applyConfirmTitle": "Substituir lastro e garrafas?", + "diveCenters_rental_applyConfirmBody": "Este mergulho já tem lastro ou garrafas. Substituir pelos do seu último mergulho aqui?", + "diveCenters_rental_applyConfirmReplace": "Substituir", + "diveCenters_rental_leadTotal": "Lastro: {total}", + "diveCenters_rental_feedbackOver": "{amount} a mais", + "diveCenters_rental_feedbackUnder": "{amount} a menos", + "diveCenters_rental_tanksLabel": "Garrafas", + "diveCenters_rental_verdictWorked": "Funcionou", + "diveCenters_rental_verdictAvoid": "Evitar", + "diveCenters_rental_extraLead": "{amount} de lastro a mais", + "diveCenters_rental_lessLead": "{amount} de lastro a menos", + "diveCenters_rental_actualCapacity": "Capacidade real {volume}", + "diveCenters_rental_sheetTitleNew": "Nova nota de aluguer", + "diveCenters_rental_sheetTitleEdit": "Editar nota de aluguer", + "diveCenters_rental_gearTypeLabel": "Tipo de equipamento", + "diveCenters_rental_labelLabel": "Etiqueta ou número", + "diveCenters_rental_sizeLabel": "Tamanho", + "diveCenters_rental_leadAdjustmentLabel": "Lastro adicional necessário ({unit})", + "diveCenters_rental_volumeLabel": "Capacidade real ({unit})", + "diveCenters_rental_noteLabel": "Nota", + "diveCenters_rental_deleteConfirm": "Eliminar esta nota de aluguer?" } diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 5f58daffc6..e471a45cff 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -12263,5 +12263,34 @@ "plannerCanvas_compare_showOnChart": "Show on chart", "plannerCanvas_gas_usedReading": "已用: {reading}", "plannerCanvas_gas_endReading": "结束: {reading}", - "numberInput_invalidValue": "请输入有效的数字" + "numberInput_invalidValue": "请输入有效的数字", + "diveCenters_rental_sectionTitle": "租赁装备", + "diveCenters_rental_lastTimeAt": "上次在{center}", + "diveCenters_rental_lastDiveOn": "上次在此潜水:{date}", + "diveCenters_rental_noHistory": "此处尚无其他潜水记录。", + "diveCenters_rental_empty": "此中心尚无租赁备注。", + "diveCenters_rental_addNote": "添加租赁备注", + "diveCenters_rental_applyLastDive": "套用上次潜水", + "diveCenters_rental_applied": "已从你上次在此的潜水复制配重和气瓶。", + "diveCenters_rental_applyConfirmTitle": "替换配重和气瓶?", + "diveCenters_rental_applyConfirmBody": "此次潜水已有配重或气瓶。是否替换为你上次在此潜水的配置?", + "diveCenters_rental_applyConfirmReplace": "替换", + "diveCenters_rental_leadTotal": "配重:{total}", + "diveCenters_rental_feedbackOver": "超重{amount}", + "diveCenters_rental_feedbackUnder": "欠重{amount}", + "diveCenters_rental_tanksLabel": "气瓶", + "diveCenters_rental_verdictWorked": "好用", + "diveCenters_rental_verdictAvoid": "避免", + "diveCenters_rental_extraLead": "多带{amount}配重", + "diveCenters_rental_lessLead": "少带{amount}配重", + "diveCenters_rental_actualCapacity": "实际容量{volume}", + "diveCenters_rental_sheetTitleNew": "新建租赁备注", + "diveCenters_rental_sheetTitleEdit": "编辑租赁备注", + "diveCenters_rental_gearTypeLabel": "装备类型", + "diveCenters_rental_labelLabel": "标签或编号", + "diveCenters_rental_sizeLabel": "尺码", + "diveCenters_rental_leadAdjustmentLabel": "需额外配重({unit})", + "diveCenters_rental_volumeLabel": "实际容量({unit})", + "diveCenters_rental_noteLabel": "备注", + "diveCenters_rental_deleteConfirm": "删除此租赁备注?" } diff --git a/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart index 59de831902..1a41e393b5 100644 --- a/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart +++ b/test/core/database/migration_v220_equipment_set_computer_auto_apply_test.dart @@ -4,12 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/database.dart'; void main() { - test('v220 is the current schema version and is in the ladder', () { - // The newest rung owns the exact assertion; relax it to - // greaterThanOrEqualTo when the next one lands. - expect(AppDatabase.currentSchemaVersion, 220); + test('v220 is in the ladder', () { + // The newest rung owns the exact currentSchemaVersion/step-count + // assertions; relaxed here once v221 (rental gear memory) landed on top. + expect(AppDatabase.currentSchemaVersion, greaterThanOrEqualTo(220)); expect(AppDatabase.migrationVersions, contains(220)); - expect(AppDatabase.migrationStepCount(219), 1); + expect(AppDatabase.migrationStepCount(219), greaterThanOrEqualTo(1)); }); test('the column is additive, so the sync floor does not move', () { diff --git a/test/core/database/migration_v221_dive_center_gear_notes_test.dart b/test/core/database/migration_v221_dive_center_gear_notes_test.dart new file mode 100644 index 0000000000..8f20dafa70 --- /dev/null +++ b/test/core/database/migration_v221_dive_center_gear_notes_test.dart @@ -0,0 +1,157 @@ +import 'package:drift/drift.dart' show Variable; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +/// Schema v221: rental gear memory, the dive_center_gear_notes table +/// (issue #2075). +void main() { + /// A v219 database with the two parents the new table references and + /// the tables the beforeOpen backstops touch, and no + /// dive_center_gear_notes. + NativeDatabase setupDb({int userVersion = 219, bool withDiveCenters = true}) { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = $userVersion'); + rawDb.execute('CREATE TABLE divers (id TEXT PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dive_sites (id TEXT PRIMARY KEY)'); + rawDb.execute('CREATE TABLE dives (id TEXT PRIMARY KEY)'); + if (withDiveCenters) { + rawDb.execute('CREATE TABLE dive_centers (id TEXT PRIMARY KEY)'); + } + rawDb.execute('CREATE TABLE equipment (id TEXT NOT NULL PRIMARY KEY)'); + rawDb.execute(''' + CREATE TABLE tags ( + id TEXT NOT NULL PRIMARY KEY, + diver_id TEXT, + name TEXT NOT NULL, + color TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + hlc TEXT, + applies_to_dives INTEGER NOT NULL DEFAULT 1 + CHECK (applies_to_dives IN (0, 1)), + applies_to_sites INTEGER NOT NULL DEFAULT 0 + CHECK (applies_to_sites IN (0, 1)), + applies_to_equipment INTEGER NOT NULL DEFAULT 0 + CHECK (applies_to_equipment IN (0, 1)) + ) + '''); + }, + ); + } + + Future> columnsOf(AppDatabase db, String table) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return cols.map((c) => c.read('name')).toSet(); + } + + Future>> tableInfo( + AppDatabase db, + String table, + ) async { + final cols = await db.customSelect("PRAGMA table_info('$table')").get(); + return [ + for (final c in cols) + { + 'name': c.data['name'], + 'type': c.data['type'], + 'notnull': c.data['notnull'], + 'dflt_value': c.data['dflt_value'], + 'pk': c.data['pk'], + }, + ]; + } + + Future ddlOf(AppDatabase db, String type, String name) async { + final rows = await db + .customSelect( + 'SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', + variables: [Variable(type), Variable(name)], + ) + .get(); + return rows.isEmpty ? null : rows.single.read('sql'); + } + + test('v221 is the current schema version and is in the ladder', () { + // The newest rung owns the exact assertion; relax it to + // greaterThanOrEqualTo when the next one lands. + expect(AppDatabase.currentSchemaVersion, 221); + expect(AppDatabase.migrationVersions, contains(221)); + expect(AppDatabase.migrationStepCount(220), 1); + // Additive rung: the sync compatibility floor must not move. + expect(AppDatabase.minimumCompatibleSchemaVersion, 210); + }); + + test('adds the dive_center_gear_notes table', () async { + final db = AppDatabase(setupDb()); + addTearDown(db.close); + + expect( + await columnsOf(db, 'dive_center_gear_notes'), + containsAll([ + 'id', + 'dive_center_id', + 'gear_type', + 'label', + 'size', + 'verdict', + 'lead_adjustment_kg', + 'volume_liters', + 'note', + 'dive_id', + 'noted_at', + 'created_at', + 'updated_at', + 'hlc', + ]), + ); + }); + + test('the table cascades from its center and detaches from a dive', () async { + final db = AppDatabase(setupDb()); + addTearDown(db.close); + + final ddl = await ddlOf(db, 'table', 'dive_center_gear_notes'); + expect(ddl, contains('REFERENCES dive_centers (id) ON DELETE CASCADE')); + expect(ddl, contains('REFERENCES dives (id) ON DELETE SET NULL')); + }); + + test('a fresh database matches the upgraded one', () async { + final upgraded = AppDatabase(setupDb()); + addTearDown(upgraded.close); + final fresh = AppDatabase(NativeDatabase.memory()); + addTearDown(fresh.close); + + expect( + await tableInfo(upgraded, 'dive_center_gear_notes'), + await tableInfo(fresh, 'dive_center_gear_notes'), + ); + expect( + await ddlOf(upgraded, 'table', 'dive_center_gear_notes'), + await ddlOf(fresh, 'table', 'dive_center_gear_notes'), + ); + }); + + test( + 'a database stamped v221 without the table heals in beforeOpen', + () async { + // A parallel branch that claimed 221 first carries a device past the + // rung; the beforeOpen backstop must build what the rung would have. + final db = AppDatabase(setupDb(userVersion: 221)); + addTearDown(db.close); + + expect( + await columnsOf(db, 'dive_center_gear_notes'), + contains('dive_center_id'), + ); + }, + ); + + test('a fixture without dive_centers skips the table', () async { + final db = AppDatabase(setupDb(withDiveCenters: false)); + addTearDown(db.close); + + expect(await columnsOf(db, 'dive_center_gear_notes'), isEmpty); + }); +} diff --git a/test/core/services/sync/dive_center_gear_notes_sync_test.dart b/test/core/services/sync/dive_center_gear_notes_sync_test.dart new file mode 100644 index 0000000000..5f9d255317 --- /dev/null +++ b/test/core/services/sync/dive_center_gear_notes_sync_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/sync/sync_data_serializer.dart'; +import 'package:submersion/core/services/sync/sync_service.dart'; + +import '../../../helpers/test_database.dart'; + +/// diveCenterGearNotes is a parent-gated child of diveCenters (issue #2075): +/// own id, own hlc, exported with its center and on its own when pending, +/// applied after diveCenters and dives. +void main() { + late SyncDataSerializer serializer; + + setUp(() async { + await setUpTestDatabase(); + serializer = SyncDataSerializer(); + }); + tearDown(tearDownTestDatabase); + + Map noteJson(String id, {String? diveId}) => { + 'id': id, + 'diveCenterId': 'c1', + 'gearType': 'regulator', + 'label': '14', + 'size': null, + 'verdict': 'avoid', + 'leadAdjustmentKg': null, + 'volumeLiters': null, + 'note': 'wet', + 'diveId': diveId, + 'notedAt': 1000, + 'createdAt': 1000, + 'updatedAt': 1000, + 'hlc': null, + }; + + Future insertCenter(String id) => + serializer.upsertRecord('diveCenters', { + 'id': id, + 'name': id, + 'affiliations': '', + 'notes': '', + 'createdAt': 1000, + 'updatedAt': 1000, + }); + + test('is registered everywhere a parent-gated child must be', () { + expect(SyncRepository.hlcTargets['diveCenterGearNotes'], ( + table: 'dive_center_gear_notes', + pk: 'id', + )); + expect( + SyncDataSerializer.parentGatedChildEntities, + contains('diveCenterGearNotes'), + ); + expect( + SyncDataSerializer.parentGatedTables['diveCenterGearNotes'], + 'dive_center_gear_notes', + ); + expect(SyncService.entityHasUpdatedAt['diveCenterGearNotes'], isFalse); + expect(SyncService.parentRefs['diveCenterGearNotes'], [ + (field: 'diveCenterId', parent: 'diveCenters', nullable: false), + (field: 'diveId', parent: 'dives', nullable: true), + ]); + }); + + test('SyncData carries the entity right after diveCenters', () { + final keys = const SyncData().toJson().keys.toList(); + expect( + keys.indexOf('diveCenterGearNotes'), + keys.indexOf('diveCenters') + 1, + ); + expect( + SyncDataSerializer.debugBaseTableKeys, + contains('diveCenterGearNotes'), + ); + expect( + SyncData.fromJson({ + 'diveCenterGearNotes': [noteJson('n1')], + }).diveCenterGearNotes, + hasLength(1), + ); + }); + + test('round-trips through upsertRecord, fetchRecord, deleteRecord', () async { + await insertCenter('c1'); + await serializer.upsertRecord('diveCenterGearNotes', noteJson('n1')); + + final row = await serializer.fetchRecord('diveCenterGearNotes', 'n1'); + expect(row, isNotNull); + expect(row!['diveCenterId'], 'c1'); + expect(row['gearType'], 'regulator'); + expect(row['verdict'], 'avoid'); + expect(row['label'], '14'); + expect(row['note'], 'wet'); + + await serializer.deleteRecord('diveCenterGearNotes', 'n1'); + expect(await serializer.fetchRecord('diveCenterGearNotes', 'n1'), isNull); + }); + + test('round-trips through the batch paths and recordIdsFor', () async { + await insertCenter('c1'); + await serializer.upsertRecords('diveCenterGearNotes', [ + noteJson('n1'), + noteJson('n2'), + ]); + final fetched = await serializer.fetchRecords('diveCenterGearNotes', [ + 'n1', + 'n2', + ]); + expect(fetched.keys, containsAll(['n1', 'n2'])); + expect( + await serializer.recordIdsFor('diveCenterGearNotes'), + containsAll(['n1', 'n2']), + ); + }); + + test('a note whose center moved is exported by watermark', () async { + await insertCenter('c1'); + await serializer.upsertRecord('diveCenterGearNotes', noteJson('n1')); + // Stamp the center so its hlc is above the old watermark. + await SyncRepository().markRecordPending( + entityType: 'diveCenters', + recordId: 'c1', + localUpdatedAt: 2000, + ); + final since = await serializer.exportChangeset( + deviceId: 'dev', + hlcWatermark: '000000000000000:000000:0', + deletions: const [], + ); + expect(since.data.diveCenterGearNotes.map((r) => r['id']), contains('n1')); + final base = await serializer.exportChangeset( + deviceId: 'dev', + hlcWatermark: null, + deletions: const [], + ); + expect(base.data.diveCenterGearNotes.map((r) => r['id']), contains('n1')); + }); +} diff --git a/test/core/services/sync/sync_parent_refs_completeness_test.dart b/test/core/services/sync/sync_parent_refs_completeness_test.dart index b69436c51b..5ebb3d2f87 100644 --- a/test/core/services/sync/sync_parent_refs_completeness_test.dart +++ b/test/core/services/sync/sync_parent_refs_completeness_test.dart @@ -27,6 +27,7 @@ void main() { 'diver_settings': 'diverSettings', 'buddies': 'buddies', 'dive_centers': 'diveCenters', + 'dive_center_gear_notes': 'diveCenterGearNotes', 'trips': 'trips', 'liveaboard_detail_records': 'liveaboardDetails', 'trip_itinerary_days': 'itineraryDays', diff --git a/test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart b/test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart new file mode 100644 index 0000000000..9843d280ae --- /dev/null +++ b/test/features/dive_centers/data/repositories/dive_center_gear_note_repository_test.dart @@ -0,0 +1,206 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveCenterGearNoteRepository repository; + const stale = 1000; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveCenterGearNoteRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future seedCenter(String id) => db + .into(db.diveCenters) + .insert( + DiveCentersCompanion( + id: Value(id), + name: Value(id), + createdAt: const Value(stale), + updatedAt: const Value(stale), + ), + ); + + Future seedDive(String id, {String? centerId}) => db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: const Value(stale), + diveCenterId: Value(centerId), + createdAt: const Value(stale), + updatedAt: const Value(stale), + ), + ); + + DiveCenterGearNote note({ + String id = '', + String centerId = 'c1', + EquipmentType type = EquipmentType.regulator, + String? diveId, + DateTime? notedAt, + }) { + final now = DateTime.utc(2026, 9, 18, 10); + return DiveCenterGearNote( + id: id, + diveCenterId: centerId, + gearType: type, + label: '14', + verdict: RentalVerdict.avoid, + leadAdjustmentKg: 2, + note: 'wet', + diveId: diveId, + notedAt: notedAt ?? now, + createdAt: now, + updatedAt: now, + ); + } + + Future pendingCountFor(String recordId) async => + (await db + .customSelect( + "SELECT COUNT(*) AS n FROM sync_records WHERE entity_type = " + "'diveCenterGearNotes' AND record_id = ? " + "AND sync_status = 'pending'", + variables: [Variable(recordId)], + ) + .getSingle()) + .read('n'); + + Future tombstoneCountFor(String recordId) async => + (await db + .customSelect( + 'SELECT COUNT(*) AS n FROM deletion_log WHERE entity_type = ' + "'diveCenterGearNotes' AND record_id = ?", + variables: [Variable(recordId)], + ) + .getSingle()) + .read('n'); + + test('create assigns an id, stores every field and marks pending', () async { + await seedCenter('c1'); + await seedDive('d1', centerId: 'c1'); + + final created = await repository.create(note(diveId: 'd1')); + + expect(created.id, isNotEmpty); + final stored = await repository.getById(created.id); + expect(stored, isNotNull); + expect(stored!.diveCenterId, 'c1'); + expect(stored.gearType, EquipmentType.regulator); + expect(stored.label, '14'); + expect(stored.size, isNull); + expect(stored.verdict, RentalVerdict.avoid); + expect(stored.leadAdjustmentKg, 2); + expect(stored.volumeLiters, isNull); + expect(stored.note, 'wet'); + expect(stored.diveId, 'd1'); + expect(stored.notedAt, DateTime.utc(2026, 9, 18, 10)); + expect(await pendingCountFor(created.id), 1); + }); + + test('getForCenter lists newest noted first and only that center', () async { + await seedCenter('c1'); + await seedCenter('c2'); + final older = await repository.create( + note(notedAt: DateTime.utc(2026, 1, 1)), + ); + final newer = await repository.create( + note(notedAt: DateTime.utc(2026, 6, 1), type: EquipmentType.bcd), + ); + await repository.create(note(centerId: 'c2')); + + final notes = await repository.getForCenter('c1'); + expect(notes.map((n) => n.id), [newer.id, older.id]); + }); + + test('update rewrites the row and marks it pending again', () async { + await seedCenter('c1'); + final created = await repository.create(note()); + + await repository.update( + created.copyWith( + verdict: RentalVerdict.worked, + size: 'L', + clearLeadAdjustment: true, + volumeLiters: 11.1, + note: 'fine after service', + ), + ); + + final stored = await repository.getById(created.id); + expect(stored!.verdict, RentalVerdict.worked); + expect(stored.size, 'L'); + expect(stored.leadAdjustmentKg, isNull); + expect(stored.volumeLiters, 11.1); + expect(stored.note, 'fine after service'); + expect(stored.updatedAt.isBefore(created.updatedAt), isFalse); + expect(await pendingCountFor(created.id), 1); + }); + + test('delete removes the row and writes a tombstone', () async { + await seedCenter('c1'); + final created = await repository.create(note()); + + await repository.delete(created.id); + + expect(await repository.getById(created.id), isNull); + expect(await tombstoneCountFor(created.id), 1); + }); + + test('deleting the dive it was noted on keeps the note, detached', () async { + await seedCenter('c1'); + await seedDive('d1', centerId: 'c1'); + final created = await repository.create(note(diveId: 'd1')); + + await (db.delete(db.dives)..where((t) => t.id.equals('d1'))).go(); + + final stored = await repository.getById(created.id); + expect(stored, isNotNull); + expect(stored!.diveId, isNull); + }); + + test('deleting the center cascades its notes and tombstones each', () async { + await seedCenter('c1'); + final a = await repository.create(note()); + final b = await repository.create(note(type: EquipmentType.wetsuit)); + + await DiveCenterRepository().deleteDiveCenter('c1'); + + expect(await repository.getForCenter('c1'), isEmpty); + expect(await tombstoneCountFor(a.id), 1); + expect(await tombstoneCountFor(b.id), 1); + }); + + test('watchChanges emits after a write', () async { + await seedCenter('c1'); + final events = []; + final sub = repository.watchChanges().listen(events.add); + addTearDown(sub.cancel); + + await repository.create(note()); + await Future.delayed(Duration.zero); + + expect(events, isNotEmpty); + }); + + test('create for a center that does not exist throws', () async { + await expectLater( + repository.create(note(centerId: 'gone')), + throwsA(anything), + ); + expect(await repository.getForCenter('gone'), isEmpty); + }); +} diff --git a/test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart b/test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart new file mode 100644 index 0000000000..9a794284b1 --- /dev/null +++ b/test/features/dive_centers/data/repositories/dive_center_repository_latest_dive_test.dart @@ -0,0 +1,79 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; + +import '../../../../helpers/test_database.dart'; + +/// The "last time here" lookup (issue #2075): the newest real dive logged +/// with a center, skipping the dive being edited and planned dives. +void main() { + late AppDatabase db; + late DiveCenterRepository repository; + + setUp(() async { + db = await setUpTestDatabase(); + repository = DiveCenterRepository(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + Future seedCenter(String id) => db + .into(db.diveCenters) + .insert( + DiveCentersCompanion( + id: Value(id), + name: Value(id), + createdAt: const Value(0), + updatedAt: const Value(0), + ), + ); + + Future seedDive( + String id, { + required String centerId, + required int at, + bool planned = false, + }) => db + .into(db.dives) + .insert( + DivesCompanion( + id: Value(id), + diveDateTime: Value(at), + diveCenterId: Value(centerId), + isPlanned: Value(planned), + createdAt: const Value(0), + updatedAt: const Value(0), + ), + ); + + test('returns the newest dive at the center', () async { + await seedCenter('c1'); + await seedCenter('c2'); + await seedDive('old', centerId: 'c1', at: 1000); + await seedDive('new', centerId: 'c1', at: 3000); + await seedDive('other', centerId: 'c2', at: 9000); + + expect(await repository.latestDiveIdAtCenter('c1'), 'new'); + }); + + test('skips the dive being edited', () async { + await seedCenter('c1'); + await seedDive('old', centerId: 'c1', at: 1000); + await seedDive('new', centerId: 'c1', at: 3000); + + expect( + await repository.latestDiveIdAtCenter('c1', excludingDiveId: 'new'), + 'old', + ); + }); + + test('skips planned dives and returns null when nothing is left', () async { + await seedCenter('c1'); + await seedDive('plan', centerId: 'c1', at: 5000, planned: true); + + expect(await repository.latestDiveIdAtCenter('c1'), isNull); + }); +} diff --git a/test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart b/test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart new file mode 100644 index 0000000000..c4f71a79a0 --- /dev/null +++ b/test/features/dive_centers/domain/entities/dive_center_gear_note_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; + +void main() { + final now = DateTime.utc(2026, 9, 18, 10); + + DiveCenterGearNote note() => DiveCenterGearNote( + id: 'n1', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + size: null, + verdict: RentalVerdict.avoid, + leadAdjustmentKg: null, + volumeLiters: null, + note: 'Breathed wet below 20 m', + diveId: 'd1', + notedAt: now, + createdAt: now, + updatedAt: now, + ); + + test('copyWith replaces only the given fields', () { + final changed = note().copyWith( + verdict: RentalVerdict.worked, + leadAdjustmentKg: 2.0, + ); + expect(changed.verdict, RentalVerdict.worked); + expect(changed.leadAdjustmentKg, 2.0); + expect(changed.label, '14'); + expect(changed.diveId, 'd1'); + expect(changed.note, 'Breathed wet below 20 m'); + }); + + test('copyWith clear flags null a nullable field', () { + final cleared = note().copyWith(clearLabel: true, clearDiveId: true); + expect(cleared.label, isNull); + expect(cleared.diveId, isNull); + // The untouched nullable stays. + expect(cleared.copyWith(size: 'L').size, 'L'); + }); + + test('verdictFromName falls back to worked for unknown text', () { + expect(DiveCenterGearNote.verdictFromName('avoid'), RentalVerdict.avoid); + expect(DiveCenterGearNote.verdictFromName('worked'), RentalVerdict.worked); + expect(DiveCenterGearNote.verdictFromName('later'), RentalVerdict.worked); + expect(DiveCenterGearNote.verdictFromName(null), RentalVerdict.worked); + }); + + test('gearTypeFromName falls back to other for a newer peer type', () { + expect(DiveCenterGearNote.gearTypeFromName('bcd'), EquipmentType.bcd); + expect( + DiveCenterGearNote.gearTypeFromName('hoverboard'), + EquipmentType.other, + ); + expect(DiveCenterGearNote.gearTypeFromName(null), EquipmentType.other); + }); + + test('equality is by value', () { + expect(note(), note()); + expect(note() == note().copyWith(note: 'x'), isFalse); + }); +} diff --git a/test/features/dive_centers/domain/services/rental_memory_resolver_test.dart b/test/features/dive_centers/domain/services/rental_memory_resolver_test.dart new file mode 100644 index 0000000000..678f767282 --- /dev/null +++ b/test/features/dive_centers/domain/services/rental_memory_resolver_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; + +void main() { + final when = DateTime(2026, 3, 12, 9, 30); + + Dive dive({ + List weights = const [], + List tanks = const [], + WeightingFeedback? feedback, + double? feedbackKg, + }) => Dive( + id: 'd1', + dateTime: when, + weights: weights, + tanks: tanks, + weightingFeedback: feedback, + weightingFeedbackKg: feedbackKg, + ); + + const belt = DiveWeight( + id: 'w1', + diveId: 'd1', + weightType: WeightType.belt, + amountKg: 6, + notes: 'their belt', + ); + const trim = DiveWeight( + id: 'w2', + diveId: 'd1', + weightType: WeightType.trimWeights, + amountKg: 2, + ); + const al80 = DiveTank( + id: 't1', + name: 'Their AL80', + volume: 11.1, + workingPressure: 207, + startPressure: 200, + endPressure: 60, + gasMix: GasMix(o2: 32), + role: TankRole.backGas, + material: TankMaterial.aluminum, + order: 0, + presetName: 'al80', + computerId: 'comp', + transmitterSerial: 'TX1', + sourceTankIndex: 0, + regulatorEquipmentId: 'reg', + equipmentId: 'tank', + ); + + test('fromDive carries date, weights, feedback and tanks', () { + final last = LastDiveAtCenter.fromDive( + dive( + weights: const [belt, trim], + tanks: const [al80], + feedback: WeightingFeedback.overweighted, + feedbackKg: 1, + ), + ); + expect(last.diveId, 'd1'); + expect(last.dateTime, when); + expect(last.weights, const [belt, trim]); + expect(last.tanks, const [al80]); + expect(last.weightingFeedback, WeightingFeedback.overweighted); + expect(last.weightingFeedbackKg, 1); + expect(last.totalLeadKg, 8); + }); + + test('a dive with no weights or tanks resolves with empty lists', () { + final last = LastDiveAtCenter.fromDive(dive()); + expect(last.weights, isEmpty); + expect(last.tanks, isEmpty); + expect(last.totalLeadKg, 0); + expect(last.weightingFeedback, isNull); + }); + + test('weightsForNewDive copies type, amount and notes under fresh ids', () { + final last = LastDiveAtCenter.fromDive(dive(weights: const [belt, trim])); + var n = 0; + final copies = last.weightsForNewDive( + diveId: 'new', + newId: () => 'id${n++}', + ); + expect(copies.map((w) => w.id), ['id0', 'id1']); + expect(copies.map((w) => w.diveId), ['new', 'new']); + expect(copies.first.weightType, WeightType.belt); + expect(copies.first.amountKg, 6); + expect(copies.first.notes, 'their belt'); + expect(copies.last.weightType, WeightType.trimWeights); + // The source list is untouched. + expect(last.weights.first.id, 'w1'); + }); + + test('tanksForNewDive keeps the rig and takes default pressures', () { + final last = LastDiveAtCenter.fromDive(dive(tanks: const [al80])); + final copies = last.tanksForNewDive( + newId: () => 'fresh', + startPressure: 210, + endPressure: 50, + ); + final copy = copies.single; + expect(copy.id, 'fresh'); + expect(copy.name, 'Their AL80'); + expect(copy.volume, 11.1); + expect(copy.workingPressure, 207); + expect(copy.presetName, 'al80'); + expect(copy.material, TankMaterial.aluminum); + expect(copy.gasMix, const GasMix(o2: 32)); + expect(copy.role, TankRole.backGas); + expect(copy.order, 0); + expect(copy.startPressure, 210); + expect(copy.endPressure, 50); + // Device, transmitter and owned-gear links belong to the old dive. + expect(copy.computerId, isNull); + expect(copy.transmitterSerial, isNull); + expect(copy.sourceTankIndex, isNull); + expect(copy.regulatorEquipmentId, isNull); + expect(copy.equipmentId, isNull); + }); + + test('two reads of the same dive are equal', () { + expect( + LastDiveAtCenter.fromDive(dive(weights: const [belt])), + LastDiveAtCenter.fromDive(dive(weights: const [belt])), + ); + expect( + LastDiveAtCenter.fromDive(dive(weights: const [belt])) == + LastDiveAtCenter.fromDive(dive(weights: const [trim])), + isFalse, + ); + }); +} diff --git a/test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart b/test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart new file mode 100644 index 0000000000..34b770af8e --- /dev/null +++ b/test/features/dive_centers/presentation/widgets/rental_gear_note_sheet_test.dart @@ -0,0 +1,294 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_gear_note_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_note_sheet.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +void main() { + late AppDatabase db; + late DiveCenterGearNoteRepository repo; + + setUp(() async { + db = await setUpTestDatabase(); + repo = DiveCenterGearNoteRepository(); + await db + .into(db.diveCenters) + .insert( + const DiveCentersCompanion( + id: Value('c1'), + name: Value('Reef Divers'), + createdAt: Value(0), + updatedAt: Value(0), + ), + ); + // The dive the sheet is opened from; the note's dive link references it. + await db + .into(db.dives) + .insert( + const DivesCompanion( + id: Value('d1'), + diveDateTime: Value(0), + diveCenterId: Value('c1'), + createdAt: Value(0), + updatedAt: Value(0), + ), + ); + }); + tearDown(tearDownTestDatabase); + + Future pumpAndOpen( + WidgetTester tester, { + DiveCenterGearNote? editing, + MockSettingsNotifier? settings, + }) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith( + (ref) => settings ?? MockSettingsNotifier(), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showRentalGearNoteSheet( + context, + diveCenterId: 'c1', + diveId: 'd1', + editing: editing, + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Finder fieldLabelled(String label) => + find.ancestor(of: find.text(label), matching: find.byType(TextField)); + + /// Opens the gear type menu and picks [label]. The menu opens scrolled to + /// the selected type, so an item near the top must be scrolled into view + /// before it exists in the tree. + Future pickGearType(WidgetTester tester, String label) async { + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + // With the menu open the closed field still reads the old type, so the + // label exists once, in the menu. + await tester.scrollUntilVisible( + find.text(label), + -100, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + } + + testWidgets('creates a note with the typed fields converted to metric', ( + tester, + ) async { + final imperial = MockSettingsNotifier(); + await imperial.setImperial(); + await pumpAndOpen(tester, settings: imperial); + expect(find.text('New rental note'), findsOneWidget); + // The volume field only shows for tanks. + expect(find.text('Actual capacity (cuft)'), findsNothing); + + await pickGearType(tester, 'BCD'); + await tester.enterText(fieldLabelled('Label or number'), 'blue 3'); + await tester.enterText(fieldLabelled('Size'), 'L'); + await tester.tap(find.text('Avoid')); + await tester.enterText(fieldLabelled('Extra lead needed (lbs)'), '4.4'); + await tester.enterText(fieldLabelled('Note'), 'inflator sticks'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.gearType, EquipmentType.bcd); + expect(stored.label, 'blue 3'); + expect(stored.size, 'L'); + expect(stored.verdict, RentalVerdict.avoid); + expect(stored.leadAdjustmentKg, closeTo(2.0, 0.01)); + expect(stored.volumeLiters, isNull); + expect(stored.note, 'inflator sticks'); + expect(stored.diveId, 'd1'); + // The sheet closed. + expect(find.text('New rental note'), findsNothing); + }); + + testWidgets('a tank note shows and stores the actual capacity', ( + tester, + ) async { + await pumpAndOpen(tester); + await pickGearType(tester, 'Tank'); + expect(find.text('Actual capacity (L)'), findsOneWidget); + await tester.enterText(fieldLabelled('Label or number'), 'AL80'); + await tester.enterText(fieldLabelled('Actual capacity (L)'), '11.1'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.gearType, EquipmentType.tank); + expect(stored.volumeLiters, 11.1); + expect(stored.verdict, RentalVerdict.worked); + }); + + testWidgets('editing seeds the fields and saves over the row', ( + tester, + ) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + verdict: RentalVerdict.avoid, + leadAdjustmentKg: 1.5, + note: 'wet', + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + expect(find.text('Edit rental note'), findsOneWidget); + expect(find.text('14'), findsOneWidget); + expect(find.text('1.5'), findsOneWidget); + expect(find.text('wet'), findsOneWidget); + + await tester.enterText(fieldLabelled('Note'), 'fine after service'); + await tester.tap(find.text('Worked')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final stored = (await repo.getForCenter('c1')).single; + expect(stored.id, existing.id); + expect(stored.verdict, RentalVerdict.worked); + expect(stored.note, 'fine after service'); + expect(stored.leadAdjustmentKg, 1.5); + }); + + testWidgets('delete asks first, then removes the note', (tester) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.wetsuit, + size: 'L', + verdict: RentalVerdict.avoid, + note: 'runs small', + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + await tester.tap(find.widgetWithText(TextButton, 'Delete')); + await tester.pumpAndSettle(); + expect(find.text('Delete this rental note?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(await repo.getForCenter('c1'), isEmpty); + expect(find.text('Edit rental note'), findsNothing); + }); + + testWidgets('unreadable lead is refused and nothing is saved', ( + tester, + ) async { + await pumpAndOpen(tester); + await tester.enterText(fieldLabelled('Extra lead needed (kg)'), 'two'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Enter a valid number'), findsOneWidget); + expect(await repo.getForCenter('c1'), isEmpty); + expect(find.text('New rental note'), findsOneWidget); + }); + + testWidgets('a failed save keeps the editor open with an error', ( + tester, + ) async { + await pumpAndOpen(tester); + // The center disappears under the open sheet (a sync delete), so the + // insert fails its foreign key. + await (db.delete(db.dives)..where((t) => t.id.equals('d1'))).go(); + await (db.delete(db.diveCenters)..where((t) => t.id.equals('c1'))).go(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + find.text('Something went wrong. Please try again.'), + findsOneWidget, + ); + expect(find.text('New rental note'), findsOneWidget); + }); + + testWidgets('cancel closes without saving', (tester) async { + await pumpAndOpen(tester); + await tester.enterText(fieldLabelled('Note'), 'never saved'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('New rental note'), findsNothing); + expect(await repo.getForCenter('c1'), isEmpty); + }); + + testWidgets('editing a tank note seeds its capacity', (tester) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.tank, + label: 'AL80', + volumeLiters: 11.1, + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + expect(find.widgetWithText(TextField, '11.1'), findsOneWidget); + }); + + testWidgets('declining the delete keeps the note', (tester) async { + final existing = await repo.create( + DiveCenterGearNote( + id: '', + diveCenterId: 'c1', + gearType: EquipmentType.fins, + notedAt: DateTime.utc(2026, 9, 1), + createdAt: DateTime.utc(2026, 9, 1), + updatedAt: DateTime.utc(2026, 9, 1), + ), + ); + await pumpAndOpen(tester, editing: existing); + await tester.tap(find.widgetWithText(TextButton, 'Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Cancel').last); + await tester.pumpAndSettle(); + + expect(await repo.getForCenter('c1'), hasLength(1)); + expect(find.text('Edit rental note'), findsOneWidget); + }); +} diff --git a/test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart b/test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart new file mode 100644 index 0000000000..8b7ecd3e6e --- /dev/null +++ b/test/features/dive_centers/presentation/widgets/rental_gear_section_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_gear_section.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + DiveCenterGearNote note(String id, EquipmentType type, {String? label}) => + DiveCenterGearNote( + id: id, + diveCenterId: 'c1', + gearType: type, + label: label, + verdict: RentalVerdict.worked, + notedAt: DateTime(2026, 1, 1), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Future pump(WidgetTester tester, List notes) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + diveCenterGearNotesProvider('c1').overrideWith((ref) async => notes), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: RentalGearSection(centerId: 'c1'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('groups notes by gear type in the canonical order', ( + tester, + ) async { + await pump(tester, [ + note('n1', EquipmentType.fins, label: 'fins'), + note('n2', EquipmentType.regulator, label: 'reg 14'), + note('n3', EquipmentType.regulator, label: 'reg 9'), + ]); + expect(find.text('Rental gear'), findsOneWidget); + final reg14 = tester.getTopLeft(find.text('reg 14')); + final reg9 = tester.getTopLeft(find.text('reg 9')); + final fins = tester.getTopLeft(find.text('fins')); + // Regulators come before fins in the canonical order. + expect(reg14.dy, lessThan(fins.dy)); + expect(reg9.dy, lessThan(fins.dy)); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('with no notes shows the empty line and the add button', ( + tester, + ) async { + await pump(tester, const []); + expect(find.text('Rental gear'), findsOneWidget); + expect(find.text('No rental notes for this center yet.'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('the add button opens the sheet', (tester) async { + await pump(tester, const []); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + expect(find.text('New rental note'), findsOneWidget); + }); + + testWidgets('a tapped note opens its editor', (tester) async { + await pump(tester, [note('n1', EquipmentType.fins, label: 'fins')]); + await tester.tap(find.text('fins')); + await tester.pumpAndSettle(); + expect(find.text('Edit rental note'), findsOneWidget); + }); +} diff --git a/test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart b/test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart new file mode 100644 index 0000000000..a428627980 --- /dev/null +++ b/test/features/dive_centers/presentation/widgets/rental_memory_card_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center_gear_note.dart'; +import 'package:submersion/features/dive_centers/domain/services/rental_memory_resolver.dart'; +import 'package:submersion/features/dive_centers/presentation/providers/dive_center_gear_note_providers.dart'; +import 'package:submersion/features/dive_centers/presentation/widgets/rental_memory_card.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; + +void main() { + final center = DiveCenter( + id: 'c1', + name: 'Reef Divers', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + + final last = LastDiveAtCenter( + diveId: 'd0', + dateTime: DateTime(2026, 3, 12), + weights: const [ + DiveWeight( + id: 'w1', + diveId: 'd0', + weightType: WeightType.belt, + amountKg: 6, + ), + DiveWeight( + id: 'w2', + diveId: 'd0', + weightType: WeightType.trimWeights, + amountKg: 2, + ), + ], + weightingFeedback: WeightingFeedback.overweighted, + weightingFeedbackKg: 1, + tanks: const [ + DiveTank( + id: 't1', + volume: 11.1, + workingPressure: 207, + presetName: 'al80', + ), + ], + ); + + final notes = [ + DiveCenterGearNote( + id: 'n1', + diveCenterId: 'c1', + gearType: EquipmentType.regulator, + label: '14', + verdict: RentalVerdict.avoid, + note: 'breathes wet', + notedAt: DateTime(2026, 3, 12), + createdAt: DateTime(2026, 3, 12), + updatedAt: DateTime(2026, 3, 12), + ), + DiveCenterGearNote( + id: 'n2', + diveCenterId: 'c1', + gearType: EquipmentType.bcd, + size: 'L', + verdict: RentalVerdict.worked, + leadAdjustmentKg: 2, + notedAt: DateTime(2026, 1, 1), + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ), + ]; + + Future pump( + WidgetTester tester, { + required LastDiveAtCenter? lastDive, + required List withNotes, + void Function(LastDiveAtCenter)? onApply, + }) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith((ref) => MockSettingsNotifier()), + diveCenterGearNotesProvider( + 'c1', + ).overrideWith((ref) async => withNotes), + lastDiveAtCenterProvider(( + centerId: 'c1', + excludingDiveId: 'd1', + )).overrideWith((ref) async => lastDive), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: RentalMemoryCard( + center: center, + currentDiveId: 'd1', + onApplyLastDive: onApply ?? (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('shows the last dive facts and the notes', (tester) async { + await pump(tester, lastDive: last, withNotes: notes); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.textContaining('Last dive here:'), findsOneWidget); + expect(find.text('Lead: 8.0 kg'), findsOneWidget); + expect(find.text('1.0 kg over'), findsOneWidget); + expect(find.textContaining('11 L'), findsOneWidget); + expect(find.text('Apply last dive'), findsOneWidget); + // Notes, newest first, with their verdicts and numbers. + expect(find.text('14'), findsOneWidget); + expect(find.text('breathes wet'), findsOneWidget); + expect(find.text('Avoid'), findsOneWidget); + expect(find.text('L'), findsOneWidget); + expect(find.text('Worked'), findsOneWidget); + expect(find.text('2.0 kg extra lead'), findsOneWidget); + expect(find.text('Add rental note'), findsOneWidget); + }); + + testWidgets('apply hands the last dive to the page', (tester) async { + LastDiveAtCenter? applied; + await pump( + tester, + lastDive: last, + withNotes: const [], + onApply: (l) => applied = l, + ); + await tester.tap(find.text('Apply last dive')); + await tester.pump(); + expect(applied, same(last)); + }); + + testWidgets('with no history and no notes only the add button shows', ( + tester, + ) async { + await pump(tester, lastDive: null, withNotes: const []); + expect(find.text('Add rental note'), findsOneWidget); + expect(find.text('Apply last dive'), findsNothing); + expect(find.text('Last time at Reef Divers'), findsNothing); + expect(find.textContaining('Lead:'), findsNothing); + }); + + testWidgets('notes without history still show under the header', ( + tester, + ) async { + await pump(tester, lastDive: null, withNotes: notes); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.text('No other dives logged here yet.'), findsOneWidget); + expect(find.text('breathes wet'), findsOneWidget); + expect(find.text('Apply last dive'), findsNothing); + }); + + testWidgets('shows under, bare over, less lead and actual capacity', ( + tester, + ) async { + final under = LastDiveAtCenter( + diveId: 'd0', + dateTime: DateTime(2026, 3, 12), + weights: last.weights, + weightingFeedback: WeightingFeedback.underweighted, + weightingFeedbackKg: 1.5, + tanks: const [], + ); + final tankNote = DiveCenterGearNote( + id: 'n3', + diveCenterId: 'c1', + gearType: EquipmentType.tank, + label: 'AL80', + verdict: RentalVerdict.worked, + leadAdjustmentKg: -1, + volumeLiters: 11.1, + notedAt: DateTime(2026, 3, 12), + createdAt: DateTime(2026, 3, 12), + updatedAt: DateTime(2026, 3, 12), + ); + await pump(tester, lastDive: under, withNotes: [tankNote]); + expect(find.text('1.5 kg under'), findsOneWidget); + expect( + find.text('1.0 kg less lead\nActual capacity 11.1 L'), + findsOneWidget, + ); + }); + + testWidgets('feedback without an amount uses the plain label', ( + tester, + ) async { + final bareOver = LastDiveAtCenter( + diveId: 'd0', + dateTime: DateTime(2026, 3, 12), + weights: last.weights, + weightingFeedback: WeightingFeedback.overweighted, + weightingFeedbackKg: null, + tanks: const [], + ); + await pump(tester, lastDive: bareOver, withNotes: const []); + expect(find.text('Overweighted'), findsOneWidget); + }); + + testWidgets('add opens a new note and a tapped note opens its editor', ( + tester, + ) async { + await pump(tester, lastDive: last, withNotes: notes); + await tester.tap(find.text('Add rental note')); + await tester.pumpAndSettle(); + expect(find.text('New rental note'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('breathes wet')); + await tester.pumpAndSettle(); + expect(find.text('Edit rental note'), findsOneWidget); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_edit_apply_last_dive_confirm_test.dart b/test/features/dive_log/presentation/pages/dive_edit_apply_last_dive_confirm_test.dart new file mode 100644 index 0000000000..f7e4c86db7 --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_edit_apply_last_dive_confirm_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_edit_page.dart'; + +/// Issue #2075: "Apply last dive" replaces the form's weights and tanks, so +/// it asks first whenever the form holds any, however empty they look. +void main() { + const blankWeight = DiveWeight( + id: 'w', + diveId: 'd', + weightType: WeightType.trimWeights, + amountKg: 0, + notes: 'left pocket', + ); + const tank = DiveTank(id: 't'); + + test('an empty form replaces without asking', () { + expect( + applyLastDiveNeedsConfirm(weights: const [], tanks: const []), + isFalse, + ); + }); + + test('a weight row with no amount yet still asks', () { + expect( + applyLastDiveNeedsConfirm(weights: const [blankWeight], tanks: const []), + isTrue, + ); + }); + + test('a tank alone asks', () { + expect( + applyLastDiveNeedsConfirm(weights: const [], tanks: const [tank]), + isTrue, + ); + }); +} diff --git a/test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart b/test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart new file mode 100644 index 0000000000..35bc193d4b --- /dev/null +++ b/test/features/dive_log/presentation/pages/dive_edit_rental_memory_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_centers/domain/entities/dive_center.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive_weight.dart'; +import 'package:submersion/features/dive_log/presentation/pages/dive_edit_page.dart'; +import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart'; +import 'package:submersion/features/divers/presentation/providers/diver_providers.dart'; +import 'package:submersion/features/tank_presets/presentation/providers/tank_preset_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +import '../../../../helpers/mock_providers.dart'; +import '../../../../helpers/test_database.dart'; + +/// Issue #2075: applying the last dive at a center copies its weights and +/// tanks into the dive being edited. +void main() { + late DiveRepository dives; + late DiveCenter center; + late String diverId; + + setUp(() async { + await setUpTestDatabase(); + dives = DiveRepository(); + final now = DateTime.now(); + diverId = (await DiverRepository().createDiver( + Diver(id: '', name: 'Tester', createdAt: now, updatedAt: now), + )).id; + center = await DiveCenterRepository().createDiveCenter( + DiveCenter( + id: '', + diverId: diverId, + name: 'Reef Divers', + createdAt: now, + updatedAt: now, + ), + ); + // The earlier dive at the center: 6 kg belt, one AL80. + await dives.createDive( + Dive( + id: 'earlier', + diverId: diverId, + dateTime: DateTime(2026, 3, 12, 9), + diveCenter: center, + weights: const [ + DiveWeight( + id: 'w-earlier', + diveId: 'earlier', + weightType: WeightType.belt, + amountKg: 6, + ), + ], + tanks: const [ + DiveTank( + id: 't-earlier', + volume: 11.1, + workingPressure: 207, + startPressure: 180, + endPressure: 60, + presetName: 'al80', + ), + ], + ), + ); + // The dive being edited: same center, no weights, no tanks. + await dives.createDive( + Dive( + id: 'current', + diverId: diverId, + dateTime: DateTime(2026, 9, 18, 9), + diveCenter: center, + ), + ); + }); + + tearDown(() async => tearDownTestDatabase()); + + Future pumpEditor(WidgetTester tester, String diveId) async { + tester.view.physicalSize = const Size(1000, 6000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final base = await getBaseOverrides(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...base.cast(), + diveRepositoryProvider.overrideWithValue(dives), + diveListNotifierProvider.overrideWith( + (ref) => DiveListNotifier(dives, ref), + ), + customTankPresetsProvider.overrideWith((ref) async => []), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: DiveEditPage(diveId: diveId, embedded: true)), + ), + ), + ); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(DiveEditPage)), + ); + await container + .read(currentDiverIdProvider.notifier) + .setCurrentDiver(diverId); + await tester.pumpAndSettle(); + } + + /// The trip group is collapsed by default and mounts its rows only when + /// open; the header row is the toggle. + Future expandTrip(WidgetTester tester) async { + final header = find.text('Trip'); + await tester.scrollUntilVisible( + header, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(header); + await tester.pumpAndSettle(); + } + + Future save(WidgetTester tester) async { + await tester.ensureVisible(find.text('Save')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + } + + testWidgets('apply copies the earlier weights and tanks into the form', ( + tester, + ) async { + await pumpEditor(tester, 'current'); + await expandTrip(tester); + expect(find.text('Last time at Reef Divers'), findsOneWidget); + expect(find.text('Lead: 6.0 kg'), findsOneWidget); + + await tester.ensureVisible(find.text('Apply last dive')); + await tester.tap(find.text('Apply last dive')); + await tester.pumpAndSettle(); + // The editor seeds a default tank row into a tankless dive, so the form + // already holds a tank and the replace is confirmed first. + expect(find.text('Replace weights and tanks?'), findsOneWidget); + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + expect(find.textContaining('Weights and tanks copied'), findsOneWidget); + + await save(tester); + + final saved = (await dives.getDiveById('current'))!; + expect(saved.weights.single.weightType, WeightType.belt); + expect(saved.weights.single.amountKg, 6); + expect(saved.weights.single.id, isNot('w-earlier')); + expect(saved.tanks.single.volume, 11.1); + expect(saved.tanks.single.presetName, 'al80'); + expect(saved.tanks.single.id, isNot('t-earlier')); + // The pressures are the defaults for a new tank (200 bar start, 50 bar + // end), not the old dive's 180 and 60. + expect(saved.tanks.single.startPressure, 200); + expect(saved.tanks.single.endPressure, 50); + }); + + testWidgets('apply on a form that already has weights replaces them', ( + tester, + ) async { + await dives.updateDive( + (await dives.getDiveById('current'))!.copyWith( + weights: const [ + DiveWeight( + id: 'w-current', + diveId: 'current', + weightType: WeightType.integrated, + amountKg: 4, + ), + ], + ), + ); + await pumpEditor(tester, 'current'); + await expandTrip(tester); + + await tester.ensureVisible(find.text('Apply last dive')); + await tester.tap(find.text('Apply last dive')); + await tester.pumpAndSettle(); + expect(find.text('Replace weights and tanks?'), findsOneWidget); + await tester.tap(find.text('Replace')); + await tester.pumpAndSettle(); + + await save(tester); + final saved = (await dives.getDiveById('current'))!; + expect(saved.weights.single.weightType, WeightType.belt); + expect(saved.weights.single.amountKg, 6); + }); +} diff --git a/test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart b/test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart index 660f31879b..1ff2080362 100644 --- a/test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart +++ b/test/features/dive_log/presentation/widgets/edit_sections/trip_section_test.dart @@ -69,4 +69,36 @@ void main() { ); expect(find.text('Add trip or dive center'), findsOneWidget); }); + + testWidgets('the center child renders under the dive center row', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: TripSection( + expanded: true, + onToggle: () {}, + summary: 'Reef Divers', + isEmpty: false, + tripName: null, + onPickTrip: () {}, + onClearTrip: () {}, + diveCenterName: 'Reef Divers', + onPickDiveCenter: () {}, + onClearDiveCenter: () {}, + centerChild: const Text('CENTER_CHILD'), + ), + ), + ), + ), + ); + expect(find.text('CENTER_CHILD'), findsOneWidget); + final child = tester.getTopLeft(find.text('CENTER_CHILD')); + final row = tester.getTopLeft(find.text('Reef Divers').last); + expect(child.dy, greaterThan(row.dy)); + }); } diff --git a/test/l10n/rental_gear_memory_strings_test.dart b/test/l10n/rental_gear_memory_strings_test.dart new file mode 100644 index 0000000000..687a1110ff --- /dev/null +++ b/test/l10n/rental_gear_memory_strings_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Every rental gear memory string (issue #2075) exists in every locale, so +/// gen-l10n never falls back to English for one of them. +void main() { + const keys = [ + 'diveCenters_rental_sectionTitle', + 'diveCenters_rental_lastTimeAt', + 'diveCenters_rental_lastDiveOn', + 'diveCenters_rental_noHistory', + 'diveCenters_rental_empty', + 'diveCenters_rental_addNote', + 'diveCenters_rental_applyLastDive', + 'diveCenters_rental_applied', + 'diveCenters_rental_applyConfirmTitle', + 'diveCenters_rental_applyConfirmBody', + 'diveCenters_rental_applyConfirmReplace', + 'diveCenters_rental_leadTotal', + 'diveCenters_rental_feedbackOver', + 'diveCenters_rental_feedbackUnder', + 'diveCenters_rental_tanksLabel', + 'diveCenters_rental_verdictWorked', + 'diveCenters_rental_verdictAvoid', + 'diveCenters_rental_extraLead', + 'diveCenters_rental_lessLead', + 'diveCenters_rental_actualCapacity', + 'diveCenters_rental_sheetTitleNew', + 'diveCenters_rental_sheetTitleEdit', + 'diveCenters_rental_gearTypeLabel', + 'diveCenters_rental_labelLabel', + 'diveCenters_rental_sizeLabel', + 'diveCenters_rental_leadAdjustmentLabel', + 'diveCenters_rental_volumeLabel', + 'diveCenters_rental_noteLabel', + 'diveCenters_rental_deleteConfirm', + ]; + const locales = [ + 'ar', + 'de', + 'en', + 'es', + 'fr', + 'he', + 'hu', + 'it', + 'nl', + 'pt', + 'zh', + ]; + + for (final locale in locales) { + test('app_$locale.arb carries every rental gear memory key', () { + final file = File('lib/l10n/arb/app_$locale.arb'); + final json = jsonDecode(file.readAsStringSync()) as Map; + final missing = [ + for (final key in keys) + if (json[key] is! String || (json[key] as String).isEmpty) key, + ]; + expect(missing, isEmpty, reason: 'missing in $locale'); + }); + } + + test('the English placeholders are declared', () { + final json = + jsonDecode(File('lib/l10n/arb/app_en.arb').readAsStringSync()) + as Map; + for (final key in const [ + 'diveCenters_rental_lastTimeAt', + 'diveCenters_rental_lastDiveOn', + 'diveCenters_rental_leadTotal', + 'diveCenters_rental_feedbackOver', + 'diveCenters_rental_feedbackUnder', + 'diveCenters_rental_extraLead', + 'diveCenters_rental_lessLead', + 'diveCenters_rental_actualCapacity', + 'diveCenters_rental_leadAdjustmentLabel', + 'diveCenters_rental_volumeLabel', + ]) { + expect(json['@$key'], isA>(), reason: key); + expect( + (json['@$key'] as Map)['placeholders'], + isNotNull, + reason: key, + ); + } + }); +}