import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/region/geo.dart'; import '../../core/region/region_config.dart'; import '../../core/region/region_repository.dart'; import 'places_repository.dart'; import 'saved_place.dart'; final placesStoreProvider = Provider( (ref) => const SharedPreferencesPlacesStore(), ); /// The user's saved places, newest first. class SavedPlaces extends AsyncNotifier> { /// Beyond this the list stops being a shortcut and becomes a directory to /// search through, which is not what it is for. static const int maxPlaces = 20; @override Future> build() => ref.watch(placesStoreProvider).load(); /// Adds a place at [point] called [name]. /// /// Throws [SavedPlaceException] naming the rule that was broken, so the UI can /// explain the refusal rather than swallowing it. Future add({ required String name, required GeoPoint point, }) async { final trimmed = name.trim(); _validateName(trimmed); await _validatePoint(point); final current = List.of(state.value ?? const []); if (current.any((p) => p.name.toLowerCase() == trimmed.toLowerCase())) { throw const SavedPlaceException(SavedPlaceRejection.duplicateName); } final place = SavedPlace( id: _newId(current), name: trimmed, point: point, createdAt: DateTime.now().toUtc(), ); // Newest first: the place just added is the one most likely to be tapped. final updated = [place, ...current]; if (updated.length > maxPlaces) { updated.removeRange(maxPlaces, updated.length); } await _persist(updated); return place; } Future rename(String id, String name) async { final trimmed = name.trim(); _validateName(trimmed); final current = List.of(state.value ?? const []); if (current.any( (p) => p.id != id && p.name.toLowerCase() == trimmed.toLowerCase(), )) { throw const SavedPlaceException(SavedPlaceRejection.duplicateName); } await _persist([ for (final place in current) if (place.id == id) place.copyWith(name: trimmed) else place, ]); } Future remove(String id) async { final current = state.value ?? const []; await _persist( current .where((SavedPlace place) => place.id != id) .toList(growable: false), ); } Future clear() => _persist(const []); Future _persist(List places) async { await ref.read(placesStoreProvider).save(places); state = AsyncData>(List.unmodifiable(places)); } /// An id no existing place is using. /// /// The clock alone is not enough: two places saved in the same microsecond /// get the same id, and everything that addresses a place by id — rename, /// remove, the duplicate-name check — then acts on the wrong one or on both. /// A test caught exactly that on a fast machine. String _newId(List existing) { final taken = existing.map((place) => place.id).toSet(); final base = 'p${DateTime.now().microsecondsSinceEpoch}'; if (!taken.contains(base)) return base; var suffix = 1; while (taken.contains('${base}_$suffix')) { suffix++; } return '${base}_$suffix'; } void _validateName(String name) { if (name.isEmpty) { throw const SavedPlaceException(SavedPlaceRejection.emptyName); } if (name.length > SavedPlace.maxNameLength) { throw const SavedPlaceException(SavedPlaceRejection.nameTooLong); } } /// Refuses points the app has no data for. /// /// Saving a place in Rome would look like it worked and then show nothing /// forever. Better to say so at the moment of saving, while the user still /// knows what they were trying to do. Future _validatePoint(GeoPoint point) async { final region = await ref.read(regionConfigProvider.future); if (!region.bounds.contains(point)) { throw const SavedPlaceException(SavedPlaceRejection.outsideRegion); } } } final savedPlacesProvider = AsyncNotifierProvider>(SavedPlaces.new); /// The place the map is currently centred on, or null for the region default. /// /// Session state on purpose: which place you last looked at is not worth /// persisting, and restoring it on launch would surprise someone who opened the /// app to see the weather where they are now. class ActivePlace extends Notifier { @override SavedPlace? build() => null; void select(SavedPlace? place) => state = place; } final activePlaceProvider = NotifierProvider( ActivePlace.new, ); /// Returns [places] ordered by distance from [point], nearest first. List byDistanceFrom(GeoPoint point, List places) { final sorted = List.of(places); sorted.sort( (a, b) => point.distanceTo(a.point).compareTo(point.distanceTo(b.point)), ); return sorted; } /// Convenience for the region default when no place is active. GeoPoint defaultCentreOf(RegionConfig region) => region.map.center;