diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 738f046..4302946 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + UIStatusBarHidden + NSLocationWhenInUseUsageDescription + Used only to set your approximate sharing area — reduced to a coarse zone, never your exact location. diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index babc54a..7f0bf5b 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; +import 'services/coarse_location.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -31,6 +32,7 @@ class TaneApp extends StatelessWidget { required this.onboarding, this.social, this.socialSettings, + this.location, this.showIntro = false, super.key, }) : _router = _buildRouter( @@ -39,6 +41,7 @@ class TaneApp extends StatelessWidget { showIntro, social, socialSettings, + location, ); final VarietyRepository repository; @@ -49,6 +52,9 @@ class TaneApp extends StatelessWidget { /// null the market stays a "coming soon" card and `/market` is not routed. final SocialService? social; final SocialSettings? socialSettings; + + /// Optional device-location source for the market's "use my location". + final CoarseLocationProvider? location; final bool showIntro; final GoRouter _router; @@ -58,6 +64,7 @@ class TaneApp extends StatelessWidget { bool showIntro, SocialService? social, SocialSettings? socialSettings, + CoarseLocationProvider? location, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', @@ -70,8 +77,11 @@ class TaneApp extends StatelessWidget { if (social != null && socialSettings != null) GoRoute( path: '/market', - builder: (context, state) => - MarketScreen(social: social, settings: socialSettings), + builder: (context, state) => MarketScreen( + social: social, + settings: socialSettings, + location: location, + ), ), GoRoute( path: '/intro', diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 9110667..a2360d0 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -351,6 +351,8 @@ "wanted": "Wanted", "shareMine": "Share my seeds", "sharedCount": "Shared {n} seeds nearby", - "nothingToShare": "Mark some seeds to give, swap or sell first" + "nothingToShare": "Mark some seeds to give, swap or sell first", + "useLocation": "Use my approximate location", + "locationFailed": "Couldn't get your location" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5957e90..88c9b91 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -351,6 +351,8 @@ "wanted": "Busco", "shareMine": "Compartir mis semillas", "sharedCount": "Compartidas {n} semillas", - "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender" + "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", + "useLocation": "Usar mi ubicación aproximada", + "locationFailed": "No se pudo obtener tu ubicación" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 843b464..49fca66 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -347,6 +347,8 @@ "wanted": "Procuro", "shareMine": "Partilhar as minhas sementes", "sharedCount": "Partilhadas {n} sementes", - "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender" + "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender", + "useLocation": "Usar a minha localização aproximada", + "locationFailed": "Não foi possível obter a tua localização" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 5157afc..8158e2a 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 938 (312 per locale) +/// Strings: 944 (314 per locale) /// -/// Built on 2026-07-10 at 08:17 UTC +/// Built on 2026-07-10 at 08:25 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 55b508a..2b7f1b2 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1114,6 +1114,12 @@ class Translations$market$en { /// en: 'Mark some seeds to give, swap or sell first' String get nothingToShare => 'Mark some seeds to give, swap or sell first'; + + /// en: 'Use my approximate location' + String get useLocation => 'Use my approximate location'; + + /// en: 'Couldn't get your location' + String get locationFailed => 'Couldn\'t get your location'; } // Path: intro.slides @@ -1887,6 +1893,8 @@ extension on Translations { 'market.shareMine' => 'Share my seeds', 'market.sharedCount' => ({required Object n}) => 'Shared ${n} seeds nearby', 'market.nothingToShare' => 'Mark some seeds to give, swap or sell first', + 'market.useLocation' => 'Use my approximate location', + 'market.locationFailed' => 'Couldn\'t get your location', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index fe5c80a..746cab2 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -622,6 +622,8 @@ class _Translations$market$es extends Translations$market$en { @override String get shareMine => 'Compartir mis semillas'; @override String sharedCount({required Object n}) => 'Compartidas ${n} semillas'; @override String get nothingToShare => 'Marca antes algunas semillas para regalar, cambiar o vender'; + @override String get useLocation => 'Usar mi ubicación aproximada'; + @override String get locationFailed => 'No se pudo obtener tu ubicación'; } // Path: intro.slides @@ -1279,6 +1281,8 @@ extension on TranslationsEs { 'market.shareMine' => 'Compartir mis semillas', 'market.sharedCount' => ({required Object n}) => 'Compartidas ${n} semillas', 'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender', + 'market.useLocation' => 'Usar mi ubicación aproximada', + 'market.locationFailed' => 'No se pudo obtener tu ubicación', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 6f649e5..877939a 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -618,6 +618,8 @@ class _Translations$market$pt extends Translations$market$en { @override String get shareMine => 'Partilhar as minhas sementes'; @override String sharedCount({required Object n}) => 'Partilhadas ${n} sementes'; @override String get nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender'; + @override String get useLocation => 'Usar a minha localização aproximada'; + @override String get locationFailed => 'Não foi possível obter a tua localização'; } // Path: intro.slides @@ -1271,6 +1273,8 @@ extension on TranslationsPt { 'market.shareMine' => 'Partilhar as minhas sementes', 'market.sharedCount' => ({required Object n}) => 'Partilhadas ${n} sementes', 'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender', + 'market.useLocation' => 'Usar a minha localização aproximada', + 'market.locationFailed' => 'Não foi possível obter a tua localização', _ => null, }; } diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index a16db49..5dfe497 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -5,6 +5,7 @@ import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; +import 'services/coarse_location.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -22,6 +23,7 @@ Future main() async { onboarding: onboarding, social: getIt(), socialSettings: getIt(), + location: const GeolocatorCoarseLocation(), showIntro: !await onboarding.introSeen(), ), ), diff --git a/apps/app_seeds/lib/services/coarse_location.dart b/apps/app_seeds/lib/services/coarse_location.dart new file mode 100644 index 0000000..cd7a50b --- /dev/null +++ b/apps/app_seeds/lib/services/coarse_location.dart @@ -0,0 +1,38 @@ +import 'package:geolocator/geolocator.dart'; + +/// Supplies the device's *approximate* location (or null when unavailable or +/// denied). Behind an interface so the UI is fakeable in tests and the concrete +/// platform plugin stays at the composition root. The caller immediately reduces +/// the result to a low-precision geohash — a precise fix never leaves the device. +abstract interface class CoarseLocationProvider { + Future<({double lat, double lon})?> currentCoarseLatLon(); +} + +/// [geolocator]-backed implementation. Requests low accuracy only, and returns +/// null on any denial/error rather than throwing, so the UI can degrade quietly. +class GeolocatorCoarseLocation implements CoarseLocationProvider { + const GeolocatorCoarseLocation(); + + @override + Future<({double lat, double lon})?> currentCoarseLatLon() async { + try { + if (!await Geolocator.isLocationServiceEnabled()) return null; + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return null; + } + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.low, + ), + ); + return (lat: position.latitude, lon: position.longitude); + } catch (_) { + return null; // service off, plugin unsupported (desktop), timeout, … + } + } +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 165180c..395ba1d 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; +import '../services/coarse_location.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.dart'; @@ -13,11 +14,20 @@ import 'theme.dart'; /// an offer transport lazily; local-first, so it degrades to a "set up sharing" /// prompt when no relay/area is configured or the network is unreachable. class MarketScreen extends StatefulWidget { - const MarketScreen({required this.social, required this.settings, super.key}); + const MarketScreen({ + required this.social, + required this.settings, + this.location, + super.key, + }); final SocialService social; final SocialSettings settings; + /// Optional device-location source for the "use my location" shortcut; when + /// null (tests, or a platform without it) the shortcut is hidden. + final CoarseLocationProvider? location; + @override State createState() => _MarketScreenState(); } @@ -53,7 +63,8 @@ class _MarketScreenState extends State { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => _ConfigSheet(settings: widget.settings), + builder: (_) => + _ConfigSheet(settings: widget.settings, location: widget.location), ); if (changed == true) await _init(); } @@ -325,9 +336,10 @@ class _EmptyState extends StatelessWidget { /// Coarse-area + community-server setup. Kept behind progressive disclosure — a /// power-user surface — with human-worded labels. class _ConfigSheet extends StatefulWidget { - const _ConfigSheet({required this.settings}); + const _ConfigSheet({required this.settings, this.location}); final SocialSettings settings; + final CoarseLocationProvider? location; @override State<_ConfigSheet> createState() => _ConfigSheetState(); @@ -356,6 +368,22 @@ class _ConfigSheetState extends State<_ConfigSheet> { if (mounted) Navigator.of(context).pop(true); } + /// Fills the area from the device's approximate location, reduced to a coarse + /// geohash so no precise fix is stored. + Future _useLocation() async { + final provider = widget.location; + if (provider == null) return; + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + final loc = await provider.currentCoarseLatLon(); + if (!mounted) return; + if (loc == null) { + messenger.showSnackBar(SnackBar(content: Text(t.market.locationFailed))); + return; + } + setState(() => _area.text = Geohash.encode(loc.lat, loc.lon, precision: 5)); + } + @override void dispose() { _area.dispose(); @@ -402,6 +430,16 @@ class _ConfigSheetState extends State<_ConfigSheet> { helperMaxLines: 3, ), ), + if (widget.location != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('market.useLocation'), + icon: const Icon(Icons.my_location, size: 18), + label: Text(t.market.useLocation), + onPressed: _useLocation, + ), + ), const SizedBox(height: 8), // The technical web address lives under progressive disclosure — // most people won't have one, and jargon shouldn't greet them. diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e722c3b..6769c37 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -62,6 +62,10 @@ dependencies: material_symbols_icons: ^4.2951.0 url_launcher: ^6.3.2 package_info_plus: ^9.0.1 + # Optional coarse device location (BSD-3) to set the sharing area without + # typing a code. Low accuracy only; the value is immediately reduced to a + # low-precision geohash. Behind an interface so it's fakeable and pluggable. + geolocator: ^13.0.1 dev_dependencies: flutter_test: diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart index d94a1f7..8a6c18a 100644 --- a/apps/app_seeds/test/ui/market_screen_test.dart +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/coarse_location.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/state/offers_cubit.dart'; @@ -13,6 +14,16 @@ import 'package:tane/ui/market_screen.dart'; import '../support/test_support.dart'; +/// A location provider returning a fixed coarse position. +class FakeLocation implements CoarseLocationProvider { + FakeLocation(this.lat, this.lon); + final double lat; + final double lon; + @override + Future<({double lat, double lon})?> currentCoarseLatLon() async => + (lat: lat, lon: lon); +} + /// In-memory offer transport: discover matches by geohash prefix. class FakeOfferTransport implements OfferTransport { final List offers; @@ -120,4 +131,40 @@ void main() { expect(find.text('Seeds near you'), findsOneWidget); // app bar title expect(find.text('Set up sharing'), findsOneWidget); // offline prompt }); + + testWidgets('"use my location" fills the area with a coarse geohash', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + LocaleSettings.setLocaleSync(AppLocale.en); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: MarketScreen( + social: social, + settings: settings, + location: FakeLocation(41.39, 2.16), // Barcelona-ish + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.useLocation'))); + await tester.pumpAndSettle(); + + final area = tester.widget(find.byKey(const Key('market.area'))); + expect(area.controller!.text, hasLength(5)); + expect(area.controller!.text, matches(RegExp(r'^[0-9a-z]+$'))); + }); }