diff --git a/waydowntown_app/lib/models/region.dart b/waydowntown_app/lib/models/region.dart index 3c34497d..accaf292 100644 --- a/waydowntown_app/lib/models/region.dart +++ b/waydowntown_app/lib/models/region.dart @@ -4,6 +4,7 @@ class Region { final String? description; final double? latitude; final double? longitude; + final double? distance; Region? parentRegion; List children = []; @@ -14,6 +15,7 @@ class Region { this.parentRegion, this.latitude, this.longitude, + this.distance, }); factory Region.fromJson(Map json, List included) { @@ -30,6 +32,7 @@ class Region { longitude: attributes['longitude'] != null ? double.parse(attributes['longitude']) : null, + distance: attributes['distance']?.toDouble(), ); if (relationships != null && @@ -50,19 +53,20 @@ class Region { static List parseRegions(Map apiResponse) { final List data = apiResponse['data']; + final List included = apiResponse['included'] ?? []; Map regionMap = {}; // Extract all regions - for (var item in data) { + for (var item in [...data, ...included]) { if (item['type'] == 'regions') { - Region region = Region.fromJson(item, []); + Region region = Region.fromJson(item, included); regionMap[region.id] = region; } } // Nest children - for (var item in data) { + for (var item in [...data, ...included]) { if (item['type'] == 'regions' && item['relationships'] != null) { var relationships = item['relationships']; if (relationships['parent'] != null && diff --git a/waydowntown_app/lib/widgets/edit_specification_widget.dart b/waydowntown_app/lib/widgets/edit_specification_widget.dart index 9e0592b2..997a0dfb 100644 --- a/waydowntown_app/lib/widgets/edit_specification_widget.dart +++ b/waydowntown_app/lib/widgets/edit_specification_widget.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:waydowntown/app.dart'; import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; @@ -27,6 +28,7 @@ class EditSpecificationWidgetState extends State { String? _selectedRegionId; List _regions = []; Map _fieldErrors = {}; + bool _sortByDistance = false; @override void initState() { @@ -48,6 +50,7 @@ class EditSpecificationWidgetState extends State { if (response.statusCode == 200) { setState(() { _regions = Region.parseRegions(response.data); + _sortRegions(); }); } } catch (e) { @@ -55,6 +58,42 @@ class EditSpecificationWidgetState extends State { } } + Future _loadNearestRegions() async { + try { + Position position = await Geolocator.getCurrentPosition(); + final response = await widget.dio.get( + '/waydowntown/regions?filter[position]=${position.latitude},${position.longitude}'); + if (response.statusCode == 200) { + setState(() { + _regions = Region.parseRegions(response.data); + _sortByDistance = true; + _sortRegions(); + }); + } + } catch (e) { + talker.error('Error loading nearest regions: $e'); + } + } + + void _sortRegions() { + _sortRegionList(_regions); + } + + void _sortRegionList(List regions) { + if (_sortByDistance) { + regions.sort((a, b) => (a.distance ?? double.infinity) + .compareTo(b.distance ?? double.infinity)); + } else { + regions.sort((a, b) => a.name.compareTo(b.name)); + } + + for (var region in regions) { + if (region.children.isNotEmpty) { + _sortRegionList(region.children); + } + } + } + Future _loadConcepts(context) async { final yamlString = await DefaultAssetBundle.of(context).loadString('assets/concepts.yaml'); @@ -84,7 +123,7 @@ class EditSpecificationWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildConceptDropdown(snapshot.data), - _buildRegionDropdown(), + _buildRegionSection(), _buildTextField('Start Description', _startDescriptionController, 'start_description'), _buildTextField('Task Description', @@ -140,35 +179,85 @@ class EditSpecificationWidgetState extends State { ); } + Widget _buildRegionSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildRegionDropdown(), + ), + const SizedBox(width: 8), + _buildSortButton( + context: context, + label: 'A-Z', + isActive: !_sortByDistance, + onPressed: () { + setState(() { + _sortByDistance = false; + _sortRegions(); + }); + }, + ), + const SizedBox(width: 8), + _buildSortButton( + context: context, + label: 'Nearest', + isActive: _sortByDistance, + onPressed: _loadNearestRegions, + ), + ], + ), + ], + ); + } + Widget _buildRegionDropdown() { - return DropdownButtonFormField( - value: _selectedRegionId, - decoration: InputDecoration( - labelText: 'Region', - errorText: _fieldErrors['region_id'], - ), - items: _buildRegionItems(_regions, 0), - onChanged: (String? newValue) { + return DropdownMenu( + initialSelection: _selectedRegionId, + onSelected: (String? newValue) { setState(() { _selectedRegionId = newValue; }); }, + errorText: _fieldErrors['region_id'], + label: const Text('Region'), + dropdownMenuEntries: _buildNestedRegionEntries(_regions), + width: MediaQuery.of(context).size.width - 150, ); } - List> _buildRegionItems( - List regions, int depth) { - List> items = []; + List> _buildNestedRegionEntries( + List regions, + {String indent = ''}) { + List> entries = []; + for (var region in regions) { - items.add(DropdownMenuItem( + entries.add(DropdownMenuEntry( value: region.id, - child: Text('${' ' * depth}${region.name}'), + label: '$indent${region.name}', + trailingIcon: _sortByDistance && region.distance != null + ? Text(_formatDistance(region.distance!)) + : null, )); + if (region.children.isNotEmpty) { - items.addAll(_buildRegionItems(region.children, depth + 1)); + entries.addAll( + _buildNestedRegionEntries(region.children, indent: '$indent ')); } } - return items; + + return entries; + } + + String _formatDistance(double distanceInMeters) { + if (distanceInMeters >= 1000) { + return '${(distanceInMeters / 1000).round()} km'; + } else { + return '${distanceInMeters.round()} m'; + } } Widget _buildTextField( @@ -272,3 +361,19 @@ class EditSpecificationWidgetState extends State { super.dispose(); } } + +Widget _buildSortButton({ + required BuildContext context, + required String label, + required bool isActive, + required VoidCallback onPressed, +}) { + return ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: isActive ? Theme.of(context).primaryColor : null, + foregroundColor: isActive ? Colors.white : null, + ), + child: Text(label), + ); +} diff --git a/waydowntown_app/pubspec.lock b/waydowntown_app/pubspec.lock index 6888a340..d86d6907 100644 --- a/waydowntown_app/pubspec.lock +++ b/waydowntown_app/pubspec.lock @@ -496,7 +496,7 @@ packages: source: hosted version: "2.3.7" geolocator_platform_interface: - dependency: transitive + dependency: "direct main" description: name: geolocator_platform_interface sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012" diff --git a/waydowntown_app/pubspec.yaml b/waydowntown_app/pubspec.yaml index 7ff1fe19..20032fe6 100644 --- a/waydowntown_app/pubspec.yaml +++ b/waydowntown_app/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: talker_dio_logger: ^4.4.1 talker_logger: ^4.4.1 flutter_secure_storage: ^9.2.2 + geolocator_platform_interface: ^4.2.4 dev_dependencies: flutter_test: diff --git a/waydowntown_app/test/widgets/edit_specification_widget_test.dart b/waydowntown_app/test/widgets/edit_specification_widget_test.dart index a0bccc62..ef282214 100644 --- a/waydowntown_app/test/widgets/edit_specification_widget_test.dart +++ b/waydowntown_app/test/widgets/edit_specification_widget_test.dart @@ -2,7 +2,11 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:geolocator_platform_interface/geolocator_platform_interface.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; @@ -51,6 +55,27 @@ class TestAssetBundle extends CachingAssetBundle { } } +class MockGeolocatorPlatform extends Mock + with MockPlatformInterfaceMixin + implements GeolocatorPlatform { + @override + Future getCurrentPosition({ + LocationSettings? locationSettings, + }) => + Future.value(Position( + latitude: 49.895077, + longitude: -97.138451, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + )); +} + void main() { late Dio dio; late DioAdapter dioAdapter; @@ -118,13 +143,133 @@ another_concept: ] }), ); + + dioAdapter.onGet( + '/waydowntown/regions?filter[position]=49.895077,-97.138451', + (server) => server.reply(200, { + 'data': [ + { + 'id': 'region1', + 'type': 'regions', + 'attributes': { + 'name': 'Region 1', + 'distance': 500, + }, + 'relationships': { + 'parent': { + 'data': {'id': 'region0', 'type': 'regions'} + } + } + }, + { + 'id': 'region2', + 'type': 'regions', + 'attributes': { + 'name': 'Region 2', + 'distance': 1500, + }, + 'relationships': { + 'parent': {'data': null}, + } + }, + { + 'id': 'region3', + 'type': 'regions', + 'attributes': { + 'name': 'Region 3', + 'distance': 2500, + }, + 'relationships': { + 'parent': {'data': null}, + } + } + ], + 'included': [ + { + 'id': 'region0', + 'type': 'regions', + 'attributes': { + 'name': 'Region 0', + 'distance': 5500, + }, + 'relationships': { + 'parent': {'data': null} + } + }, + ] + }), + ); + + GeolocatorPlatform.instance = MockGeolocatorPlatform(); }); - tearDown(() { - testAssetBundle.clear(); + testWidgets( + 'EditSpecificationWidget updates region selection and region sort can be changed', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specification, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Alphabetic sort by default + final azButtonFinder = find.widgetWithText(ElevatedButton, 'A-Z'); + final nearestButtonFinder = find.widgetWithText(ElevatedButton, 'Nearest'); + + ElevatedButton azButton = tester.widget(azButtonFinder); + ElevatedButton nearestButton = tester.widget(nearestButtonFinder); + + expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + equals(Theme.of(tester.element(azButtonFinder)).primaryColor)); + expect(nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + isNot(Theme.of(tester.element(nearestButtonFinder)).primaryColor)); + + await tester.tap(azButtonFinder); + await tester.pumpAndSettle(); + + azButton = tester.widget(azButtonFinder); + nearestButton = tester.widget(nearestButtonFinder); + + expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + equals(Theme.of(tester.element(azButtonFinder)).primaryColor)); + expect( + nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + isNot(equals( + Theme.of(tester.element(nearestButtonFinder)).primaryColor))); + + // No distances in dropdown + await tester.tap(find.text('Region')); + await tester.pumpAndSettle(); + expect(find.text('500 m'), findsNothing); + expect(find.text('2 km'), findsNothing); + expect(find.text('3 km'), findsNothing); + + await tester.tap(nearestButtonFinder); + await tester.pumpAndSettle(); + + azButton = tester.widget(azButtonFinder); + nearestButton = tester.widget(nearestButtonFinder); + + expect(azButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + isNot(equals(Theme.of(tester.element(azButtonFinder)).primaryColor))); + expect(nearestButton.style?.backgroundColor?.resolve({WidgetState.pressed}), + equals(Theme.of(tester.element(nearestButtonFinder)).primaryColor)); + + await tester.tap(find.text('Region')); + await tester.pumpAndSettle(); + expect(find.text('500 m'), findsOneWidget); + expect(find.text('2 km'), findsOneWidget); + expect(find.text('3 km'), findsOneWidget); }); - testWidgets('EditSpecificationWidget updates', (WidgetTester tester) async { + testWidgets('EditSpecificationWidget updates correctly', + (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: DefaultAssetBundle( bundle: testAssetBundle,