From 608b6731129a973da1c146be22faf21094b1cda7 Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:14:05 +0200 Subject: [PATCH 01/11] docs: spec for mobile set-home flow --- .../2026-04-26-mobile-set-home-design.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-mobile-set-home-design.md diff --git a/docs/superpowers/specs/2026-04-26-mobile-set-home-design.md b/docs/superpowers/specs/2026-04-26-mobile-set-home-design.md new file mode 100644 index 0000000..bab8323 --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-mobile-set-home-design.md @@ -0,0 +1,108 @@ +# Mobile: set home location + +## Goal + +Let mobile users set / change their home location. Backend (`PUT /api/locations/home`), API client (`LocationsApi.setHome`), and provider (`homeLocationProvider`) already exist. Mobile reads home in several places but exposes no setter UI. + +## Non-goals + +- Map long-press to set home +- "Use current location" shortcut +- Editable label after geocode +- Multiple saved places (work, etc.) +- Backend changes + +## Entry points + +Three triggers, all push the same picker: + +1. **Settings screen** ([mobile/lib/screens/settings_screen.dart](../../../mobile/lib/screens/settings_screen.dart)) + - No home set: list row labelled `settingsSetHome` ("Set home") + - Home set: existing home display becomes tappable as a whole row, with `settingsChangeHome` ("Change home") shown as a trailing label or icon-button hint + - Both push `SearchScreen(mode: SearchMode.pickHome)` + +2. **Home sheet — empty state** ([mobile/lib/widgets/home_sheet.dart](../../../mobile/lib/widgets/home_sheet.dart)) + - When `auth.isLoggedIn && homeLocation == null`: render a single primary CTA ("Set home", `homeSheetSetHome`) in place of the existing `Go Home` button + - Tap pushes `SearchScreen(mode: SearchMode.pickHome)` + - Login/register cards still shown when logged out (existing behaviour preserved) + +3. **Home sheet — long-press existing Go Home button** + - When home is set: long-press `_GoHomeButton` triggers `HapticFeedback.mediumImpact()` and pushes the same picker + - Short-press behaviour (start nav home) unchanged + +## Picker: SearchScreen pickHome mode + +Add `enum SearchMode { route, pickHome }` to [mobile/lib/screens/search_screen.dart](../../../mobile/lib/screens/search_screen.dart). Default `route` keeps current behaviour. + +In `pickHome` mode: +- App-bar title uses `searchPickHomeTitle` ("Pick home location") +- "Saved home" / recent / current-location shortcuts that exist for routing pickup are **not** rendered +- Search field + geocode results behave identically to route mode +- Tapping a result: + 1. Calls `await ref.read(homeLocationProvider.notifier).set(location)` + 2. On success: `Navigator.of(context).pop()`, then `ScaffoldMessenger.of(context).showSnackBar(...)` with `homeSetSuccess` ("Home set to {label}") + 3. On failure: stay on screen, snackbar with `homeSetError` ("Couldn't set home"). No state mutation on failure (provider's `set` already guards via `AsyncValue.guard`) + +The snackbar must fire on the screen the user *returns to*, not on `SearchScreen` (which has popped). Capture `final messenger = ScaffoldMessenger.of(context)` in the trigger callback **before** the `Navigator.push`, then call `messenger.showSnackBar(...)` after the push resolves with the saved `Location`. Picker returns the saved `Location?` from `Navigator.pop(context, savedLocation)` so the caller knows whether to show success or stay silent. + +## Provider + +`HomeLocationController.set(Location)` already exists at [mobile/lib/providers/location_provider.dart:25](../../../mobile/lib/providers/location_provider.dart). No changes. + +The `Location` passed in: built from the tapped `GeocodeResult` — `Location(label: result.label, lng: result.lng, lat: result.lat)`. Existing route mode already does this conversion; reuse the same helper if one exists, otherwise inline. + +## Localization + +Add to `mobile/lib/l10n/app_en.arb` and `mobile/lib/l10n/app_de.arb`: + +| Key | en | de | +|---|---|---| +| `settingsSetHome` | "Set home" | "Zuhause festlegen" | +| `settingsChangeHome` | "Change home" | "Zuhause ändern" | +| `homeSheetSetHome` | "Set home" | "Zuhause festlegen" | +| `searchPickHomeTitle` | "Pick home location" | "Zuhause auswählen" | +| `homeSetSuccess` | "Home set to {label}" | "Zuhause auf {label} gesetzt" | +| `homeSetError` | "Couldn't set home" | "Zuhause konnte nicht gespeichert werden" | + +`homeSetSuccess` uses ARB placeholder syntax with `{label}` (string). + +Regenerate `mobile/lib/l10n/generated/*` as part of the change. + +## Tests + +- **`mobile/test/widgets/home_sheet_test.dart`** (new or extended) + - Logged-in + no home → "Set home" CTA renders, "Go Home" button absent + - Logged-in + home set → long-press on `_GoHomeButton` calls `HapticFeedback.mediumImpact` (verify via test channel) and pushes route to picker (use a `MaterialApp` with `onGenerateRoute` mock) + - Logged-out → existing login/register cards still render + +- **`mobile/test/screens/search_screen_test.dart`** (new or extended) + - `pickHome` mode: title is `searchPickHomeTitle` + - Tap on a fake geocode result calls `homeLocationProvider.set` with the right `Location` + - On success: route popped + snackbar visible with success text + - On `setHome` throwing: route not popped + snackbar with error text + +- **`mobile/test/screens/settings_screen_test.dart`** (new or extended) + - No home → row labelled `settingsSetHome` + - Home set → existing home block + `settingsChangeHome` affordance + +Override `locationsApiProvider` with a fake in tests to control success/failure without hitting Dio. + +## Files touched + +- `mobile/lib/screens/settings_screen.dart` +- `mobile/lib/screens/search_screen.dart` +- `mobile/lib/widgets/home_sheet.dart` +- `mobile/lib/l10n/app_en.arb` +- `mobile/lib/l10n/app_de.arb` +- `mobile/lib/l10n/generated/*` (regenerated) +- `mobile/test/widgets/home_sheet_test.dart` +- `mobile/test/screens/search_screen_test.dart` +- `mobile/test/screens/settings_screen_test.dart` + +## Edge cases + +- **Logged-out user opens settings via deep link**: settings already gates home block on auth. New "Set home" row only renders when logged in. +- **Network failure mid-set**: `AsyncValue.guard` keeps prior state; UI stays on picker; user can retry. +- **Backend returns different label than typed query**: snackbar uses the saved `Location.label` returned from the provider, not the geocode result's raw label. +- **User pops picker before tapping**: no-op. No partial state. +- **Rapid double-tap on a result**: protect with a local "saving" flag in the picker tile or disable the list during the in-flight `set` call. From de6ee783c808d1c086ace0a5b139058032947c73 Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:19:42 +0200 Subject: [PATCH 02/11] docs: implementation plan for mobile set-home --- .../plans/2026-04-26-mobile-set-home.md | 967 ++++++++++++++++++ 1 file changed, 967 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-mobile-set-home.md diff --git a/docs/superpowers/plans/2026-04-26-mobile-set-home.md b/docs/superpowers/plans/2026-04-26-mobile-set-home.md new file mode 100644 index 0000000..e132f09 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-mobile-set-home.md @@ -0,0 +1,967 @@ +# Mobile Set-Home 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:** Add UI in the Flutter app so users can set their home location from Settings, the empty-state Home Sheet CTA, and a long-press on the existing "Go home" button. + +**Architecture:** Reuse the existing `homeLocationProvider.save()` + `LocationsApi.setHome()`. Add a `SearchMode` enum to `SearchScreen` so it can either return a picked `Location` (existing route flow) or save it as home and pop. New entry points push the picker, then show a snackbar. + +**Tech Stack:** Flutter 3, Riverpod 2 (`AsyncNotifierProvider`), Flutter `gen-l10n` for ARB localization, Material 3 `ScaffoldMessenger`, `HapticFeedback.mediumImpact()`. + +**Spec:** [docs/superpowers/specs/2026-04-26-mobile-set-home-design.md](../specs/2026-04-26-mobile-set-home-design.md) + +**Provider note:** The spec calls the setter `set()`. The actual method on `HomeLocationController` ([mobile/lib/providers/location_provider.dart:23](../../../mobile/lib/providers/location_provider.dart)) is `save(Location)`. This plan uses `save()` everywhere. + +--- + +## File map + +| Path | Change | +|---|---| +| `mobile/lib/l10n/app_en.arb` | Add 6 keys | +| `mobile/lib/l10n/app_de.arb` | Add 6 keys (German) | +| `mobile/lib/l10n/generated/*.dart` | Regenerated by `flutter gen-l10n` | +| `mobile/lib/screens/search_screen.dart` | Add `SearchMode` enum + `mode` param; pickHome behaviour | +| `mobile/lib/screens/settings_screen.dart` | New "Set home" / "Change home" row in `_AccountSection` | +| `mobile/lib/widgets/home_sheet.dart` | Empty-state "Set home" CTA + long-press on `_GoHomeButton` | +| `mobile/test/screens/search_screen_test.dart` | Cases for pickHome mode | +| `mobile/test/screens/settings_screen_set_home_test.dart` | New file: settings row tests | +| `mobile/test/widgets/home_sheet_test.dart` | New cases: empty-state CTA + long-press | + +--- + +## Task 1: Add localization strings + +**Files:** +- Modify: `mobile/lib/l10n/app_en.arb` +- Modify: `mobile/lib/l10n/app_de.arb` +- Regenerate: `mobile/lib/l10n/generated/app_localizations.dart` and locale files + +- [ ] **Step 1: Add English strings** + +Open `mobile/lib/l10n/app_en.arb`. Find the existing block that starts with `"searchHint": "Where to?"`. Insert these keys immediately *before* `"searchHint"`: + +```json + "settingsSetHome": "Set home", + "settingsChangeHome": "Change home", + "homeSheetSetHome": "Set home", + "searchPickHomeTitle": "Pick home location", + "homeSetSuccess": "Home set to {label}", + "@homeSetSuccess": { + "placeholders": { "label": { "type": "String" } } + }, + "homeSetError": "Couldn't set home", + +``` + +(Keep trailing comma after the last new line — `searchHint` follows.) + +- [ ] **Step 2: Add German strings** + +Open `mobile/lib/l10n/app_de.arb` and add the same keys at the equivalent position: + +```json + "settingsSetHome": "Zuhause festlegen", + "settingsChangeHome": "Zuhause ändern", + "homeSheetSetHome": "Zuhause festlegen", + "searchPickHomeTitle": "Zuhause auswählen", + "homeSetSuccess": "Zuhause auf {label} gesetzt", + "@homeSetSuccess": { + "placeholders": { "label": { "type": "String" } } + }, + "homeSetError": "Zuhause konnte nicht gespeichert werden", + +``` + +- [ ] **Step 3: Regenerate localizations** + +Run from repo root: + +```bash +cd mobile && flutter gen-l10n && cd .. +``` + +Expected: no output (or "Generated `lib/l10n/generated/...`"). New `l10n.AppLocalizations` getters: `settingsSetHome`, `settingsChangeHome`, `homeSheetSetHome`, `searchPickHomeTitle`, `homeSetSuccess(String label)`, `homeSetError`. + +- [ ] **Step 4: Verify** + +```bash +just test-mobile +``` + +Expected: existing tests pass (no behaviour change yet). If `flutter gen-l10n` failed, fix ARB JSON syntax. + +- [ ] **Step 5: Commit** + +```bash +git add mobile/lib/l10n/app_en.arb mobile/lib/l10n/app_de.arb mobile/lib/l10n/generated/ +git commit -m "feat(mobile,l10n): add set-home strings" +``` + +--- + +## Task 2: Add `SearchMode` to `SearchScreen` (TDD) + +**Files:** +- Modify: `mobile/lib/screens/search_screen.dart` +- Modify: `mobile/test/screens/search_screen_test.dart` + +- [ ] **Step 1: Write failing tests for pickHome mode** + +Open `mobile/test/screens/search_screen_test.dart`. Add at the bottom of the existing `void main() { ... }` block (just before its closing `}`): + +```dart + group('pickHome mode', () { + testWidgets('app bar title uses pick-home string', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SearchScreen(mode: SearchMode.pickHome), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + expect(find.text('Pick home location'), findsOneWidget); + // Quick section / current-location shortcut MUST NOT render. + expect(find.text('QUICK'), findsNothing); + }); + + testWidgets('tapping a result saves home and pops with the Location', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + Location? popped; + await tester.pumpWidget(buildTestWidget( + Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + popped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const SearchScreen(mode: SearchMode.pickHome), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + prefs: prefs, + authenticated: true, + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(popped, isNotNull); + expect(popped!.name, 'Alexanderplatz'); + }); + }); +``` + +You also need to import the new symbol — at the top of the test file, the existing `import 'package:beebeebike/screens/search_screen.dart';` will pick `SearchMode` up once it's exported in step 3. + +- [ ] **Step 2: Run tests, verify failure** + +```bash +cd mobile && flutter test test/screens/search_screen_test.dart && cd .. +``` + +Expected: compilation error — `SearchMode` undefined. Good. + +- [ ] **Step 3: Add `SearchMode` enum and `mode` constructor parameter** + +Open `mobile/lib/screens/search_screen.dart`. Just below the existing `_geocodeApiProvider` (around line 16), insert: + +```dart +enum SearchMode { route, pickHome } +``` + +Then change the class declaration (around line 18-23) from: + +```dart +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key}); + + @override + ConsumerState createState() => _SearchScreenState(); +} +``` + +to: + +```dart +class SearchScreen extends ConsumerStatefulWidget { + const SearchScreen({super.key, this.mode = SearchMode.route}); + + final SearchMode mode; + + @override + ConsumerState createState() => _SearchScreenState(); +} +``` + +- [ ] **Step 4: Add pickHome behaviour to `_selectLocation` + body** + +In the same file, replace the existing `_selectLocation` method (around line 62-64) with: + +```dart + Future _selectLocation(Location location) async { + if (widget.mode == SearchMode.pickHome) { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final l10n = AppLocalizations.of(context)!; + try { + await ref.read(homeLocationProvider.notifier).save(location); + } on Object catch (_) { + messenger.showSnackBar(SnackBar(content: Text(l10n.homeSetError))); + return; + } + navigator.pop(location); + return; + } + Navigator.of(context).pop(location); + } +``` + +Then replace the entire `build` method body's app-bar title and quick-section block. Find the existing `appBar: AppBar(...)` (around line 75-96) and change `title: TextField(` to use the right hint based on mode — the simplest split is to inject a different title widget. Replace the `appBar` argument with: + +```dart + appBar: AppBar( + leading: const BackButton(color: BbbColors.ink), + backgroundColor: BbbColors.panel, + foregroundColor: BbbColors.ink, + elevation: 0, + scrolledUnderElevation: 0, + title: widget.mode == SearchMode.pickHome + ? Text(l10n.searchPickHomeTitle, style: BbbText.cardTitle()) + : TextField( + controller: _controller, + autofocus: true, + style: BbbText.body(), + decoration: InputDecoration( + hintText: l10n.searchHint, + hintStyle: BbbText.body().copyWith(color: BbbColors.inkFaint), + border: InputBorder.none, + ), + onChanged: _onChanged, + onSubmitted: (value) { + _debounce?.cancel(); + if (value.trim().isNotEmpty) unawaited(_search(value.trim())); + }, + ), + ), +``` + +For pickHome we still need a search input — render it inline at the top of the `ListView` body in pickHome mode. Also drop the Quick / Saved-Home section in pickHome mode. Replace the `body: ListView(...)` block (around line 97-128) with: + +```dart + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + if (widget.mode == SearchMode.pickHome) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: TextField( + controller: _controller, + autofocus: true, + style: BbbText.body(), + decoration: InputDecoration( + hintText: l10n.searchHint, + hintStyle: BbbText.body().copyWith(color: BbbColors.inkFaint), + border: const OutlineInputBorder(), + ), + onChanged: _onChanged, + onSubmitted: (value) { + _debounce?.cancel(); + if (value.trim().isNotEmpty) unawaited(_search(value.trim())); + }, + ), + ) + else ...[ + _SectionHeader(l10n.searchSectionQuick.toUpperCase()), + _SearchRow( + icon: Icons.my_location, + title: l10n.locationCurrent, + onTap: () => _selectLocation(Location( + id: 'gps', + name: l10n.locationCurrent, + label: l10n.locationCurrent, + lng: 0, + lat: 0, + )), + ), + if (homeLocation != null) + _SearchRow( + icon: Icons.home_outlined, + title: l10n.settingsHome, + subtitle: homeLocation.label.isNotEmpty + ? homeLocation.label + : null, + onTap: () => _selectLocation(homeLocation), + ), + const _SectionDivider(), + ], + if (hasQuery) + ..._buildResultsSection(l10n) + else if (widget.mode != SearchMode.pickHome) + ..._buildRecentSection(history), + ], + ), +``` + +- [ ] **Step 5: Run tests, verify pass** + +```bash +cd mobile && flutter test test/screens/search_screen_test.dart && cd .. +``` + +Expected: all tests pass, including the two new pickHome cases. + +- [ ] **Step 6: Lint** + +```bash +just test-mobile +``` + +Expected: analyze + tests green. + +- [ ] **Step 7: Commit** + +```bash +git add mobile/lib/screens/search_screen.dart mobile/test/screens/search_screen_test.dart +git commit -m "feat(mobile): pickHome mode for SearchScreen" +``` + +--- + +## Task 3: Settings screen "Set home" / "Change home" row (TDD) + +**Files:** +- Create: `mobile/test/screens/settings_screen_set_home_test.dart` +- Modify: `mobile/lib/screens/settings_screen.dart` + +- [ ] **Step 1: Write failing test** + +Create `mobile/test/screens/settings_screen_set_home_test.dart`: + +```dart +import 'package:beebeebike/screens/search_screen.dart'; +import 'package:beebeebike/screens/settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + testWidgets('shows "Set home" row when no home saved', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + expect(find.text('Set home'), findsOneWidget); + expect(find.text('Change home'), findsNothing); + }); + + testWidgets('shows "Change home" row when home is saved', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + homeLocation: fakeHome(), + )); + await tester.pump(); + + expect(find.text('Change home'), findsOneWidget); + expect(find.text('Set home'), findsNothing); + }); + + testWidgets('tapping the row pushes SearchScreen in pickHome mode', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + await tester.tap(find.text('Set home')); + await tester.pumpAndSettle(); + + expect(find.byType(SearchScreen), findsOneWidget); + final search = tester.widget(find.byType(SearchScreen)); + expect(search.mode, SearchMode.pickHome); + }); +} +``` + +- [ ] **Step 2: Run, verify failure** + +```bash +cd mobile && flutter test test/screens/settings_screen_set_home_test.dart && cd .. +``` + +Expected: tests fail — "Set home" / "Change home" not found. + +- [ ] **Step 3: Add the row to `_AccountSection`** + +Open `mobile/lib/screens/settings_screen.dart`. At the top, add the import: + +```dart +import 'package:beebeebike/screens/search_screen.dart'; +``` + +In `_AccountSection.build` (around line 84-130), replace the entire `Column(... children: [...])` block. Specifically, replace the existing `if (home != null) Padding(...)` block with this new row (which always renders when logged in, replacing the old display): + +Find: + +```dart + if (home != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settingsHome, style: BbbText.label()), + const SizedBox(height: 2), + Text(home.label, style: BbbText.monoSub()), + ], + ), + ), +``` + +Replace with: + +```dart + if (loggedIn) + _SetHomeRow(home: home), +``` + +Then add this new private widget at the bottom of `mobile/lib/screens/settings_screen.dart` (just before the final closing brace of the file): + +```dart +class _SetHomeRow extends StatelessWidget { + const _SetHomeRow({required this.home}); + + final Location? home; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final hasHome = home != null; + return InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Row( + children: [ + const Icon(Icons.home_outlined, + size: 20, color: BbbColors.inkMuted), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hasHome ? l10n.settingsChangeHome : l10n.settingsSetHome, + style: BbbText.label(), + ), + if (hasHome) ...[ + const SizedBox(height: 2), + Text(home!.label, style: BbbText.monoSub()), + ], + ], + ), + ), + const Icon(Icons.chevron_right, color: BbbColors.inkFaint), + ], + ), + ), + ); + } +} +``` + +Add this import at the top if missing: + +```dart +import 'package:beebeebike/models/location.dart'; +``` + +- [ ] **Step 4: Run tests, verify pass** + +```bash +cd mobile && flutter test test/screens/settings_screen_set_home_test.dart && cd .. +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Lint** + +```bash +just test-mobile +``` + +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add mobile/lib/screens/settings_screen.dart mobile/test/screens/settings_screen_set_home_test.dart +git commit -m "feat(mobile): set/change home entry in settings" +``` + +--- + +## Task 4: Home sheet empty-state CTA + long-press (TDD) + +**Files:** +- Modify: `mobile/test/widgets/home_sheet_test.dart` +- Modify: `mobile/lib/widgets/home_sheet.dart` + +- [ ] **Step 1: Write failing test for empty-state CTA** + +Open `mobile/test/widgets/home_sheet_test.dart`. Add at the bottom of `void main() { ... }`: + +```dart + testWidgets('shows Set home CTA when logged in but no home saved', + (tester) async { + final prefs = await _prefsWithHistory(const []); + final controller = DraggableScrollableController(); + addTearDown(controller.dispose); + + await tester.pumpWidget(_host( + prefs: prefs, + controller: controller, + authenticated: true, + )); + await tester.pump(); + + expect(find.text('Set home'), findsOneWidget); + expect(find.text('Go home'), findsNothing); + }); + + testWidgets('long-press on Go home triggers haptic and navigates to picker', + (tester) async { + final prefs = await _prefsWithHistory(const []); + final controller = DraggableScrollableController(); + addTearDown(controller.dispose); + + final hapticCalls = []; + TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget(_host( + prefs: prefs, + controller: controller, + homeLocation: fakeHome(), + )); + await tester.pump(); + + await tester.longPress(find.text('Go home')); + await tester.pumpAndSettle(); + + expect(hapticCalls, isNotEmpty); + expect(find.byType(SearchScreen), findsOneWidget); + final search = tester.widget(find.byType(SearchScreen)); + expect(search.mode, SearchMode.pickHome); + }); +``` + +Add the imports needed at the top of the file: + +```dart +import 'package:beebeebike/screens/search_screen.dart'; +import 'package:flutter/services.dart'; +``` + +- [ ] **Step 2: Run tests, verify failure** + +```bash +cd mobile && flutter test test/widgets/home_sheet_test.dart && cd .. +``` + +Expected: new tests fail — Set home not found, long-press no-op. + +- [ ] **Step 3: Add empty-state CTA to `_GoHomeRow`** + +Open `mobile/lib/widgets/home_sheet.dart`. Add imports at the top: + +```dart +import 'package:beebeebike/screens/search_screen.dart'; +import 'package:flutter/services.dart'; +``` + +Replace `_GoHomeRow.build` (around line 104-118) with: + +```dart + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authControllerProvider).valueOrNull; + final loggedIn = user?.email != null; + final home = ref.watch(homeLocationProvider).valueOrNull; + final eta = home != null ? ref.watch(homeEtaMinutesProvider) : null; + + if (!loggedIn) return const _AuthRow(); + if (home == null) return _SetHomeCta(onTap: () => _openPicker(context)); + return _GoHomeButton( + enabled: true, + etaMinutes: eta, + onTap: onNavigateHome, + onLongPress: () { + unawaited(HapticFeedback.mediumImpact()); + _openPicker(context); + }, + ); + } + + void _openPicker(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ); + } +``` + +(Keep the `Future` import — `dart:async` is already imported at line 1.) + +- [ ] **Step 4: Add `onLongPress` to `_GoHomeButton`** + +In the same file, change `_GoHomeButton`'s constructor (around line 211-216) from: + +```dart +class _GoHomeButton extends StatelessWidget { + const _GoHomeButton({ + required this.enabled, + required this.etaMinutes, + this.onTap, + }); + + final bool enabled; + final AsyncValue? etaMinutes; + final VoidCallback? onTap; +``` + +to: + +```dart +class _GoHomeButton extends StatelessWidget { + const _GoHomeButton({ + required this.enabled, + required this.etaMinutes, + this.onTap, + this.onLongPress, + }); + + final bool enabled; + final AsyncValue? etaMinutes; + final VoidCallback? onTap; + final VoidCallback? onLongPress; +``` + +Then in `_GoHomeButton.build` (around line 246), find the `InkWell(`: + +```dart + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), +``` + +Add `onLongPress: onLongPress,` so it becomes: + +```dart + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(16), +``` + +- [ ] **Step 5: Add the `_SetHomeCta` widget** + +Append to `mobile/lib/widgets/home_sheet.dart` (e.g. just below `_GoHomeButton`): + +```dart +class _SetHomeCta extends StatelessWidget { + const _SetHomeCta({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + color: BbbColors.ink, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: Color.fromRGBO(255, 255, 255, 0.14), + shape: BoxShape.circle, + ), + child: const Icon(Icons.home_outlined, + size: 17, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.homeSheetSetHome, + style: BbbText.cardTitle().copyWith(color: Colors.white), + ), + ), + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 6: Run tests, verify pass** + +```bash +cd mobile && flutter test test/widgets/home_sheet_test.dart && cd .. +``` + +Expected: all home-sheet tests pass, including the two new ones. The pre-existing `'Go home button disabled when no home saved'` test will now fail (since we replaced the disabled button with the CTA). Update that test: + +Replace its body so the assertion matches the new behaviour: the `disabled` test was created when the design was "show disabled Go home". The new behaviour is "show Set home CTA". Either delete that test (covered by the new CTA test) or rewrite it to assert the CTA + absence of ETA. Delete it for clarity: + +Find and delete the `testWidgets('Go home button disabled when no home saved', ...)` block (around lines 136-148). + +Re-run: + +```bash +cd mobile && flutter test test/widgets/home_sheet_test.dart && cd .. +``` + +Expected: green. + +- [ ] **Step 7: Lint + full mobile tests** + +```bash +just test-mobile +``` + +Expected: green. + +- [ ] **Step 8: Commit** + +```bash +git add mobile/lib/widgets/home_sheet.dart mobile/test/widgets/home_sheet_test.dart +git commit -m "feat(mobile): set-home CTA and long-press on home sheet" +``` + +--- + +## Task 5: Snackbar wiring + manual smoke + +**Files:** +- (no new files) + +The success snackbar fires from `SearchScreen._selectLocation` only on the picker itself, *before* pop. Because the route is popped immediately after, the snackbar must come from the caller. Update Task 2's implementation if not already correct: the picker should pop with the saved `Location?`, and each caller (settings row, home-sheet CTA, home-sheet long-press) should show the success snackbar after the push completes. + +- [ ] **Step 1: Update `_selectLocation` to pop *without* showing snackbar on success** + +The current Task 2 implementation already pops with the location. Confirm `mobile/lib/screens/search_screen.dart` `_selectLocation` matches: + +```dart + Future _selectLocation(Location location) async { + if (widget.mode == SearchMode.pickHome) { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final l10n = AppLocalizations.of(context)!; + try { + await ref.read(homeLocationProvider.notifier).save(location); + } on Object catch (_) { + messenger.showSnackBar(SnackBar(content: Text(l10n.homeSetError))); + return; + } + navigator.pop(location); + return; + } + Navigator.of(context).pop(location); + } +``` + +Good — error snackbar fires on the picker (still mounted), success pops returning the `Location`. + +- [ ] **Step 2: Wire success snackbar in settings row caller** + +In `mobile/lib/screens/settings_screen.dart`, change `_SetHomeRow.build`'s `onTap`: + +```dart + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ); + if (saved != null) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.homeSetSuccess(saved.label))), + ); + } + }, +``` + +- [ ] **Step 3: Wire success snackbar in home-sheet caller** + +In `mobile/lib/widgets/home_sheet.dart`, replace `_openPicker` with: + +```dart + Future _openPicker(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ); + if (saved != null) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.homeSetSuccess(saved.label))), + ); + } + } +``` + +Both call sites need to be updated to `unawaited(_openPicker(context))` since the inner `Navigator.push` callbacks are sync. Find the existing call sites in `_GoHomeRow.build` and change them from `_openPicker(context)` to `unawaited(_openPicker(context))` (the `dart:async` import is already there). + +- [ ] **Step 4: Add success-snackbar tests** + +In `mobile/test/screens/settings_screen_set_home_test.dart`, append: + +```dart + testWidgets('shows success snackbar after picker returns a location', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + await tester.tap(find.text('Set home')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Home set to'), findsOneWidget); + }); +``` + +Add the import to the top of that file: + +```dart +import 'package:beebeebike/models/location.dart'; +``` + +- [ ] **Step 5: Run tests** + +```bash +cd mobile && flutter test test/screens/settings_screen_set_home_test.dart test/widgets/home_sheet_test.dart test/screens/search_screen_test.dart && cd .. +``` + +Expected: green. + +- [ ] **Step 6: Full lint + tests** + +```bash +just test-mobile +``` + +Expected: green. + +- [ ] **Step 7: Manual smoke (iOS sim)** + +```bash +just dev-ios-sim +``` + +Verify all four entry points end-to-end: + +1. Logged in, no home → home sheet shows "Set home" CTA → tap → search → tap result → snackbar "Home set to {label}" → home sheet now shows "Go home" +2. Settings → "Set home" row visible (no home) → after step 1, "Change home" row with current label +3. Settings → tap "Change home" → search → tap result → snackbar +4. Home sheet → long-press "Go home" → haptic vibration → search → tap result → snackbar + +If anything fails, fix and re-run before commit. + +- [ ] **Step 8: Commit** + +```bash +git add mobile/lib/screens/settings_screen.dart mobile/lib/widgets/home_sheet.dart mobile/test/screens/settings_screen_set_home_test.dart +git commit -m "feat(mobile): success snackbar after set-home" +``` + +--- + +## Done + +All four entry points work, all tests green. Plan addresses every requirement in the spec: + +- Settings entry (Set / Change home) — Task 3 +- Home sheet empty-state CTA — Task 4 +- Home sheet long-press with haptic — Task 4 +- Search reuse via `pickHome` mode (no current-location shortcut, picker title) — Task 2 +- Snackbar success / error wiring — Tasks 2 + 5 +- l10n strings (en + de) — Task 1 +- Tests at every layer — Tasks 2, 3, 4, 5 From 159ad5d103605793fc964708c09056f7ccb9e7ab Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:22:27 +0200 Subject: [PATCH 03/11] feat(mobile,l10n): add set-home strings --- mobile/lib/l10n/app_de.arb | 10 ++++++ mobile/lib/l10n/app_en.arb | 10 ++++++ .../lib/l10n/generated/app_localizations.dart | 36 +++++++++++++++++++ .../l10n/generated/app_localizations_de.dart | 20 +++++++++++ .../l10n/generated/app_localizations_en.dart | 20 +++++++++++ 5 files changed, 96 insertions(+) diff --git a/mobile/lib/l10n/app_de.arb b/mobile/lib/l10n/app_de.arb index 1df4a52..f27bd27 100644 --- a/mobile/lib/l10n/app_de.arb +++ b/mobile/lib/l10n/app_de.arb @@ -76,6 +76,16 @@ "onboarding3Headline": "Am Rechner malen, am Rad fahren", "onboarding3Body": "Du kannst beebeebike sofort anonym nutzen. Mit einem kostenlosen Konto kannst du auf beebeebike.com am Rechner schneller malen — deine Bewertungen landen automatisch auf dem Handy.", + "settingsSetHome": "Zuhause festlegen", + "settingsChangeHome": "Zuhause ändern", + "homeSheetSetHome": "Zuhause festlegen", + "searchPickHomeTitle": "Zuhause auswählen", + "homeSetSuccess": "Zuhause auf {label} gesetzt", + "@homeSetSuccess": { + "placeholders": { "label": { "type": "String" } } + }, + "homeSetError": "Zuhause konnte nicht gespeichert werden", + "searchHint": "Wohin?", "searchSavedPlaces": "Gespeicherte Orte", "searchSectionQuick": "Schnell", diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb index e86d59f..083c2fd 100644 --- a/mobile/lib/l10n/app_en.arb +++ b/mobile/lib/l10n/app_en.arb @@ -76,6 +76,16 @@ "onboarding3Headline": "Paint on the desktop, ride with your phone", "onboarding3Body": "You can use beebeebike right away anonymously. With a free account you can paint faster on beebeebike.com in the browser — your ratings sync to the phone automatically.", + "settingsSetHome": "Set home", + "settingsChangeHome": "Change home", + "homeSheetSetHome": "Set home", + "searchPickHomeTitle": "Pick home location", + "homeSetSuccess": "Home set to {label}", + "@homeSetSuccess": { + "placeholders": { "label": { "type": "String" } } + }, + "homeSetError": "Couldn't set home", + "searchHint": "Where to?", "searchSavedPlaces": "Saved places", "searchSectionQuick": "Quick", diff --git a/mobile/lib/l10n/generated/app_localizations.dart b/mobile/lib/l10n/generated/app_localizations.dart index 31339f0..9d82233 100644 --- a/mobile/lib/l10n/generated/app_localizations.dart +++ b/mobile/lib/l10n/generated/app_localizations.dart @@ -494,6 +494,42 @@ abstract class AppLocalizations { /// **'You can use beebeebike right away anonymously. With a free account you can paint faster on beebeebike.com in the browser — your ratings sync to the phone automatically.'** String get onboarding3Body; + /// No description provided for @settingsSetHome. + /// + /// In en, this message translates to: + /// **'Set home'** + String get settingsSetHome; + + /// No description provided for @settingsChangeHome. + /// + /// In en, this message translates to: + /// **'Change home'** + String get settingsChangeHome; + + /// No description provided for @homeSheetSetHome. + /// + /// In en, this message translates to: + /// **'Set home'** + String get homeSheetSetHome; + + /// No description provided for @searchPickHomeTitle. + /// + /// In en, this message translates to: + /// **'Pick home location'** + String get searchPickHomeTitle; + + /// No description provided for @homeSetSuccess. + /// + /// In en, this message translates to: + /// **'Home set to {label}'** + String homeSetSuccess(String label); + + /// No description provided for @homeSetError. + /// + /// In en, this message translates to: + /// **'Couldn\'t set home'** + String get homeSetError; + /// No description provided for @searchHint. /// /// In en, this message translates to: diff --git a/mobile/lib/l10n/generated/app_localizations_de.dart b/mobile/lib/l10n/generated/app_localizations_de.dart index 47d8206..3926843 100644 --- a/mobile/lib/l10n/generated/app_localizations_de.dart +++ b/mobile/lib/l10n/generated/app_localizations_de.dart @@ -221,6 +221,26 @@ class AppLocalizationsDe extends AppLocalizations { String get onboarding3Body => 'Du kannst beebeebike sofort anonym nutzen. Mit einem kostenlosen Konto kannst du auf beebeebike.com am Rechner schneller malen — deine Bewertungen landen automatisch auf dem Handy.'; + @override + String get settingsSetHome => 'Zuhause festlegen'; + + @override + String get settingsChangeHome => 'Zuhause ändern'; + + @override + String get homeSheetSetHome => 'Zuhause festlegen'; + + @override + String get searchPickHomeTitle => 'Zuhause auswählen'; + + @override + String homeSetSuccess(String label) { + return 'Zuhause auf $label gesetzt'; + } + + @override + String get homeSetError => 'Zuhause konnte nicht gespeichert werden'; + @override String get searchHint => 'Wohin?'; diff --git a/mobile/lib/l10n/generated/app_localizations_en.dart b/mobile/lib/l10n/generated/app_localizations_en.dart index 923e5c8..893b18f 100644 --- a/mobile/lib/l10n/generated/app_localizations_en.dart +++ b/mobile/lib/l10n/generated/app_localizations_en.dart @@ -220,6 +220,26 @@ class AppLocalizationsEn extends AppLocalizations { String get onboarding3Body => 'You can use beebeebike right away anonymously. With a free account you can paint faster on beebeebike.com in the browser — your ratings sync to the phone automatically.'; + @override + String get settingsSetHome => 'Set home'; + + @override + String get settingsChangeHome => 'Change home'; + + @override + String get homeSheetSetHome => 'Set home'; + + @override + String get searchPickHomeTitle => 'Pick home location'; + + @override + String homeSetSuccess(String label) { + return 'Home set to $label'; + } + + @override + String get homeSetError => 'Couldn\'t set home'; + @override String get searchHint => 'Where to?'; From c302aeecf431258bbdf3089ad47b74028e2412ce Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:27:58 +0200 Subject: [PATCH 04/11] feat(mobile): pickHome mode for SearchScreen --- mobile/lib/screens/search_screen.dart | 116 +++++++++++++------- mobile/test/screens/search_screen_test.dart | 59 ++++++++++ 2 files changed, 137 insertions(+), 38 deletions(-) diff --git a/mobile/lib/screens/search_screen.dart b/mobile/lib/screens/search_screen.dart index b9374f4..6e9173f 100644 --- a/mobile/lib/screens/search_screen.dart +++ b/mobile/lib/screens/search_screen.dart @@ -15,8 +15,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final _geocodeApiProvider = Provider((ref) => GeocodeApi(ref.watch(dioProvider))); +enum SearchMode { route, pickHome } + class SearchScreen extends ConsumerStatefulWidget { - const SearchScreen({super.key}); + const SearchScreen({super.key, this.mode = SearchMode.route}); + + final SearchMode mode; @override ConsumerState createState() => _SearchScreenState(); @@ -59,7 +63,20 @@ class _SearchScreenState extends ConsumerState { } } - void _selectLocation(Location location) { + Future _selectLocation(Location location) async { + if (widget.mode == SearchMode.pickHome) { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final l10n = AppLocalizations.of(context)!; + try { + await ref.read(homeLocationProvider.notifier).save(location); + } on Object catch (_) { + messenger.showSnackBar(SnackBar(content: Text(l10n.homeSetError))); + return; + } + navigator.pop(location); + return; + } Navigator.of(context).pop(location); } @@ -78,50 +95,73 @@ class _SearchScreenState extends ConsumerState { foregroundColor: BbbColors.ink, elevation: 0, scrolledUnderElevation: 0, - title: TextField( - controller: _controller, - autofocus: true, - style: BbbText.body(), - decoration: InputDecoration( - hintText: l10n.searchHint, - hintStyle: BbbText.body().copyWith(color: BbbColors.inkFaint), - border: InputBorder.none, - ), - onChanged: _onChanged, - onSubmitted: (value) { - _debounce?.cancel(); - if (value.trim().isNotEmpty) unawaited(_search(value.trim())); - }, - ), + title: widget.mode == SearchMode.pickHome + ? Text(l10n.searchPickHomeTitle, style: BbbText.cardTitle()) + : TextField( + controller: _controller, + autofocus: true, + style: BbbText.body(), + decoration: InputDecoration( + hintText: l10n.searchHint, + hintStyle: BbbText.body().copyWith(color: BbbColors.inkFaint), + border: InputBorder.none, + ), + onChanged: _onChanged, + onSubmitted: (value) { + _debounce?.cancel(); + if (value.trim().isNotEmpty) unawaited(_search(value.trim())); + }, + ), ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ - _SectionHeader(l10n.searchSectionQuick.toUpperCase()), - _SearchRow( - icon: Icons.my_location, - title: l10n.locationCurrent, - onTap: () => _selectLocation(Location( - id: 'gps', - name: l10n.locationCurrent, - label: l10n.locationCurrent, - lng: 0, - lat: 0, - )), - ), - if (homeLocation != null) + if (widget.mode == SearchMode.pickHome) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: TextField( + controller: _controller, + autofocus: true, + style: BbbText.body(), + decoration: InputDecoration( + hintText: l10n.searchHint, + hintStyle: BbbText.body().copyWith(color: BbbColors.inkFaint), + border: const OutlineInputBorder(), + ), + onChanged: _onChanged, + onSubmitted: (value) { + _debounce?.cancel(); + if (value.trim().isNotEmpty) unawaited(_search(value.trim())); + }, + ), + ) + else ...[ + _SectionHeader(l10n.searchSectionQuick.toUpperCase()), _SearchRow( - icon: Icons.home_outlined, - title: l10n.settingsHome, - subtitle: homeLocation.label.isNotEmpty - ? homeLocation.label - : null, - onTap: () => _selectLocation(homeLocation), + icon: Icons.my_location, + title: l10n.locationCurrent, + onTap: () => _selectLocation(Location( + id: 'gps', + name: l10n.locationCurrent, + label: l10n.locationCurrent, + lng: 0, + lat: 0, + )), ), - const _SectionDivider(), + if (homeLocation != null) + _SearchRow( + icon: Icons.home_outlined, + title: l10n.settingsHome, + subtitle: homeLocation.label.isNotEmpty + ? homeLocation.label + : null, + onTap: () => _selectLocation(homeLocation), + ), + const _SectionDivider(), + ], if (hasQuery) ..._buildResultsSection(l10n) - else + else if (widget.mode != SearchMode.pickHome) ..._buildRecentSection(history), ], ), diff --git a/mobile/test/screens/search_screen_test.dart b/mobile/test/screens/search_screen_test.dart index 50fceb6..53d481c 100644 --- a/mobile/test/screens/search_screen_test.dart +++ b/mobile/test/screens/search_screen_test.dart @@ -270,4 +270,63 @@ void main() { expect(poppedResult!.lng, 13.4050); expect(poppedResult!.lat, 52.5200); }); + + group('pickHome mode', () { + testWidgets('app bar title uses pick-home string', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SearchScreen(mode: SearchMode.pickHome), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + expect(find.text('Pick home location'), findsOneWidget); + // Quick section / current-location shortcut MUST NOT render. + expect(find.text('QUICK'), findsNothing); + }); + + testWidgets('tapping a result saves home and pops with the Location', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + Location? popped; + await tester.pumpWidget(buildTestWidget( + Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () async { + popped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const SearchScreen(mode: SearchMode.pickHome), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + prefs: prefs, + authenticated: true, + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(popped, isNotNull); + expect(popped!.name, 'Alexanderplatz'); + }); + }); } From 2ca34055be3650ebe137068e5b086b63069df56b Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:33:12 +0200 Subject: [PATCH 05/11] test(mobile): error path for pickHome save failure Adds a test that overrides homeLocationProvider with a throwing controller, taps an Alexanderplatz result, and asserts the "Couldn't set home" SnackBar is shown while SearchScreen stays visible. --- mobile/test/screens/search_screen_test.dart | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/mobile/test/screens/search_screen_test.dart b/mobile/test/screens/search_screen_test.dart index 53d481c..28409ab 100644 --- a/mobile/test/screens/search_screen_test.dart +++ b/mobile/test/screens/search_screen_test.dart @@ -1,11 +1,26 @@ +import 'dart:async'; + +import 'package:beebeebike/l10n/generated/app_localizations.dart'; import 'package:beebeebike/models/location.dart'; +import 'package:beebeebike/providers/location_provider.dart'; import 'package:beebeebike/screens/search_screen.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/test_helpers.dart'; +class _ThrowingHomeController extends HomeLocationController { + @override + Future build() async => null; + + @override + Future save(Location location) async { + throw Exception('simulated save failure'); + } +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -328,5 +343,56 @@ void main() { expect(popped, isNotNull); expect(popped!.name, 'Alexanderplatz'); }); + + testWidgets('shows error snackbar and stays on screen when save throws', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...testProviderOverrides(prefs: prefs, authenticated: true), + homeLocationProvider.overrideWith(_ThrowingHomeController.new), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: + AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { + unawaited(Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const SearchScreen(mode: SearchMode.pickHome), + ), + )); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(find.text("Couldn't set home"), findsOneWidget); + expect(find.byType(SearchScreen), findsOneWidget); + }); }); } From 2a6355fd2a48a909b4dd2c73c0a8577b8fed4c3d Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:38:46 +0200 Subject: [PATCH 06/11] feat(mobile): set/change home entry in settings --- mobile/lib/screens/settings_screen.dart | 61 +++++++++++++++---- .../settings_screen_set_home_test.dart | 59 ++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 mobile/test/screens/settings_screen_set_home_test.dart diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index d947d6c..6e2efa7 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:beebeebike/app.dart'; import 'package:beebeebike/l10n/generated/app_localizations.dart'; +import 'package:beebeebike/models/location.dart'; import 'package:beebeebike/providers/auth_provider.dart'; import 'package:beebeebike/providers/cobblestone_avoidance_provider.dart'; import 'package:beebeebike/providers/location_provider.dart'; import 'package:beebeebike/screens/legal_document_screen.dart'; import 'package:beebeebike/screens/login_screen.dart'; import 'package:beebeebike/screens/register_screen.dart'; +import 'package:beebeebike/screens/search_screen.dart'; import 'package:beebeebike/services/error_reporter.dart'; import 'package:beebeebike/theme/tokens.dart'; import 'package:beebeebike/theme/typography.dart'; @@ -113,18 +115,8 @@ class _AccountSection extends ConsumerWidget { ], ), ), - if (home != null) - Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.settingsHome, style: BbbText.label()), - const SizedBox(height: 2), - Text(home.label, style: BbbText.monoSub()), - ], - ), - ), + if (loggedIn) + _SetHomeRow(home: home), Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), child: _AuthButton(loggedIn: loggedIn), @@ -510,6 +502,51 @@ class _CreditLink extends StatefulWidget { State<_CreditLink> createState() => _CreditLinkState(); } +class _SetHomeRow extends StatelessWidget { + const _SetHomeRow({required this.home}); + + final Location? home; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final hasHome = home != null; + return InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Row( + children: [ + const Icon(Icons.home_outlined, + size: 20, color: BbbColors.inkMuted), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hasHome ? l10n.settingsChangeHome : l10n.settingsSetHome, + style: BbbText.label(), + ), + if (hasHome) ...[ + const SizedBox(height: 2), + Text(home!.label, style: BbbText.monoSub()), + ], + ], + ), + ), + const Icon(Icons.chevron_right, color: BbbColors.inkFaint), + ], + ), + ), + ); + } +} + class _CreditLinkState extends State<_CreditLink> { late final TapGestureRecognizer _recognizer; diff --git a/mobile/test/screens/settings_screen_set_home_test.dart b/mobile/test/screens/settings_screen_set_home_test.dart new file mode 100644 index 0000000..8a8f87c --- /dev/null +++ b/mobile/test/screens/settings_screen_set_home_test.dart @@ -0,0 +1,59 @@ +import 'package:beebeebike/screens/search_screen.dart'; +import 'package:beebeebike/screens/settings_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + testWidgets('shows "Set home" row when no home saved', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + expect(find.text('Set home'), findsOneWidget); + expect(find.text('Change home'), findsNothing); + }); + + testWidgets('shows "Change home" row when home is saved', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + homeLocation: fakeHome(), + )); + await tester.pump(); + + expect(find.text('Change home'), findsOneWidget); + expect(find.text('Set home'), findsNothing); + }); + + testWidgets('tapping the row pushes SearchScreen in pickHome mode', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + await tester.tap(find.text('Set home')); + await tester.pumpAndSettle(); + + expect(find.byType(SearchScreen), findsOneWidget); + final search = tester.widget(find.byType(SearchScreen)); + expect(search.mode, SearchMode.pickHome); + }); +} From 4b15d653ab2365707e0d19f47bd947ea078d967b Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:46:36 +0200 Subject: [PATCH 07/11] feat(mobile): set-home CTA and long-press on home sheet --- mobile/lib/widgets/home_sheet.dart | 79 +++++++++++++++++++++--- mobile/test/screens/map_screen_test.dart | 10 +-- mobile/test/widgets/home_sheet_test.dart | 51 +++++++++++++-- 3 files changed, 119 insertions(+), 21 deletions(-) diff --git a/mobile/lib/widgets/home_sheet.dart b/mobile/lib/widgets/home_sheet.dart index db5c4e0..319ec08 100644 --- a/mobile/lib/widgets/home_sheet.dart +++ b/mobile/lib/widgets/home_sheet.dart @@ -9,10 +9,12 @@ import 'package:beebeebike/providers/route_provider.dart'; import 'package:beebeebike/providers/search_history_provider.dart'; import 'package:beebeebike/screens/login_screen.dart'; import 'package:beebeebike/screens/register_screen.dart'; +import 'package:beebeebike/screens/search_screen.dart'; import 'package:beebeebike/theme/tokens.dart'; import 'package:beebeebike/theme/typography.dart'; import 'package:beebeebike/widgets/saved_item.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; @@ -105,16 +107,27 @@ class _GoHomeRow extends ConsumerWidget { final user = ref.watch(authControllerProvider).valueOrNull; final loggedIn = user?.email != null; final home = ref.watch(homeLocationProvider).valueOrNull; - final enabled = home != null; - final eta = enabled ? ref.watch(homeEtaMinutesProvider) : null; - - return loggedIn - ? _GoHomeButton( - enabled: enabled, - etaMinutes: eta, - onTap: enabled ? onNavigateHome : null, - ) - : const _AuthRow(); + final eta = home != null ? ref.watch(homeEtaMinutesProvider) : null; + + if (!loggedIn) return const _AuthRow(); + if (home == null) return _SetHomeCta(onTap: () => _openPicker(context)); + return _GoHomeButton( + enabled: true, + etaMinutes: eta, + onTap: onNavigateHome, + onLongPress: () { + unawaited(HapticFeedback.mediumImpact()); + _openPicker(context); + }, + ); + } + + void _openPicker(BuildContext context) { + unawaited(Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + )); } } @@ -213,11 +226,13 @@ class _GoHomeButton extends StatelessWidget { required this.enabled, required this.etaMinutes, this.onTap, + this.onLongPress, }); final bool enabled; final AsyncValue? etaMinutes; final VoidCallback? onTap; + final VoidCallback? onLongPress; String? _subtitle() { if (!enabled) return null; @@ -245,6 +260,7 @@ class _GoHomeButton extends StatelessWidget { borderRadius: BorderRadius.circular(16), child: InkWell( onTap: onTap, + onLongPress: onLongPress, borderRadius: BorderRadius.circular(16), child: Padding( padding: @@ -296,6 +312,49 @@ class _GoHomeButton extends StatelessWidget { } } +class _SetHomeCta extends StatelessWidget { + const _SetHomeCta({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Material( + color: BbbColors.ink, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: Color.fromRGBO(255, 255, 255, 0.14), + shape: BoxShape.circle, + ), + child: const Icon(Icons.home_outlined, + size: 17, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.homeSheetSetHome, + style: BbbText.cardTitle().copyWith(color: Colors.white), + ), + ), + ], + ), + ), + ), + ); + } +} + class _EyebrowLabel extends StatelessWidget { const _EyebrowLabel(this.text); diff --git a/mobile/test/screens/map_screen_test.dart b/mobile/test/screens/map_screen_test.dart index fd9fdc0..3b8c059 100644 --- a/mobile/test/screens/map_screen_test.dart +++ b/mobile/test/screens/map_screen_test.dart @@ -135,7 +135,7 @@ void main() { expect(container.read(navigationSessionProvider), isTrue); }); - testWidgets('empty state shows disabled Go home button when logged in', + testWidgets('empty state shows Set home CTA when logged in but no home saved', (tester) async { final prefs = await SharedPreferences.getInstance(); @@ -144,10 +144,10 @@ void main() { ); await tester.pump(); - // Landing sheet always renders the Go home button; without a saved home - // the button is visually disabled and omits the ETA subtitle. - expect(find.text('Go home'), findsOneWidget); - expect(find.text('Set home to enable'), findsNothing); + // Without a saved home the sheet shows the Set home CTA, not a disabled + // Go home button. + expect(find.text('Set home'), findsOneWidget); + expect(find.text('Go home'), findsNothing); }); testWidgets('Go home subtitle shows calculating state while ETA resolves', diff --git a/mobile/test/widgets/home_sheet_test.dart b/mobile/test/widgets/home_sheet_test.dart index 71ebd23..f589b3c 100644 --- a/mobile/test/widgets/home_sheet_test.dart +++ b/mobile/test/widgets/home_sheet_test.dart @@ -4,8 +4,10 @@ import 'dart:convert'; import 'package:beebeebike/l10n/generated/app_localizations.dart'; import 'package:beebeebike/models/location.dart'; import 'package:beebeebike/providers/route_provider.dart'; +import 'package:beebeebike/screens/search_screen.dart'; import 'package:beebeebike/widgets/home_sheet.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -133,18 +135,55 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); }); - testWidgets('Go home button disabled when no home saved', (tester) async { + testWidgets('shows Set home CTA when logged in but no home saved', + (tester) async { final prefs = await _prefsWithHistory(const []); final controller = DraggableScrollableController(); addTearDown(controller.dispose); - await tester.pumpWidget(_host(prefs: prefs, controller: controller)); + await tester.pumpWidget(_host( + prefs: prefs, + controller: controller, + )); await tester.pump(); - expect(find.text('Go home'), findsOneWidget); - // Without a saved home there is no ETA subtitle. - expect(find.textContaining('min'), findsNothing); - expect(find.text('Calculating ETA…'), findsNothing); + expect(find.text('Set home'), findsOneWidget); + expect(find.text('Go home'), findsNothing); + }); + + testWidgets('long-press on Go home triggers haptic and navigates to picker', + (tester) async { + final prefs = await _prefsWithHistory(const []); + final controller = DraggableScrollableController(); + addTearDown(controller.dispose); + + final hapticCalls = []; + TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + await tester.pumpWidget(_host( + prefs: prefs, + controller: controller, + homeLocation: fakeHome(), + )); + await tester.pump(); + + await tester.longPress(find.text('Go home')); + await tester.pumpAndSettle(); + + expect(hapticCalls, isNotEmpty); + expect(find.byType(SearchScreen), findsOneWidget); + final search = tester.widget(find.byType(SearchScreen)); + expect(search.mode, SearchMode.pickHome); }); testWidgets('Log in button shown when not authenticated', (tester) async { From b075a865fb899d952f153104f97ffc220a341302 Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 13:51:39 +0200 Subject: [PATCH 08/11] refactor(mobile): drop dead disabled state on Go home button --- mobile/lib/widgets/home_sheet.dart | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/mobile/lib/widgets/home_sheet.dart b/mobile/lib/widgets/home_sheet.dart index 319ec08..3d8ba7f 100644 --- a/mobile/lib/widgets/home_sheet.dart +++ b/mobile/lib/widgets/home_sheet.dart @@ -112,7 +112,6 @@ class _GoHomeRow extends ConsumerWidget { if (!loggedIn) return const _AuthRow(); if (home == null) return _SetHomeCta(onTap: () => _openPicker(context)); return _GoHomeButton( - enabled: true, etaMinutes: eta, onTap: onNavigateHome, onLongPress: () { @@ -223,19 +222,16 @@ class _AuthButton extends StatelessWidget { class _GoHomeButton extends StatelessWidget { const _GoHomeButton({ - required this.enabled, required this.etaMinutes, this.onTap, this.onLongPress, }); - final bool enabled; final AsyncValue? etaMinutes; final VoidCallback? onTap; final VoidCallback? onLongPress; String? _subtitle() { - if (!enabled) return null; final eta = etaMinutes; if (eta == null) return null; return eta.when( @@ -249,11 +245,9 @@ class _GoHomeButton extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final subtitle = _subtitle(); - final bg = enabled ? BbbColors.ink : BbbColors.bgAlt; - final fg = enabled ? Colors.white : BbbColors.inkFaint; - final iconBg = enabled - ? const Color.fromRGBO(255, 255, 255, 0.14) - : BbbColors.divider; + const bg = BbbColors.ink; + const fg = Colors.white; + const iconBg = Color.fromRGBO(255, 255, 255, 0.14); return Material( color: bg, @@ -270,11 +264,11 @@ class _GoHomeButton extends StatelessWidget { Container( width: 32, height: 32, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: iconBg, shape: BoxShape.circle, ), - child: Icon( + child: const Icon( Icons.home_rounded, size: 17, color: fg, @@ -337,7 +331,7 @@ class _SetHomeCta extends StatelessWidget { color: Color.fromRGBO(255, 255, 255, 0.14), shape: BoxShape.circle, ), - child: const Icon(Icons.home_outlined, + child: const Icon(Icons.home_rounded, size: 17, color: Colors.white), ), const SizedBox(width: 12), From 3e894e6ba0e2c984fddf47bcf2cc6db7090719d0 Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 14:01:33 +0200 Subject: [PATCH 09/11] feat(mobile): success snackbar after set-home --- mobile/lib/screens/settings_screen.dart | 21 +++++++++++---- mobile/lib/widgets/home_sheet.dart | 17 ++++++++---- .../settings_screen_set_home_test.dart | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 6e2efa7..495a8e5 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -507,16 +507,27 @@ class _SetHomeRow extends StatelessWidget { final Location? home; + Future _onTap(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SearchScreen(mode: SearchMode.pickHome), + ), + ); + if (saved != null) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.homeSetSuccess(saved.label))), + ); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final hasHome = home != null; return InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SearchScreen(mode: SearchMode.pickHome), - ), - ), + onTap: () => _onTap(context), child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: Row( diff --git a/mobile/lib/widgets/home_sheet.dart b/mobile/lib/widgets/home_sheet.dart index 3d8ba7f..9b1d4cc 100644 --- a/mobile/lib/widgets/home_sheet.dart +++ b/mobile/lib/widgets/home_sheet.dart @@ -110,23 +110,30 @@ class _GoHomeRow extends ConsumerWidget { final eta = home != null ? ref.watch(homeEtaMinutesProvider) : null; if (!loggedIn) return const _AuthRow(); - if (home == null) return _SetHomeCta(onTap: () => _openPicker(context)); + if (home == null) return _SetHomeCta(onTap: () => unawaited(_openPicker(context))); return _GoHomeButton( etaMinutes: eta, onTap: onNavigateHome, onLongPress: () { unawaited(HapticFeedback.mediumImpact()); - _openPicker(context); + unawaited(_openPicker(context)); }, ); } - void _openPicker(BuildContext context) { - unawaited(Navigator.of(context).push( + Future _openPicker(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final l10n = AppLocalizations.of(context)!; + final saved = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => const SearchScreen(mode: SearchMode.pickHome), ), - )); + ); + if (saved != null) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.homeSetSuccess(saved.label))), + ); + } } } diff --git a/mobile/test/screens/settings_screen_set_home_test.dart b/mobile/test/screens/settings_screen_set_home_test.dart index 8a8f87c..a1b9882 100644 --- a/mobile/test/screens/settings_screen_set_home_test.dart +++ b/mobile/test/screens/settings_screen_set_home_test.dart @@ -1,5 +1,6 @@ import 'package:beebeebike/screens/search_screen.dart'; import 'package:beebeebike/screens/settings_screen.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -56,4 +57,29 @@ void main() { final search = tester.widget(find.byType(SearchScreen)); expect(search.mode, SearchMode.pickHome); }); + + testWidgets('shows success snackbar after picker returns a location', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(buildTestWidget( + const SettingsScreen(), + prefs: prefs, + authenticated: true, + )); + await tester.pump(); + + await tester.tap(find.text('Set home')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Home set to'), findsOneWidget); + }); } From 50b4cdcb7007ed9bf18f0544a3ec61aaea271c1a Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 14:20:24 +0200 Subject: [PATCH 10/11] test(mobile): home-sheet success snackbar coverage --- mobile/test/widgets/home_sheet_test.dart | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/mobile/test/widgets/home_sheet_test.dart b/mobile/test/widgets/home_sheet_test.dart index f589b3c..731a2df 100644 --- a/mobile/test/widgets/home_sheet_test.dart +++ b/mobile/test/widgets/home_sheet_test.dart @@ -218,4 +218,35 @@ void main() { expect(find.text('Go home'), findsOneWidget); expect(find.text('Calculating ETA…'), findsOneWidget); }); + + testWidgets('Set home CTA shows success snackbar after picker returns a location', + (tester) async { + final prefs = await _prefsWithHistory(const []); + final controller = DraggableScrollableController(); + addTearDown(controller.dispose); + + // No homeLocation — renders the "Set home" CTA, not the long-press path. + // authenticated defaults to true in _host; no homeLocation needed. + await tester.pumpWidget(_host( + prefs: prefs, + controller: controller, + )); + await tester.pump(); + + expect(find.text('Set home'), findsOneWidget); + + await tester.tap(find.text('Set home')); + await tester.pumpAndSettle(); + + // We're now inside SearchScreen (pickHome mode). Type a query, wait for + // the debounce, then tap the geocode result. + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Home set to'), findsOneWidget); + }); } From 24f7fb84f410bc4bc78534524dd7b362ab45fb12 Mon Sep 17 00:00:00 2001 From: cafca Date: Sun, 26 Apr 2026 14:39:44 +0200 Subject: [PATCH 11/11] feat(mobile): guard pickHome against double-tap save Add a `_saving` boolean flag to `_SearchScreenState` that short-circuits `_selectLocation` on re-entry while a save is in-flight, preventing a rapid double-tap from calling `homeLocationProvider.save()` twice. Reset `_saving` only on the error path; the success path pops the screen so no reset is needed. Includes a regression test with a slow-save stub. --- mobile/lib/screens/search_screen.dart | 4 ++ mobile/test/screens/search_screen_test.dart | 65 +++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/mobile/lib/screens/search_screen.dart b/mobile/lib/screens/search_screen.dart index 6e9173f..954700b 100644 --- a/mobile/lib/screens/search_screen.dart +++ b/mobile/lib/screens/search_screen.dart @@ -31,6 +31,7 @@ class _SearchScreenState extends ConsumerState { final List _results = []; Timer? _debounce; bool _loading = false; + bool _saving = false; @override void dispose() { @@ -65,12 +66,15 @@ class _SearchScreenState extends ConsumerState { Future _selectLocation(Location location) async { if (widget.mode == SearchMode.pickHome) { + if (_saving) return; final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); final l10n = AppLocalizations.of(context)!; + setState(() => _saving = true); try { await ref.read(homeLocationProvider.notifier).save(location); } on Object catch (_) { + if (mounted) setState(() => _saving = false); messenger.showSnackBar(SnackBar(content: Text(l10n.homeSetError))); return; } diff --git a/mobile/test/screens/search_screen_test.dart b/mobile/test/screens/search_screen_test.dart index 28409ab..fee7d92 100644 --- a/mobile/test/screens/search_screen_test.dart +++ b/mobile/test/screens/search_screen_test.dart @@ -21,6 +21,20 @@ class _ThrowingHomeController extends HomeLocationController { } } +class _CountingHomeController extends HomeLocationController { + int saveCount = 0; + + @override + Future build() async => null; + + @override + Future save(Location location) async { + await Future.delayed(const Duration(milliseconds: 100)); + saveCount++; + state = AsyncValue.data(location); + } +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -394,5 +408,56 @@ void main() { expect(find.text("Couldn't set home"), findsOneWidget); expect(find.byType(SearchScreen), findsOneWidget); }); + + testWidgets('rapid double-tap only calls save() once', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final controller = _CountingHomeController(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...testProviderOverrides(prefs: prefs, authenticated: true), + homeLocationProvider.overrideWith(() => controller), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { + unawaited(Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const SearchScreen(mode: SearchMode.pickHome), + ), + )); + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Alex'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + // Two rapid taps without settling in between. + await tester.tap(find.text('Alexanderplatz')); + await tester.tap(find.text('Alexanderplatz')); + await tester.pumpAndSettle(); + + expect(controller.saveCount, 1); + }); }); }