diff --git a/lib/fields/address.dart b/lib/fields/address.dart index 9de1d330..16903643 100644 --- a/lib/fields/address.dart +++ b/lib/fields/address.dart @@ -1,11 +1,14 @@ // Copyright 2022-2025 Ilya Zverev // This file is a part of Every Door, distributed under GPL v3 or later version. // Refer to LICENSE file and https://www.gnu.org/licenses/gpl-3.0.html for details. +import 'package:country_coder/country_coder.dart'; import 'package:every_door/constants.dart'; +import 'package:every_door/fields/address_blockbased.dart'; import 'package:every_door/fields/helpers/new_addr.dart'; import 'package:every_door/widgets/radio_field.dart'; import 'package:every_door/models/address.dart'; import 'package:every_door/models/amenity.dart'; +import 'package:every_door/providers/editor_settings.dart'; import 'package:every_door/providers/osm_data.dart'; import 'package:every_door/screens/editor/addr_chooser.dart'; import 'package:flutter/material.dart'; @@ -19,7 +22,23 @@ class AddressField extends PresetField { ); @override - Widget buildWidget(OsmChange element) => AddressInput(this, element); + Widget buildWidget(OsmChange element) { + return Consumer(builder: (context, ref, child) { + final preferBlock = ref.watch(editorSettingsProvider).preferBlockAddress; + final isJapan = CountryCoder.instance.isIn( + lat: element.location.latitude, + lon: element.location.longitude, + inside: 'Q17', + ); + if (preferBlock || isJapan) { + return AddressBlockBasedInput( + AddressBlockBasedField(label: label, key: key), + element, + ); + } + return AddressInput(this, element); + }); + } @override bool hasRelevantKey(Map tags) { diff --git a/lib/fields/address_blockbased.dart b/lib/fields/address_blockbased.dart new file mode 100644 index 00000000..cf73e6cb --- /dev/null +++ b/lib/fields/address_blockbased.dart @@ -0,0 +1,162 @@ +import 'package:every_door/constants.dart'; +import 'package:every_door/fields/address_form_blockbased.dart'; +import 'package:every_door/models/address_blockbased.dart'; +import 'package:every_door/models/address.dart'; +import 'package:every_door/models/amenity.dart'; +import 'package:every_door/providers/osm_data.dart'; +import 'package:every_door/screens/editor/addr_chooser.dart'; +import 'package:flutter/material.dart'; +import 'package:every_door/models/field.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:every_door/fields/address_form.dart'; + +import '../widgets/radio_field.dart'; + +/// Block-based address field (counterpart of lib/fields/address.dart) +/// Uses BlockBasedAddress model and the blockbased form for manual editing. +class AddressBlockBasedField extends PresetField { + AddressBlockBasedField({required super.label, super.key = "addr"}) + : super( + icon: key == 'addr' ? Icons.home_outlined : null, + ); + + @override + Widget buildWidget(OsmChange element) => AddressBlockBasedInput(this, element); + + @override + bool hasRelevantKey(Map tags) { + return BlockBasedAddress.fromTags(tags, base: key).isNotEmpty; + } +} + +class AddressBlockBasedInput extends ConsumerStatefulWidget { + final OsmChange element; + final AddressBlockBasedField field; + + const AddressBlockBasedInput(this.field, this.element); + + @override + ConsumerState createState() => _AddressBlockBasedInputState(); +} + +class _AddressBlockBasedInputState extends ConsumerState { + static const kChooseOnMap = '🗺️'; + List nearestAddresses = []; + + @override + void initState() { + super.initState(); + loadAddresses(); + } + + Future loadAddresses() async { + final osmData = ref.read(osmDataProvider); + final addr = await osmData.getBlockBasedAddressesAround( + widget.element.location, + limit: 3, + ); + setState(() { + nearestAddresses = addr; + }); + } + + Future _openManualEditor(BuildContext context) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.only( + top: 6.0, + left: 10.0, + right: 10.0, + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: AddressFormBlockBasedField( + AddressFormPresetField(key: widget.field.key, label: widget.field.label), + widget.element, + ), + ), + ); + }, + ); + // Element tags are updated in-place by the form; no return value needed. + setState(() {}); + } + + BlockBasedAddress _convertFromStreet(StreetAddress sa) { + // Prefer city if present, otherwise place as suburb. + return BlockBasedAddress( + housenumber: sa.housenumber, + blockNumber: sa.blockNumber, + city: sa.city, + province: sa.province, + suburb: sa.city == null ? sa.place : null, + ).withBase(widget.field.key); + } + + Future _chooseAddressOnMap() async { + final StreetAddress? sa = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddrChooserPage(location: widget.element.location), + ), + ); + if (sa != null && sa.isNotEmpty) { + final ba = _convertFromStreet(sa); + setState(() { + ba.forceTags(widget.element); + }); + } + } + + @override + Widget build(BuildContext context) { + final current = BlockBasedAddress.fromTags( + widget.element.getFullTags(), + base: widget.field.key, + ); + + final List options = + nearestAddresses.map((e) => e.toShortString()).toList(); + final String? currentValue = current.isEmpty ? null : current.toShortString(); + if (currentValue != null && !options.contains(currentValue)) { + options.insert(0, currentValue); + } + + if (current.isEmpty) { + options.insert(0, kChooseOnMap); + options.add(kManualOption); + } else { + options.insert(0, kManualOption); + } + + return RadioField( + options: options, + value: currentValue, + keepFirst: true, + onChange: (value) async { + if (value == null) { + setState(() { + BlockBasedAddress.clearTags(widget.element, base: widget.field.key); + }); + } else if (value == kManualOption) { + await _openManualEditor(context); + } else if (value == kChooseOnMap) { + await _chooseAddressOnMap(); + } else { + final ba = nearestAddresses.cast().firstWhere( + (e) => e?.toShortString() == value, + orElse: () => value == currentValue ? current : null, + ); + if (ba != null) { + setState(() { + ba.withBase(widget.field.key).setTags(widget.element); + }); + } + } + }, + ); + } +} diff --git a/lib/fields/address_form.dart b/lib/fields/address_form.dart index 09c9c648..cdb88dc7 100644 --- a/lib/fields/address_form.dart +++ b/lib/fields/address_form.dart @@ -72,7 +72,8 @@ class _AddressFormFieldState extends ConsumerState { _streetFocus = FocusNode(); street = address.street; place = address.place ?? address.city; - needBlockNumber = CountryCoder.instance.isIn( + final preferBlock = ref.read(editorSettingsProvider).preferBlockAddress; + needBlockNumber = preferBlock || CountryCoder.instance.isIn( lat: widget.element.location.latitude, lon: widget.element.location.longitude, inside: 'Q17', // Japan diff --git a/lib/fields/address_form_blockbased.dart b/lib/fields/address_form_blockbased.dart new file mode 100644 index 00000000..5cdab7df --- /dev/null +++ b/lib/fields/address_form_blockbased.dart @@ -0,0 +1,301 @@ +import 'package:country_coder/country_coder.dart'; +import 'package:every_door/constants.dart'; +import 'package:every_door/fields/address_form.dart'; +import 'package:every_door/generated/l10n/app_localizations.dart' show AppLocalizations; +import 'package:every_door/providers/editor_settings.dart'; +import 'package:every_door/widgets/radio_field.dart'; +import 'package:every_door/providers/osm_data.dart'; +import 'package:flutter/material.dart'; +import 'package:every_door/models/address_blockbased.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AddressFormBlockBasedField extends AddressFormField { + + const AddressFormBlockBasedField(super.field, super.element); + + @override + ConsumerState createState() => _AddressFormFieldBlockBasedState(); +} + +class _AddressFormFieldBlockBasedState extends ConsumerState { + late final TextEditingController _provinceController; + late final TextEditingController _cityController; + late final TextEditingController _neighController; + late final TextEditingController _blockController; + late final TextEditingController _houseController; + late final TextEditingController _postcodeController; + late final TextEditingController _countyController; + late final TextEditingController _suburbController; + late final TextEditingController _quarterController; + + List nearestProvinces = []; + List nearestCities = []; + List nearestNeighbourhoods = []; + + @override + void initState() { + super.initState(); + final address = BlockBasedAddress.fromTags(widget.element.getFullTags()); + _provinceController = TextEditingController(text: address.province); + _cityController = TextEditingController(text: address.city); + _neighController = TextEditingController(text: address.neighbourhood); + _blockController = TextEditingController(text: address.blockNumber); + _houseController = TextEditingController(text: address.housenumber); + _postcodeController = TextEditingController(text: address.postcode); + _countyController = TextEditingController(text: address.county); + _suburbController = TextEditingController(text: address.suburb); + _quarterController = TextEditingController(text: address.quarter); + _updateNearbyAddressHints(); + } + + @override + void dispose() { + _provinceController.dispose(); + _cityController.dispose(); + _neighController.dispose(); + _blockController.dispose(); + _houseController.dispose(); + _postcodeController.dispose(); + _countyController.dispose(); + _suburbController.dispose(); + _quarterController.dispose(); + + super.dispose(); + } + + List _filterDuplicates(Iterable source) { + final values = {}; + final result = source.whereType().toList(); + result.retainWhere((element) => values.add(element)); + return result; + } + + List _nearbyAddresses = []; + + Future _updateNearbyAddressHints() async { + final provider = ref.read(osmDataProvider); + final addrs = await provider.getBlockBasedAddressesAround( + widget.element.location, + limit: 30, + ); + setState(() { + _nearbyAddresses = addrs; + nearestProvinces = _filterDuplicates(addrs.map((e) => e.province)); + nearestCities = _filterDuplicates(addrs.map((e) => e.city)); + nearestNeighbourhoods = _filterDuplicates( + addrs.map((e) => e.neighbourhood ?? e.quarter ?? e.suburb), + ); + }); + } + + String? _getValue(TextEditingController controller) { + final value = controller.text.trim(); + return value.isEmpty ? null : value; + } + + void notifyOnChange() { + String postcode = _postcodeController.text.trim(); + final isJapan = CountryCoder.instance.isIn( + lat: widget.element.location.latitude, + lon: widget.element.location.longitude, + inside: 'Q17', + ); + + if (isJapan && postcode.length == 7 && !postcode.contains('-')) { + postcode = postcode.substring(0, 3) + '-' + postcode.substring(3); + _postcodeController.value = TextEditingValue( + text: postcode, + selection: TextSelection.collapsed(offset: postcode.length), + ); + } + + final address = BlockBasedAddress( + province: _getValue(_provinceController), + city: _getValue(_cityController), + neighbourhood: _getValue(_neighController), + blockNumber: _getValue(_blockController), + housenumber: _getValue(_houseController), + postcode: _getValue(_postcodeController), + county: _getValue(_countyController), + suburb: _getValue(_suburbController), + quarter: _getValue(_quarterController), + ); + address.forceTags(widget.element); + setState(() {}); + } + + Future _editValue(String label, TextEditingController controller, + {TextInputType? keyboardType, + List? inputFormatters}) async { + final result = await showDialog( + context: context, + builder: (context) { + final controllerCopy = TextEditingController(text: controller.text); + return AlertDialog( + title: Text(label), + content: TextFormField( + controller: controllerCopy, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + autofocus: true, + decoration: InputDecoration(hintText: label), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, controllerCopy.text), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + }, + ); + + if (result != null) { + controller.text = result; + notifyOnChange(); + } + } + + TableRow _buildRow(String label, TextEditingController controller, + {TextInputType? keyboardType, + String? hintText, + bool autofocus = false, + String? Function(String?)? validator, + Color? labelColor, + List? inputFormatters, + List? options}) { + final hasOptions = options != null && options.isNotEmpty; + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0, top: 15.0), + child: Text(label, style: kFieldTextStyle.copyWith(color: labelColor)), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasOptions) + Row( + children: [ + Expanded( + child: RadioField( + options: options, + value: options.contains(controller.text.trim()) ? controller.text.trim() : null, + onChange: (value) { + if (value != null) { + controller.text = value; + if (_provinceController.text.isEmpty) { + final addr = _nearbyAddresses.firstWhere( + (e) => + e.city == value || + e.neighbourhood == value || + e.quarter == value || + e.suburb == value, + orElse: () => BlockBasedAddress.empty, + ); + if (addr.province != null) { + _provinceController.text = addr.province!; + } + } + notifyOnChange(); + } + }, + ), + ), + IconButton( + icon: Text(kManualOption, style: TextStyle(fontSize: 20.0)), + onPressed: () => _editValue(label, controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters), + ), + ], + ), + if (!hasOptions) + TextFormField( + controller: controller, + keyboardType: keyboardType, + autofocus: autofocus, + style: kFieldTextStyle, + decoration: InputDecoration(hintText: hintText), + validator: validator, + inputFormatters: inputFormatters, + onChanged: (value) => notifyOnChange(), + ), + ], + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final numericKeyboardType = ref.watch(editorSettingsProvider).keyboardType; + final isJapan = CountryCoder.instance.isIn( + lat: widget.element.location.latitude, + lon: widget.element.location.longitude, + inside: 'Q17', + ); + final postcodeRegExp = RegExp(r'^\d{3}-\d{4}$'); + + // Order: Postcode -> Province -> County -> City -> Suburb -> Quarter -> Neighbourhood -> Block -> House + return Table( + columnWidths: const { 0: FixedColumnWidth(100.0)}, + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: [ + _buildRow(loc.addressPostcode, _postcodeController, + keyboardType: TextInputType.number, + hintText: '123-4567', + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9-]')), + LengthLimitingTextInputFormatter(8), + ], + validator: (value) { + if (value == null || value.isEmpty) return null; + if (isJapan && !postcodeRegExp.hasMatch(value)) return loc.addressPostcodeWrong; + return null; + }), + _buildRow(loc.addressProvince, _provinceController, + options: nearestProvinces.isNotEmpty ? nearestProvinces : null), + _buildRow(loc.addressCounty, _countyController), + _buildRow(loc.addressCity, _cityController, + options: nearestCities.isNotEmpty ? nearestCities : null), + _buildRow(loc.addressSuburb, _suburbController), + _buildRow(loc.addressQuarter, _quarterController), + _buildRow(loc.addressNeighbourhood, _neighController, + options: nearestNeighbourhoods.isNotEmpty + ? nearestNeighbourhoods + : null), + _buildRow( + loc.addressBlock, + _blockController, + keyboardType: numericKeyboardType, + labelColor: _houseController.text.trim().isNotEmpty && + _blockController.text.trim().isEmpty + ? Colors.red + : null, + ), + _buildRow( + loc.addressHouseNumber, + _houseController, + keyboardType: TextInputType.visiblePassword, + autofocus: widget.field.autoFocus, + hintText: '1, 89, 154A, ...', + validator: (value) => value == null || value.trim().isEmpty + ? loc.addressHouseNotEmpty + : null, + labelColor: _houseController.text.trim().isEmpty && + _blockController.text.trim().isNotEmpty + ? Colors.red + : null, + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 83c73006..e0ac80dd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1268,6 +1268,42 @@ "@addressPlace": { "description": "Label for addr:place or addr:city in the form. Keep it short." }, + "addressProvince": "Province", + "@addressProvince": { + "description": "Label for addr:province in the form. Keep it short." + }, + "addressCity": "City/Ward", + "@addressCity": { + "description": "Label for addr:city in the form. Keep it short." + }, + "addressCounty": "County", + "@addressCounty": { + "description": "Label for addr:county in the form. Keep it short." + }, + "addressSuburb": "District", + "@addressSuburb": { + "description": "Label for addr:suburb in the form. Keep it short." + }, + "addressNeighbourhood": "Neighbourhood", + "@addressNeighbourhood": { + "description": "Label for addr:neighbourhood in the form. Keep it short." + }, + "addressQuarter": "Quarter", + "@addressQuarter": { + "description": "Label for addr:quarter in the form. Keep it short." + }, + "addressPostcode": "Postcode", + "@addressPostcode": { + "description": "Label for addr:postcode in the form. Keep it short." + }, + "addressPostcodeWrong": "Wrong postcode format (123-4567)", + "@addressPostcodeWrong": { + "description": "Validation message for a wrong postcode format." + }, + "settingsPreferBlockAddress": "Use block-based addressing", + "@settingsPreferBlockAddress": { + "description": "Title for the setting to use block-based addressing (city, neighbourhood, block, house) instead of street-based." + }, "notesAddNote": "Add note", "@notesAddNote": { "description": "Tooltip for the button to add a note." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 071406fd..75aed415 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -635,7 +635,11 @@ "@addressUnitOptional": { "description": "Hint for addr:unit that it's optional." }, - "addressStreet": "街路", + "addressBlock": "番地", + "@addressBlock": { + "description": "Label for addr:block_number or addr_block. Keep it short." + }, + "addressStreet": "道路", "@addressStreet": { "description": "Label for addr:street in the form. Keep it short." }, @@ -643,6 +647,42 @@ "@addressPlace": { "description": "Label for addr:place or addr:city in the form. Keep it short." }, + "addressProvince": "都道府県", + "@addressProvince": { + "description": "Label for addr:province in the form. Keep it short." + }, + "addressCity": "市町村・23区", + "@addressCity": { + "description": "Label for addr:city in the form. Keep it short." + }, + "addressCounty": "郡", + "@addressCounty": { + "description": "Label for addr:county in the form. Keep it short." + }, + "addressSuburb": "区", + "@addressSuburb": { + "description": "Label for addr:suburb in the form. Keep it short." + }, + "addressNeighbourhood": "町丁・字", + "@addressNeighbourhood": { + "description": "Label for addr:neighbourhood in the form. Keep it short." + }, + "addressQuarter": "大字", + "@addressQuarter": { + "description": "Label for addr:quarter in the form. Keep it short." + }, + "addressPostcode": "郵便番号", + "@addressPostcode": { + "description": "Label for addr:postcode in the form. Keep it short." + }, + "addressPostcodeWrong": "郵便番号の形式が異なります (123-4567)", + "@addressPostcodeWrong": { + "description": "Validation message for a wrong postcode format." + }, + "settingsPreferBlockAddress": "番地ベースの住所入力を使用", + "@settingsPreferBlockAddress": { + "description": "Title for the setting to use block-based addressing (city, neighbourhood, block, house) instead of street-based." + }, "apiStatusDownloading": "データダウンロード中…", "@apiStatusDownloading": { "description": "Text displayed when the app is downloading data from the API." @@ -1132,10 +1172,6 @@ "@fieldDirectionSet": { "description": "Button label for setting a direction (0-360°) value." }, - "addressBlock": "街区符号/番/番地/地番", - "@addressBlock": { - "description": "Label for addr:block_number or addr_block. Keep it short." - }, "notesComment": "あなたのコメント", "@notesComment": { "description": "Label for the note comment field." diff --git a/lib/models/address.dart b/lib/models/address.dart index 4ae27e4b..1d35fd14 100644 --- a/lib/models/address.dart +++ b/lib/models/address.dart @@ -19,6 +19,7 @@ class StreetAddress { final String? blockNumber; final String? place; final String? city; + final String? province; /// The key part before the semicolon, "addr" by default. final String base; @@ -32,6 +33,7 @@ class StreetAddress { this.blockNumber, this.place, this.city, + this.province, this.location, this.base = "addr", }); @@ -49,6 +51,7 @@ class StreetAddress { blockNumber: blockNumber, place: place, city: city, + province: province, location: location, base: base, ); @@ -67,6 +70,7 @@ class StreetAddress { city: (tags['$base:street'] == null && tags['$base:place'] == null) ? tags['$base:city'] : null, + province: tags['$base:province'], location: location, ); } @@ -79,7 +83,8 @@ class StreetAddress { place == null && city == null && block == null && - blockNumber == null); + blockNumber == null && + province == null); bool get isNotEmpty => !isEmpty; /// Applies address tags onto the [element]. Does not erase @@ -98,8 +103,9 @@ class StreetAddress { element['$base:street'] = street; else if (place != null) element['$base:place'] = place; - else + else if (city != null) element['$base:city'] = city; + if (province != null) element['$base:province'] = province; } /// Applies address tags onto the [element], removing any tags @@ -112,6 +118,7 @@ class StreetAddress { element['$base:block_number'] = blockNumber; element['$base:street'] = street; element['$base:place'] = place; + element['$base:province'] = province; // TODO: decide something about the city if (city != null) element['$base:city'] = city; } @@ -126,7 +133,8 @@ class StreetAddress { 'block_number', 'street', 'place', - 'city' + 'city', + 'province' ]) element.removeTag('$base:$key'); } @@ -141,7 +149,8 @@ class StreetAddress { blockNumber == other.blockNumber && street == other.street && place == other.place && - city == other.city; + city == other.city && + province == other.province; } @override @@ -149,6 +158,7 @@ class StreetAddress { (housenumber ?? housename ?? '').hashCode + (block ?? blockNumber ?? '').hashCode + (street ?? place ?? city ?? '').hashCode + + (province ?? '').hashCode + unit.hashCode; @override diff --git a/lib/models/address_blockbased.dart b/lib/models/address_blockbased.dart new file mode 100644 index 00000000..bc8f4c7c --- /dev/null +++ b/lib/models/address_blockbased.dart @@ -0,0 +1,198 @@ +import 'package:every_door/models/amenity.dart'; +import 'package:latlong2/latlong.dart'; + +/// A model for block-based addressing. +/// Fields are fixed and must use underscores exactly as specified: +/// province, county, city, suburb, neighbourhood, block_number, housenumber +class BlockBasedAddress { + /// Location is informative; it does not participate in comparison. + final LatLng? location; + + final String? postcode; + final String? province; + final String? county; + final String? city; + final String? suburb; + final String? quarter; + final String? neighbourhood; + /// Note: key name must be `block_number` in tags + final String? blockNumber; + final String? housenumber; + + /// The key part before the semicolon, "addr" by default. + final String base; + + const BlockBasedAddress({ + this.postcode, + this.province, + this.county, + this.city, + this.suburb, + this.quarter, + this.neighbourhood, + this.blockNumber, + this.housenumber, + this.location, + this.base = 'addr', + }); + + static const empty = BlockBasedAddress(); + + BlockBasedAddress withBase(String base) => BlockBasedAddress( + postcode: postcode, + province: province, + county: county, + city: city, + suburb: suburb, + quarter: quarter, + neighbourhood: neighbourhood, + blockNumber: blockNumber, + housenumber: housenumber, + location: location, + base: base, + ); + + factory BlockBasedAddress.fromTags(Map tags, + {LatLng? location, String base = 'addr'}) { + return BlockBasedAddress( + province: tags['$base:province'], + county: tags['$base:county'], + city: tags['$base:city'], + suburb: tags['$base:suburb'], + neighbourhood: tags['$base:neighbourhood'], + quarter: tags['$base:quarter'], + postcode: tags['$base:postcode'], + blockNumber: tags['$base:block_number'], + housenumber: tags['$base:housenumber'], + location: location, + base: base, + ); + } + + bool get isEmpty => (housenumber == null || housenumber!.isEmpty) && + (blockNumber == null || blockNumber!.isEmpty) && + (city == null || city!.isEmpty) && + (suburb == null || suburb!.isEmpty) && + (neighbourhood == null || neighbourhood!.isEmpty) && + (quarter == null || quarter!.isEmpty) && + (postcode == null || postcode!.isEmpty) && + (county == null || county!.isEmpty) && + (province == null || province!.isEmpty); + bool get isNotEmpty => !isEmpty; + + /// Applies address tags onto the [element]. Does not erase tags that this + /// address does not define. + void setTags(OsmChange element) { + if (isEmpty) return; + if (housenumber != null) { + element['$base:housenumber'] = housenumber; + } + if (blockNumber != null) { + element['$base:block_number'] = blockNumber; + } + if (neighbourhood != null) { + element['$base:neighbourhood'] = neighbourhood; + } + if (quarter != null) { + element['$base:quarter'] = quarter; + } + if (postcode != null) { + element['$base:postcode'] = postcode; + } + if (suburb != null) { + element['$base:suburb'] = suburb; + } + if (city != null) { + element['$base:city'] = city; + } + if (county != null) { + element['$base:county'] = county; + } + if (province != null) { + element['$base:province'] = province; + } + element.removeTag('$base:street'); + } + + /// Applies address tags onto the [element], removing any tags + /// that this address does not have. + void forceTags(OsmChange element) { + element['$base:housenumber'] = housenumber; + element['$base:block_number'] = blockNumber; + element['$base:neighbourhood'] = neighbourhood; + element['$base:quarter'] = quarter; + element['$base:postcode'] = postcode; + element['$base:suburb'] = suburb; + element['$base:city'] = city; + element['$base:county'] = county; + element['$base:province'] = province; + element.removeTag('$base:street'); + } + + static void clearTags(OsmChange element, {String base = 'addr'}) { + for (final key in const [ + 'housenumber', + 'block_number', + 'neighbourhood', + 'quarter', + 'postcode', + 'suburb', + 'city', + 'county', + 'province', + 'street', + ]) { + element.removeTag('$base:$key'); + } + } + + @override + bool operator ==(Object other) { + if (other is! BlockBasedAddress) return false; + if (isEmpty && other.isEmpty) return true; + return housenumber == other.housenumber && + blockNumber == other.blockNumber && + neighbourhood == other.neighbourhood && + quarter == other.quarter && + suburb == other.suburb && + city == other.city && + county == other.county && + province == other.province && + postcode == other.postcode; + } + + @override + int get hashCode => + (housenumber ?? '').hashCode + + (blockNumber ?? '').hashCode + + (neighbourhood ?? '').hashCode + + (quarter ?? '').hashCode + + (suburb ?? '').hashCode + + (city ?? '').hashCode + + (county ?? '').hashCode + + (province ?? '').hashCode + + (postcode ?? '').hashCode; + + @override + String toString() { + // For BlockBased display, prefer order: postcode -> province -> county -> city -> neighbourhood -> block_number -> housenumber + return [ + postcode, + province, + county, + city, + neighbourhood, + blockNumber, + housenumber + ].where((s) => s != null && s.isNotEmpty).join(' '); + } + + String toShortString() { + // Show only the most specific parts for selection + final main = [neighbourhood, blockNumber].where((s) => s != null && s.isNotEmpty).join(); + if (main.isEmpty && (housenumber == null || housenumber!.isEmpty)) return toString(); + if (housenumber == null || housenumber!.isEmpty) return main; + if (main.isEmpty) return housenumber!; + return '$main-$housenumber'; + } +} diff --git a/lib/providers/editor_settings.dart b/lib/providers/editor_settings.dart index 84970155..9e2b0a23 100644 --- a/lib/providers/editor_settings.dart +++ b/lib/providers/editor_settings.dart @@ -15,6 +15,7 @@ class EditorSettings { static const kDefaultPayment = ['debit_cards', 'credit_cards']; final bool preferContact; + final bool preferBlockAddress; final bool fixNumKeyboard; final bool leftHand; final List defaultPayment; @@ -22,6 +23,7 @@ class EditorSettings { const EditorSettings({ this.preferContact = false, + this.preferBlockAddress = false, this.fixNumKeyboard = true, this.leftHand = false, this.defaultPayment = kDefaultPayment, @@ -30,6 +32,7 @@ class EditorSettings { EditorSettings copyWith({ bool? preferContact, + bool? preferBlockAddress, bool? fixNumKeyboard, bool? leftHand, List? defaultPayment, @@ -37,6 +40,7 @@ class EditorSettings { }) { return EditorSettings( preferContact: preferContact ?? this.preferContact, + preferBlockAddress: preferBlockAddress ?? this.preferBlockAddress, fixNumKeyboard: fixNumKeyboard ?? this.fixNumKeyboard, leftHand: leftHand ?? this.leftHand, defaultPayment: defaultPayment ?? this.defaultPayment, @@ -53,9 +57,10 @@ class EditorSettings { ? kDefaultPayment : data[2].split(';').map((s) => s.trim()).toList(), leftHand: data.length >= 4 && data[3] == '1', - changesetReview: data.length < 5 - ? ChangesetReview.never + changesetReview: data.length < 5 + ? ChangesetReview.never : ChangesetReview.values[int.parse(data[4])], + preferBlockAddress: data.length >= 6 && data[5] == '1', ); } @@ -66,6 +71,7 @@ class EditorSettings { defaultPayment.join(';'), leftHand ? '1' : '0', ChangesetReview.values.indexOf(changesetReview).toString(), + preferBlockAddress ? '1' : '0', ]; } @@ -93,6 +99,11 @@ class EditorSettingsProvider extends Notifier { store(); } + void setPreferBlockAddress(bool value) { + state = state.copyWith(preferBlockAddress: value); + store(); + } + void setFixNumKeyboard(bool value) { state = state.copyWith(fixNumKeyboard: value); store(); diff --git a/lib/providers/osm_data.dart b/lib/providers/osm_data.dart index 067dd309..b1c9e620 100644 --- a/lib/providers/osm_data.dart +++ b/lib/providers/osm_data.dart @@ -12,6 +12,7 @@ import 'package:every_door/helpers/location_object.dart'; import 'package:every_door/helpers/normalizer.dart'; import 'package:every_door/helpers/tags/payment_tags.dart'; import 'package:every_door/models/address.dart'; +import 'package:every_door/models/address_blockbased.dart'; import 'package:every_door/models/floor.dart'; import 'package:every_door/models/osm_element.dart'; import 'package:every_door/models/road_name.dart'; @@ -254,6 +255,39 @@ class OsmDataHelper extends ChangeNotifier { return results; } + /// Build block-based addresses directly from OSM elements' tags to + /// preserve fields like province/county/city/neighbourhood when present. + Future> getBlockBasedAddressesAround(LatLng location, + {int limit = 4, bool includeAmenities = true}) async { + // + final elements = await _getAddressedElementsAround(location); + + // Optionally remove non-buildings/amenities to align with Street variant behavior. + if (!includeAmenities) { + elements.removeWhere((e) => !isBuildingOrAddressPoint(e.tags)); + } + + const distance = DistanceEquirectangular(); + final Map addresses = {}; + + for (final e in elements) { + final fromTags = BlockBasedAddress.fromTags(e.tags, location: e.center); + + if (fromTags.isNotEmpty && e.center != null) { + final dist = distance(location, e.center!); + final old = addresses[fromTags]; + if (old == null || old > dist) { + addresses[fromTags] = dist; + } + } + } + + final results = addresses.keys.toList(); + results.sort((a, b) => addresses[a]!.compareTo(addresses[b]!)); + if (results.length > limit) return results.sublist(0, limit); + return results; + } + Future isUniqueAddress(StreetAddress address, LatLng location) async { final elements = await _getAddressedElementsAround(location); final addresses = Counter(elements diff --git a/lib/screens/editor/building.dart b/lib/screens/editor/building.dart index 416426dd..5980fa7d 100644 --- a/lib/screens/editor/building.dart +++ b/lib/screens/editor/building.dart @@ -1,11 +1,14 @@ // Copyright 2022-2025 Ilya Zverev // This file is a part of Every Door, distributed under GPL v3 or later version. // Refer to LICENSE file and https://www.gnu.org/licenses/gpl-3.0.html for details. +import 'package:country_coder/country_coder.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:every_door/helpers/geometry/equirectangular.dart'; import 'package:every_door/helpers/in_countries.dart'; import 'package:every_door/models/address.dart'; +import 'package:every_door/models/address_blockbased.dart'; import 'package:every_door/widgets/address_form.dart'; +import 'package:every_door/widgets/address_form_blockbased.dart'; import 'package:every_door/widgets/radio_field.dart'; import 'package:every_door/models/amenity.dart'; import 'package:every_door/providers/changes.dart'; @@ -33,6 +36,7 @@ class _BuildingEditorPaneState extends ConsumerState { late final OsmChange building; bool manualLevels = false; bool buildingsNeedAddresses = true; + bool isJapan = false; bool saved = false; late final FocusNode _levelsFocus; List nearestLevels = []; @@ -49,6 +53,11 @@ class _BuildingEditorPaneState extends ConsumerState { buildingsHaveAddresses(widget.location).then((value) { buildingsNeedAddresses = value; }); + isJapan = CountryCoder.instance.isIn( + lat: widget.location.latitude, + lon: widget.location.longitude, + inside: 'Q17', //Japan + ); saved = false; updateLevels(); } @@ -154,7 +163,18 @@ class _BuildingEditorPaneState extends ConsumerState { ), child: Column( children: [ - if (buildingsNeedAddresses || isAddress) + if (isJapan) + AddressFormBlockBased( + location: widget.location, + initialAddress: + BlockBasedAddress.fromTags(building.getFullTags()), + autoFocus: + building['addr:housenumber'] == null && !manualLevels, + onChange: (addr) { + addr.forceTags(building); + }, + ) + else if (buildingsNeedAddresses || isAddress) AddressForm( location: widget.location, initialAddress: diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 6e2b393d..20a23907 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -174,6 +174,15 @@ class SettingsPage extends ConsumerWidget { }, initialValue: editorSettings.preferContact, ), + SettingsTile.switchTile( + title: Text(loc.settingsPreferBlockAddress), + onToggle: (value) { + ref + .read(editorSettingsProvider.notifier) + .setPreferBlockAddress(value); + }, + initialValue: editorSettings.preferBlockAddress, + ), ], ), if (defaultTargetPlatform == TargetPlatform.android) diff --git a/lib/widgets/address_form.dart b/lib/widgets/address_form.dart index 3961fcd4..9d495e0e 100644 --- a/lib/widgets/address_form.dart +++ b/lib/widgets/address_form.dart @@ -61,7 +61,8 @@ class _AddressFormState extends ConsumerState { _streetFocus = FocusNode(); street = address.street; place = address.place ?? address.city; - needBlockNumber = CountryCoder.instance.isIn( + final preferBlock = ref.read(editorSettingsProvider).preferBlockAddress; + needBlockNumber = preferBlock || CountryCoder.instance.isIn( lat: widget.location.latitude, lon: widget.location.longitude, inside: 'Q17', // Japan diff --git a/lib/widgets/address_form_blockbased.dart b/lib/widgets/address_form_blockbased.dart new file mode 100644 index 00000000..7780c19f --- /dev/null +++ b/lib/widgets/address_form_blockbased.dart @@ -0,0 +1,317 @@ +import 'package:country_coder/country_coder.dart'; +import 'package:every_door/constants.dart'; +import 'package:every_door/providers/editor_settings.dart'; +import 'package:every_door/widgets/radio_field.dart'; +import 'package:every_door/providers/osm_data.dart'; +import 'package:flutter/material.dart'; +import 'package:every_door/models/address_blockbased.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:every_door/generated/l10n/app_localizations.dart' + show AppLocalizations; +import 'package:latlong2/latlong.dart' show LatLng; + +class AddressFormBlockBased extends ConsumerStatefulWidget { + final LatLng location; + final BlockBasedAddress? initialAddress; + final Function(BlockBasedAddress) onChange; + final double columnWidth; + final bool autoFocus; + + const AddressFormBlockBased({ + this.initialAddress, + required this.location, + required this.onChange, + this.columnWidth = 100.0, + this.autoFocus = true, + }); + + @override + ConsumerState createState() => _AddressFormBlockBasedState(); +} + +class _AddressFormBlockBasedState extends ConsumerState { + late final TextEditingController _provinceController; + late final TextEditingController _cityController; + late final TextEditingController _neighController; + late final TextEditingController _blockController; + late final TextEditingController _houseController; + late final TextEditingController _postcodeController; + late final TextEditingController _countyController; + late final TextEditingController _suburbController; + late final TextEditingController _quarterController; + + List nearestProvinces = []; + List nearestCities = []; + List nearestNeighbourhoods = []; + + @override + void initState() { + super.initState(); + final address = widget.initialAddress ?? BlockBasedAddress(); + _provinceController = TextEditingController(text: address.province); + _cityController = TextEditingController(text: address.city); + _neighController = TextEditingController(text: address.neighbourhood); + _blockController = TextEditingController(text: address.blockNumber); + _houseController = TextEditingController(text: address.housenumber); + _postcodeController = TextEditingController(text: address.postcode); + _countyController = TextEditingController(text: address.county); + _suburbController = TextEditingController(text: address.suburb); + _quarterController = TextEditingController(text: address.quarter); + _updateNearbyAddressHints(); + } + + @override + void dispose() { + _provinceController.dispose(); + _cityController.dispose(); + _neighController.dispose(); + _blockController.dispose(); + _houseController.dispose(); + _postcodeController.dispose(); + _countyController.dispose(); + _suburbController.dispose(); + _quarterController.dispose(); + super.dispose(); + } + + List _filterDuplicates(Iterable source) { + final values = {}; + final result = source.whereType().toList(); + result.retainWhere((element) => values.add(element)); + return result; + } + + List _nearbyAddresses = []; + + Future _updateNearbyAddressHints() async { + final provider = ref.read(osmDataProvider); + final addrs = await provider.getBlockBasedAddressesAround( + widget.location, + limit: 30, + ); + setState(() { + _nearbyAddresses = addrs; + nearestProvinces = _filterDuplicates(addrs.map((e) => e.province)); + nearestCities = _filterDuplicates(addrs.map((e) => e.city)); + nearestNeighbourhoods = _filterDuplicates( + addrs.map((e) => e.neighbourhood ?? e.quarter ?? e.suburb), + ); + }); + } + + String? _getValue(TextEditingController controller) { + final value = controller.text.trim(); + return value.isEmpty ? null : value; + } + + Future _editValue(String label, TextEditingController controller, + {TextInputType? keyboardType, List? inputFormatters}) async { + final result = await showDialog( + context: context, + builder: (context) { + final controllerCopy = TextEditingController(text: controller.text); + return AlertDialog( + title: Text(label), + content: TextFormField( + controller: controllerCopy, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + autofocus: true, + decoration: InputDecoration(hintText: label), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, controllerCopy.text), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + }, + ); + + if (result != null) { + controller.text = result; + notifyOnChange(); + } + } + + void notifyOnChange() { + String postcode = _postcodeController.text.trim(); + final isJapan = CountryCoder.instance.isIn( + lat: widget.location.latitude, + lon: widget.location.longitude, + inside: 'Q17', // Japan + ); + + if (isJapan && postcode.length == 7 && !postcode.contains('-')) { + postcode = postcode.substring(0, 3) + '-' + postcode.substring(3); + _postcodeController.value = TextEditingValue( + text: postcode, + selection: TextSelection.collapsed(offset: postcode.length), + ); + } + + final address = BlockBasedAddress( + province: _getValue(_provinceController), + city: _getValue(_cityController), + neighbourhood: _getValue(_neighController), + blockNumber: _getValue(_blockController), + housenumber: _getValue(_houseController), + postcode: _getValue(_postcodeController), + county: _getValue(_countyController), + suburb: _getValue(_suburbController), + quarter: _getValue(_quarterController), + ); + + // If we have nearby addresses, try to fill province from the selected city/neighbourhood + if (address.province == null) { + final provider = ref.read(osmDataProvider); + // We don't want to await here to avoid lag, but maybe we can find it in already loaded addrs + // Actually, it's better to do it in onChange of RadioField + } + + widget.onChange(address); + setState(() {}); + } + + TableRow _buildRow(String label, TextEditingController controller, + {TextInputType? keyboardType, + String? hintText, + bool autofocus = false, + String? Function(String?)? validator, + Color? labelColor, + List? inputFormatters, + List? options}) { + final hasOptions = options != null && options.isNotEmpty; + final displayOptions = hasOptions ? options + [kManualOption] : null; + + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10.0, top: 10.0), + child: Text(label, style: kFieldTextStyle.copyWith(color: labelColor)), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasOptions) + RadioField( + options: displayOptions!, + value: options.contains(controller.text.trim()) ? controller.text.trim() : null, + onChange: (value) { + if (value == kManualOption) { + _editValue(label, controller, + keyboardType: keyboardType, inputFormatters: inputFormatters); + } else if (value != null) { + controller.text = value; + if (_provinceController.text.isEmpty) { + final addr = _nearbyAddresses.firstWhere( + (e) => + e.city == value || + e.neighbourhood == value || + e.quarter == value || + e.suburb == value, + orElse: () => BlockBasedAddress.empty, + ); + if (addr.province != null) { + _provinceController.text = addr.province!; + } + } + notifyOnChange(); + } + }, + ), + if (!hasOptions) + TextFormField( + controller: controller, + keyboardType: keyboardType, + autofocus: autofocus, + style: kFieldTextStyle, + decoration: InputDecoration( + hintText: hintText, + contentPadding: EdgeInsets.symmetric(vertical: 5.0), + ), + validator: validator, + inputFormatters: inputFormatters, + onChanged: (value) => notifyOnChange(), + ), + ], + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final numericKeyboardType = ref.watch(editorSettingsProvider).keyboardType; + final isJapan = CountryCoder.instance.isIn( + lat: widget.location.latitude, + lon: widget.location.longitude, + inside: 'Q17', + ); + final postcodeRegExp = RegExp(r'^\d{3}-\d{4}$'); + + // Requested order: 郵便番号 -> 市町村 -> "町丁・字" -> 番地 -> 住居番号 + // Mapping to OSM tags: + // 郵便番号: postcode + // 市町村: city / county / province + // 町丁・字: neighbourhood / quarter / suburb + // 番地: block_number + // 住居番号: housenumber + + return Table( + columnWidths: {0: FixedColumnWidth(widget.columnWidth)}, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + _buildRow(loc.addressPostcode, _postcodeController, + keyboardType: TextInputType.number, + hintText: '123-4567', + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9-]')), + LengthLimitingTextInputFormatter(8), + ], + validator: (value) { + if (value == null || value.isEmpty) return null; + if (isJapan && !postcodeRegExp.hasMatch(value)) return loc.addressPostcodeWrong; + return null; + }), + if (nearestProvinces.isNotEmpty) + _buildRow(loc.addressProvince, _provinceController, options: nearestProvinces), + _buildRow(loc.addressCity, _cityController, options: nearestCities), + _buildRow(loc.addressNeighbourhood, _neighController, options: nearestNeighbourhoods), + _buildRow( + loc.addressBlock, + _blockController, + keyboardType: numericKeyboardType, + labelColor: _houseController.text.trim().isNotEmpty && + _blockController.text.trim().isEmpty + ? Colors.red + : null, + ), + _buildRow( + loc.addressHouseNumber, + _houseController, + keyboardType: TextInputType.visiblePassword, + autofocus: widget.autoFocus, + hintText: '1, 89, 154A, ...', + validator: (value) => value == null || value.trim().isEmpty + ? loc.addressHouseNotEmpty + : null, + labelColor: _houseController.text.trim().isEmpty && + _blockController.text.trim().isNotEmpty + ? Colors.red + : null, + ), + ], + ); + } +}