Skip to content
Draft
967 changes: 967 additions & 0 deletions docs/superpowers/plans/2026-04-26-mobile-set-home.md

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions docs/superpowers/specs/2026-04-26-mobile-set-home-design.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions mobile/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions mobile/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions mobile/lib/l10n/generated/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions mobile/lib/l10n/generated/app_localizations_de.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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?';

Expand Down
20 changes: 20 additions & 0 deletions mobile/lib/l10n/generated/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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?';

Expand Down
120 changes: 82 additions & 38 deletions mobile/lib/screens/search_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
final _geocodeApiProvider =
Provider<GeocodeApi>((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<SearchScreen> createState() => _SearchScreenState();
Expand All @@ -27,6 +31,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
final List<GeocodeResult> _results = [];
Timer? _debounce;
bool _loading = false;
bool _saving = false;

@override
void dispose() {
Expand Down Expand Up @@ -59,7 +64,23 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
}

void _selectLocation(Location location) {
Future<void> _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;
}
navigator.pop(location);
return;
}
Navigator.of(context).pop(location);
}

Expand All @@ -78,50 +99,73 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
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),
],
),
Expand Down
Loading
Loading