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 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 = { 'à': '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) { 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(Comune.fromJson) .toList(growable: false), source: source, license: license is String && license.isNotEmpty ? license : null, ); } final List 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 search(String query) { final needle = foldForSearch(query.trim()); if (needle.isEmpty) return const []; final prefix = []; final contains = []; 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 [ ...prefix, ...contains, ].take(maxResults).toList(growable: false); } }