Add municipality and coordinate search, and a map target selector
Puts the controls that change what the map is looking at over the map itself, where they act, and gives them something to search. Search is one field, not two. A string either parses as coordinates or it does not, and the answer is obvious from the text, so making the user declare up front which kind of thing they are looking for would be asking them to do the program's job. Coordinates accept decimal and degrees-minutes-seconds, comma or space separated, with hemisphere letters — including the Italian O for ovest, because someone reading an Italian map will type it and silently reading it as east would put them the wrong side of Greenwich. The 1180 Piedmont municipalities are bundled rather than geocoded online. The app is region-scoped, so a list of one region's towns is small enough to ship (100 KB) and beats a geocoder on every axis that matters: instant, offline, no API key, no rate limit, and it cannot return a result somewhere the app has no radar for. Matching folds accents, so "aglie" finds "Agliè", and prefix matches outrank substring ones — typing "tor" should surface Torino, not the first alphabetical name that happens to contain those letters. tool/generate_places.py derives the list from Istat boundary shapefiles (CC BY 4.0). Two properties of that file cost time and are now written down: the geometry is UTM 32N rather than degrees, and the DBF is UTF-8 despite one bilingual Friulian record that makes strict cp1252 fail. A terminal renders utf-8 and latin-1 output identically, so the encoding cannot be settled by looking at printed text — it took dumping codepoints. A guard in the generator and a test against the shipped asset both check for mojibake now, and the guard caught a real mistake the moment it was written. The target selector switches between following the device, a saved place, and the whole region, and the selection doubles as what the app reopens on: "the place I marked" and "what I see when I open the app" are one idea to the person using it. Dragging the map while it is following stops the camera chasing them but leaves that preference alone, because looking somewhere else now is not the same as changing their mind about next launch. The position marker and the following are MapLibre's own, driven by onCameraTrackingDismissed, so there is no second location stream to keep in step with the map. A test asserts that all 1180 municipalities fall inside the region bounds, which is what actually validates the UTM-to-degrees conversion end to end. Verified on the emulator: the dropdown lists follow, region and both saved places; "aglie" finds Agliè; "44.3841 7.5426" offers the coordinate jump and lands on Cuneo at town-reading zoom; selecting follow moves the map to the device position with the blue dot on it and changes the recentre button to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/region/geo.dart';
|
||||
|
||||
/// A municipality the user can search for.
|
||||
///
|
||||
/// Bundled rather than geocoded online: the app only has radar for one region,
|
||||
/// so a list of its 1180 municipalities is small enough to ship, searches
|
||||
/// instantly, works with no network, needs no API key, and cannot return a
|
||||
/// result somewhere the app has nothing to show.
|
||||
class Comune {
|
||||
Comune({
|
||||
required this.name,
|
||||
required this.province,
|
||||
required this.istatCode,
|
||||
required this.point,
|
||||
});
|
||||
|
||||
factory Comune.fromJson(Map<String, Object?> json) {
|
||||
final name = json['name'];
|
||||
final province = json['province'];
|
||||
final istat = json['istat'];
|
||||
final latitude = json['lat'];
|
||||
final longitude = json['lng'];
|
||||
|
||||
if (name is! String || name.isEmpty) {
|
||||
throw const FormatException('comune name must be a non-empty string');
|
||||
}
|
||||
if (province is! String || province.isEmpty) {
|
||||
throw const FormatException('comune province must be a non-empty string');
|
||||
}
|
||||
if (istat is! String || istat.isEmpty) {
|
||||
throw const FormatException(
|
||||
'comune istat code must be a non-empty string',
|
||||
);
|
||||
}
|
||||
if (latitude is! num || longitude is! num) {
|
||||
throw const FormatException('comune coordinates must be numbers');
|
||||
}
|
||||
|
||||
return Comune(
|
||||
name: name,
|
||||
province: province,
|
||||
istatCode: istat,
|
||||
point: GeoPoint(longitude.toDouble(), latitude.toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
final String name;
|
||||
|
||||
/// Two-letter province abbreviation, for example `TO`.
|
||||
final String province;
|
||||
|
||||
/// Istat municipality code, stable across renames.
|
||||
final String istatCode;
|
||||
|
||||
/// A representative point inside the municipality — the centroid of its
|
||||
/// largest part, so it is a few kilometres from the town centre at worst.
|
||||
final GeoPoint point;
|
||||
|
||||
/// How it reads in a list: `Agliè (TO)`.
|
||||
String get label => '$name ($province)';
|
||||
|
||||
/// Lower-case and stripped of accents, for matching.
|
||||
///
|
||||
/// Computed on first use rather than per keystroke: 1180 entries times a
|
||||
/// character-by-character fold on every letter typed is work worth doing
|
||||
/// exactly once. This is why the class is not const.
|
||||
late final String searchKey = foldForSearch(name);
|
||||
|
||||
@override
|
||||
String toString() => 'Comune($name, $province)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Comune && other.istatCode == istatCode;
|
||||
|
||||
@override
|
||||
int get hashCode => istatCode.hashCode;
|
||||
}
|
||||
|
||||
/// Lower-cases and removes the accents Italian place names use.
|
||||
///
|
||||
/// Nobody types "Agliè" with the accent when searching, and several names differ
|
||||
/// only by one. Dart has no Unicode normalisation in the core library, and the
|
||||
/// full table would be overkill: Italian municipality names use exactly these.
|
||||
String foldForSearch(String value) {
|
||||
const folded = <String, String>{
|
||||
'à': 'a',
|
||||
'á': 'a',
|
||||
'â': 'a',
|
||||
'ä': 'a',
|
||||
'ã': 'a',
|
||||
'å': 'a',
|
||||
'è': 'e',
|
||||
'é': 'e',
|
||||
'ê': 'e',
|
||||
'ë': 'e',
|
||||
'ì': 'i',
|
||||
'í': 'i',
|
||||
'î': 'i',
|
||||
'ï': 'i',
|
||||
'ò': 'o',
|
||||
'ó': 'o',
|
||||
'ô': 'o',
|
||||
'ö': 'o',
|
||||
'õ': 'o',
|
||||
'ù': 'u',
|
||||
'ú': 'u',
|
||||
'û': 'u',
|
||||
'ü': 'u',
|
||||
'ç': 'c',
|
||||
'ñ': 'n',
|
||||
};
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in value.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
buffer.write(folded[char] ?? char);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// The bundled municipality list, with its provenance.
|
||||
class ComuneIndex {
|
||||
ComuneIndex({
|
||||
required this.comuni,
|
||||
required this.source,
|
||||
required this.license,
|
||||
});
|
||||
|
||||
factory ComuneIndex.parse(String raw) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('comuni index must be a JSON object');
|
||||
}
|
||||
|
||||
final places = decoded['places'];
|
||||
if (places is! List || places.isEmpty) {
|
||||
throw const FormatException('comuni index must list places');
|
||||
}
|
||||
|
||||
final source = decoded['source'];
|
||||
final license = decoded['license'];
|
||||
if (source is! String || source.isEmpty) {
|
||||
throw const FormatException(
|
||||
'comuni index must name its source: the list is redistributed data and '
|
||||
'the credit travels with it',
|
||||
);
|
||||
}
|
||||
|
||||
return ComuneIndex(
|
||||
comuni: places
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(Comune.fromJson)
|
||||
.toList(growable: false),
|
||||
source: source,
|
||||
license: license is String && license.isNotEmpty ? license : null,
|
||||
);
|
||||
}
|
||||
|
||||
final List<Comune> comuni;
|
||||
|
||||
/// Where the list came from, shown on the Sources screen.
|
||||
final String source;
|
||||
|
||||
final String? license;
|
||||
|
||||
/// How many results a search returns at most.
|
||||
///
|
||||
/// A list longer than this is not a search result, it is a phone book: the
|
||||
/// user should type another letter instead of scrolling.
|
||||
static const int maxResults = 30;
|
||||
|
||||
/// Municipalities matching [query], best first.
|
||||
///
|
||||
/// Ranking is deliberately simple and predictable: names that *start* with
|
||||
/// the query come before names that merely contain it, and ties break
|
||||
/// alphabetically. Someone typing "tor" wants Torino before Cavallermaggiore.
|
||||
List<Comune> search(String query) {
|
||||
final needle = foldForSearch(query.trim());
|
||||
if (needle.isEmpty) return const <Comune>[];
|
||||
|
||||
final prefix = <Comune>[];
|
||||
final contains = <Comune>[];
|
||||
|
||||
for (final comune in comuni) {
|
||||
final key = comune.searchKey;
|
||||
if (key.startsWith(needle)) {
|
||||
prefix.add(comune);
|
||||
} else if (key.contains(needle)) {
|
||||
contains.add(comune);
|
||||
}
|
||||
}
|
||||
|
||||
int byName(Comune a, Comune b) => a.searchKey.compareTo(b.searchKey);
|
||||
prefix.sort(byName);
|
||||
contains.sort(byName);
|
||||
|
||||
return <Comune>[
|
||||
...prefix,
|
||||
...contains,
|
||||
].take(maxResults).toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/region/region_repository.dart';
|
||||
import 'comune.dart';
|
||||
|
||||
/// Loads the bundled municipality list.
|
||||
class ComuniRepository {
|
||||
const ComuniRepository({this.bundle});
|
||||
|
||||
/// Overridden in tests; null means the real asset bundle.
|
||||
final AssetBundle? bundle;
|
||||
|
||||
AssetBundle get _assets => bundle ?? rootBundle;
|
||||
|
||||
static String assetPathFor(String regionId) =>
|
||||
'assets/regions/${regionId}_comuni.json';
|
||||
|
||||
Future<ComuneIndex> load(String regionId) async {
|
||||
final source = await _assets.loadString(assetPathFor(regionId));
|
||||
return ComuneIndex.parse(source);
|
||||
}
|
||||
}
|
||||
|
||||
final comuniRepositoryProvider = Provider<ComuniRepository>(
|
||||
(ref) => const ComuniRepository(),
|
||||
);
|
||||
|
||||
/// The searchable municipality index for the active region.
|
||||
///
|
||||
/// Loaded once and kept: 1180 entries with a precomputed fold key is a
|
||||
/// negligible amount of memory next to a single radar frame, and rebuilding it
|
||||
/// per search would make typing feel slow for no reason.
|
||||
final comuneIndexProvider = FutureProvider<ComuneIndex>((ref) async {
|
||||
final regionId = ref.watch(regionIdProvider);
|
||||
return ref.watch(comuniRepositoryProvider).load(regionId);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import '../../core/region/geo.dart';
|
||||
|
||||
/// Parses coordinates typed or pasted by the user.
|
||||
///
|
||||
/// People paste from wherever they found them, so this accepts the shapes that
|
||||
/// actually turn up rather than one canonical form: decimal degrees separated
|
||||
/// by a comma, a semicolon or spaces, with or without hemisphere letters, and
|
||||
/// degrees-minutes-seconds.
|
||||
///
|
||||
/// Latitude always comes first, matching every consumer-facing map. Returns
|
||||
/// null when the text is not coordinates at all — that is the normal case while
|
||||
/// someone is typing a town name into the same field.
|
||||
GeoPoint? parseCoordinates(String input) {
|
||||
final text = input.trim();
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
final dms = _parseDms(text);
|
||||
if (dms != null) return dms;
|
||||
|
||||
return _parseDecimal(text);
|
||||
}
|
||||
|
||||
final RegExp _decimalPair = RegExp(
|
||||
r'^\s*([NS])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([NS])?'
|
||||
r'\s*[,;\s]\s*'
|
||||
r'([EWO])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([EWO])?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
GeoPoint? _parseDecimal(String text) {
|
||||
final match = _decimalPair.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final latitude = _applyHemisphere(
|
||||
_number(match.group(2)),
|
||||
match.group(1) ?? match.group(3),
|
||||
negative: 'S',
|
||||
);
|
||||
final longitude = _applyHemisphere(
|
||||
_number(match.group(5)),
|
||||
match.group(4) ?? match.group(6),
|
||||
negative: 'W',
|
||||
);
|
||||
if (latitude == null || longitude == null) return null;
|
||||
|
||||
return _validated(latitude, longitude);
|
||||
}
|
||||
|
||||
/// Degrees, minutes and optional seconds, for both halves of a pair.
|
||||
///
|
||||
/// The seconds mark can be a double-prime, two apostrophes, a plain quote or
|
||||
/// an `s`, because those are all shapes people paste. Built by string
|
||||
/// concatenation rather than one long literal: a raw string cannot contain the
|
||||
/// double quote this pattern needs, and a non-raw one would turn every
|
||||
/// backslash in the pattern into an escape.
|
||||
final RegExp _dmsPair = RegExp(
|
||||
'^\\s*$_dmsPart([NS])\\s*[,;\\s]\\s*$_dmsPart([EWO])\\s*\$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
const String _dmsPart =
|
||||
r'(\d+)\s*[°d]\s*'
|
||||
r'(\d+(?:[.,]\d+)?)\s*'
|
||||
"['m′]?\\s*"
|
||||
r'(?:(\d+(?:[.,]\d+)?)\s*'
|
||||
"[″\"']*s?\\s*)?";
|
||||
|
||||
GeoPoint? _parseDms(String text) {
|
||||
final match = _dmsPair.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final latitude = _fromDms(
|
||||
match.group(1),
|
||||
match.group(2),
|
||||
match.group(3),
|
||||
match.group(4),
|
||||
negative: 'S',
|
||||
);
|
||||
final longitude = _fromDms(
|
||||
match.group(5),
|
||||
match.group(6),
|
||||
match.group(7),
|
||||
match.group(8),
|
||||
negative: 'W',
|
||||
);
|
||||
if (latitude == null || longitude == null) return null;
|
||||
|
||||
return _validated(latitude, longitude);
|
||||
}
|
||||
|
||||
double? _fromDms(
|
||||
String? degrees,
|
||||
String? minutes,
|
||||
String? seconds,
|
||||
String? hemisphere, {
|
||||
required String negative,
|
||||
}) {
|
||||
final d = _number(degrees);
|
||||
final m = _number(minutes) ?? 0;
|
||||
final s = _number(seconds) ?? 0;
|
||||
if (d == null) return null;
|
||||
if (m >= 60 || s >= 60) return null;
|
||||
|
||||
final value = d + m / 60 + s / 3600;
|
||||
return _applyHemisphere(value, hemisphere, negative: negative);
|
||||
}
|
||||
|
||||
double? _applyHemisphere(
|
||||
double? value,
|
||||
String? hemisphere, {
|
||||
required String negative,
|
||||
}) {
|
||||
if (value == null) return null;
|
||||
if (hemisphere == null) return value;
|
||||
|
||||
final letter = hemisphere.toUpperCase();
|
||||
// "O" is Italian for west — someone reading coordinates off an Italian map
|
||||
// will type it, and silently treating it as east would put them the wrong
|
||||
// side of Greenwich.
|
||||
final isNegative = letter == negative || (negative == 'W' && letter == 'O');
|
||||
final magnitude = value.abs();
|
||||
return isNegative ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
/// Accepts both the decimal point and the comma Italian keyboards produce.
|
||||
double? _number(String? raw) =>
|
||||
raw == null ? null : double.tryParse(raw.replaceAll(',', '.'));
|
||||
|
||||
GeoPoint? _validated(double latitude, double longitude) {
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
return GeoPoint(longitude, latitude);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'saved_place.dart';
|
||||
import 'saved_places.dart';
|
||||
|
||||
/// What the map is pointed at.
|
||||
sealed class MapTarget {
|
||||
const MapTarget();
|
||||
|
||||
/// The value persisted as the preferred target, or null if it is not a
|
||||
/// sensible thing to reopen on.
|
||||
String? get storageValue;
|
||||
}
|
||||
|
||||
/// Follow the device: the blue dot stays centred until the user pans away.
|
||||
class FollowUser extends MapTarget {
|
||||
const FollowUser();
|
||||
|
||||
@override
|
||||
String? get storageValue => 'follow';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is FollowUser;
|
||||
|
||||
@override
|
||||
int get hashCode => 'follow'.hashCode;
|
||||
}
|
||||
|
||||
/// Sit on a saved place.
|
||||
class PlaceTarget extends MapTarget {
|
||||
const PlaceTarget(this.place);
|
||||
|
||||
final SavedPlace place;
|
||||
|
||||
@override
|
||||
String? get storageValue => 'place:${place.id}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaceTarget && other.place.id == place.id;
|
||||
|
||||
@override
|
||||
int get hashCode => place.id.hashCode;
|
||||
}
|
||||
|
||||
/// The whole region, and wherever the user has panned to.
|
||||
///
|
||||
/// Also what following degrades to the moment the user drags the map: the
|
||||
/// camera stops chasing them, which is the behaviour every map app has trained
|
||||
/// people to expect.
|
||||
class FreeTarget extends MapTarget {
|
||||
const FreeTarget();
|
||||
|
||||
@override
|
||||
String? get storageValue => null;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is FreeTarget;
|
||||
|
||||
@override
|
||||
int get hashCode => 'free'.hashCode;
|
||||
}
|
||||
|
||||
/// Remembers what the app should open on.
|
||||
abstract interface class PreferredTargetStore {
|
||||
Future<String?> load();
|
||||
|
||||
Future<void> save(String? value);
|
||||
}
|
||||
|
||||
class SharedPreferencesPreferredTargetStore implements PreferredTargetStore {
|
||||
const SharedPreferencesPreferredTargetStore();
|
||||
|
||||
static const String storageKey = 'nuvolari.preferred_target.v1';
|
||||
|
||||
@override
|
||||
Future<String?> load() async =>
|
||||
(await SharedPreferences.getInstance()).getString(storageKey);
|
||||
|
||||
@override
|
||||
Future<void> save(String? value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (value == null) {
|
||||
await prefs.remove(storageKey);
|
||||
} else {
|
||||
await prefs.setString(storageKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryPreferredTargetStore implements PreferredTargetStore {
|
||||
InMemoryPreferredTargetStore([this._value]);
|
||||
|
||||
String? _value;
|
||||
|
||||
@override
|
||||
Future<String?> load() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> save(String? value) async => _value = value;
|
||||
}
|
||||
|
||||
final preferredTargetStoreProvider = Provider<PreferredTargetStore>(
|
||||
(ref) => const SharedPreferencesPreferredTargetStore(),
|
||||
);
|
||||
|
||||
/// The map's current target, and the one it will reopen on.
|
||||
///
|
||||
/// One notion rather than two, because "the place I marked as preferred" and
|
||||
/// "what the app shows me when I open it" are the same thing to the person
|
||||
/// using it. Selecting from the dropdown sets both.
|
||||
class MapTargetController extends Notifier<MapTarget> {
|
||||
@override
|
||||
MapTarget build() {
|
||||
// Places load asynchronously, so the preferred target is resolved once they
|
||||
// arrive rather than guessed now.
|
||||
ref.listen<AsyncValue<List<SavedPlace>>>(savedPlacesProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (previous?.hasValue ?? false) return;
|
||||
final places = next.value;
|
||||
if (places != null) unawaited(_restorePreferred(places));
|
||||
}, fireImmediately: true);
|
||||
|
||||
return const FreeTarget();
|
||||
}
|
||||
|
||||
bool _restored = false;
|
||||
|
||||
Future<void> _restorePreferred(List<SavedPlace> places) async {
|
||||
if (_restored) return;
|
||||
_restored = true;
|
||||
|
||||
final stored = await ref.read(preferredTargetStoreProvider).load();
|
||||
if (stored == null) return;
|
||||
|
||||
if (stored == 'follow') {
|
||||
state = const FollowUser();
|
||||
return;
|
||||
}
|
||||
if (stored.startsWith('place:')) {
|
||||
final id = stored.substring('place:'.length);
|
||||
for (final place in places) {
|
||||
if (place.id == id) {
|
||||
state = PlaceTarget(place);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The preferred place was deleted. Forget it rather than reopening on
|
||||
// something that no longer exists.
|
||||
await ref.read(preferredTargetStoreProvider).save(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects [target] and remembers it as what to reopen on.
|
||||
Future<void> select(MapTarget target) async {
|
||||
state = target;
|
||||
await ref.read(preferredTargetStoreProvider).save(target.storageValue);
|
||||
}
|
||||
|
||||
/// Drops out of following without changing what the app reopens on.
|
||||
///
|
||||
/// Called when the user drags the map while it is chasing them: they want to
|
||||
/// look somewhere else *now*, which is not the same as changing their mind
|
||||
/// about what the app should do next launch.
|
||||
void releaseFollow() {
|
||||
if (state is FollowUser) state = const FreeTarget();
|
||||
}
|
||||
|
||||
/// Forgets a place that has been deleted, if it was the target.
|
||||
Future<void> forget(String placeId) async {
|
||||
final current = state;
|
||||
if (current is PlaceTarget && current.place.id == placeId) {
|
||||
state = const FreeTarget();
|
||||
await ref.read(preferredTargetStoreProvider).save(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final mapTargetProvider = NotifierProvider<MapTargetController, MapTarget>(
|
||||
MapTargetController.new,
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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 '../../l10n/app_localizations.dart';
|
||||
import 'comune.dart';
|
||||
import 'comuni_repository.dart';
|
||||
import 'coordinate_input.dart';
|
||||
|
||||
/// What the user picked out of search.
|
||||
class SearchResult {
|
||||
const SearchResult({required this.point, required this.label});
|
||||
|
||||
final GeoPoint point;
|
||||
|
||||
/// How to name it if it gets saved as a place.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// One field that takes either a municipality name or coordinates.
|
||||
///
|
||||
/// Two separate searches would make the user decide up front which kind of
|
||||
/// thing they are looking for, which they should not have to: a string either
|
||||
/// parses as coordinates or it does not, and the answer is obvious from the
|
||||
/// text itself.
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final index = ref.watch(comuneIndexProvider);
|
||||
final region = ref.watch(regionConfigProvider).value;
|
||||
|
||||
final coordinates = parseCoordinates(_query);
|
||||
final matches = switch (index) {
|
||||
AsyncData(:final value) => value.search(_query),
|
||||
_ => const <Comune>[],
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchHint,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
actions: [
|
||||
if (_query.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: switch (index) {
|
||||
AsyncError(:final error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${l10n.dataUnavailable}\n$error'),
|
||||
),
|
||||
),
|
||||
AsyncData() => _results(
|
||||
context,
|
||||
coordinates: coordinates,
|
||||
matches: matches,
|
||||
region: region,
|
||||
),
|
||||
_ => Center(child: Text(l10n.loading)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _results(
|
||||
BuildContext context, {
|
||||
required GeoPoint? coordinates,
|
||||
required List<Comune> matches,
|
||||
required RegionConfig? region,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (_query.trim().isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.searchHelp, style: theme.textTheme.bodyMedium),
|
||||
);
|
||||
}
|
||||
|
||||
final outsideRegion =
|
||||
coordinates != null &&
|
||||
region != null &&
|
||||
!region.bounds.contains(coordinates);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (coordinates != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.my_location),
|
||||
title: Text(l10n.searchCoordinatesResult),
|
||||
subtitle: Text(
|
||||
'${coordinates.latitude.toStringAsFixed(4)}, '
|
||||
'${coordinates.longitude.toStringAsFixed(4)}'
|
||||
// Saying so beats letting them travel to an empty map.
|
||||
'${outsideRegion ? '\n${l10n.searchOutsideRegion}' : ''}',
|
||||
style: outsideRegion
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(
|
||||
SearchResult(
|
||||
point: coordinates,
|
||||
label:
|
||||
'${coordinates.latitude.toStringAsFixed(4)}, '
|
||||
'${coordinates.longitude.toStringAsFixed(4)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (coordinates != null && matches.isNotEmpty) const Divider(),
|
||||
|
||||
if (matches.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
l10n.searchComuniCount(matches.length),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
for (final comune in matches)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_city),
|
||||
title: Text(comune.name),
|
||||
subtitle: Text(comune.province),
|
||||
onTap: () =>
|
||||
Navigator.of(context)
|
||||
.pop(SearchResult(point: comune.point, label: comune.name)),
|
||||
),
|
||||
|
||||
if (matches.isEmpty && coordinates == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.searchNoResults, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.searchHelp, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user