diff --git a/waydowntown_app/lib/models/region.dart b/waydowntown_app/lib/models/region.dart index 1cf6cce2..3c34497d 100644 --- a/waydowntown_app/lib/models/region.dart +++ b/waydowntown_app/lib/models/region.dart @@ -5,14 +5,16 @@ class Region { final double? latitude; final double? longitude; Region? parentRegion; + List children = []; - Region( - {required this.id, - required this.name, - this.description, - this.parentRegion, - this.latitude, - this.longitude}); + Region({ + required this.id, + required this.name, + this.description, + this.parentRegion, + this.latitude, + this.longitude, + }); factory Region.fromJson(Map json, List included) { final attributes = json['attributes']; @@ -45,4 +47,40 @@ class Region { return region; } + + static List parseRegions(Map apiResponse) { + final List data = apiResponse['data']; + + Map regionMap = {}; + + // Extract all regions + for (var item in data) { + if (item['type'] == 'regions') { + Region region = Region.fromJson(item, []); + regionMap[region.id] = region; + } + } + + // Nest children + for (var item in data) { + if (item['type'] == 'regions' && item['relationships'] != null) { + var relationships = item['relationships']; + if (relationships['parent'] != null && + relationships['parent']['data'] != null) { + String parentId = relationships['parent']['data']['id']; + Region? parentRegion = regionMap[parentId]; + Region? childRegion = regionMap[item['id']]; + if (parentRegion != null && childRegion != null) { + childRegion.parentRegion = parentRegion; + parentRegion.children.add(childRegion); + } + } + } + } + + // Return only root regions + return regionMap.values + .where((region) => region.parentRegion == null) + .toList(); + } } diff --git a/waydowntown_app/lib/widgets/edit_specification_widget.dart b/waydowntown_app/lib/widgets/edit_specification_widget.dart index a8f38c7b..9e0592b2 100644 --- a/waydowntown_app/lib/widgets/edit_specification_widget.dart +++ b/waydowntown_app/lib/widgets/edit_specification_widget.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; import 'package:yaml/yaml.dart'; @@ -23,6 +24,8 @@ class EditSpecificationWidgetState extends State { late TextEditingController _taskDescriptionController; late TextEditingController _durationController; String? _selectedConcept; + String? _selectedRegionId; + List _regions = []; Map _fieldErrors = {}; @override @@ -35,6 +38,21 @@ class EditSpecificationWidgetState extends State { _durationController = TextEditingController( text: widget.specification.duration?.toString() ?? ''); _selectedConcept = widget.specification.concept; + _selectedRegionId = widget.specification.region?.id; + _loadRegions(); + } + + Future _loadRegions() async { + try { + final response = await widget.dio.get('/waydowntown/regions'); + if (response.statusCode == 200) { + setState(() { + _regions = Region.parseRegions(response.data); + }); + } + } catch (e) { + talker.error('Error loading regions: $e'); + } } Future _loadConcepts(context) async { @@ -66,6 +84,7 @@ class EditSpecificationWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildConceptDropdown(snapshot.data), + _buildRegionDropdown(), _buildTextField('Start Description', _startDescriptionController, 'start_description'), _buildTextField('Task Description', @@ -121,6 +140,37 @@ class EditSpecificationWidgetState extends State { ); } + Widget _buildRegionDropdown() { + return DropdownButtonFormField( + value: _selectedRegionId, + decoration: InputDecoration( + labelText: 'Region', + errorText: _fieldErrors['region_id'], + ), + items: _buildRegionItems(_regions, 0), + onChanged: (String? newValue) { + setState(() { + _selectedRegionId = newValue; + }); + }, + ); + } + + List> _buildRegionItems( + List regions, int depth) { + List> items = []; + for (var region in regions) { + items.add(DropdownMenuItem( + value: region.id, + child: Text('${' ' * depth}${region.name}'), + )); + if (region.children.isNotEmpty) { + items.addAll(_buildRegionItems(region.children, depth + 1)); + } + } + return items; + } + Widget _buildTextField( String label, TextEditingController controller, String fieldName) { return TextField( @@ -185,6 +235,7 @@ class EditSpecificationWidgetState extends State { 'start_description': _startDescriptionController.text, 'task_description': _taskDescriptionController.text, 'duration': int.tryParse(_durationController.text), + 'region_id': _selectedRegionId, }, }, }, diff --git a/waydowntown_app/test/models/region_test.dart b/waydowntown_app/test/models/region_test.dart new file mode 100644 index 00000000..09480e81 --- /dev/null +++ b/waydowntown_app/test/models/region_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:waydowntown/models/region.dart'; + +void main() { + group('Region', () { + test('parseRegions parses API response and nests regions', () { + final apiResponse = { + 'data': [ + { + 'id': '1', + 'type': 'regions', + 'attributes': { + 'name': 'Region 1', + 'description': 'Root region', + 'latitude': '45.0', + 'longitude': '-75.0', + }, + 'relationships': { + 'parent': {'data': null} + } + }, + { + 'id': '2', + 'type': 'regions', + 'attributes': { + 'name': 'Region 2', + 'description': 'Child of Region 1', + }, + 'relationships': { + 'parent': { + 'data': {'id': '1', 'type': 'regions'} + } + } + }, + { + 'id': '3', + 'type': 'regions', + 'attributes': { + 'name': 'Region 3', + 'description': 'Child of Region 2', + }, + 'relationships': { + 'parent': { + 'data': {'id': '2', 'type': 'regions'} + } + } + }, + ] + }; + + List rootRegions = Region.parseRegions(apiResponse); + + expect(rootRegions.length, 1); + expect(rootRegions[0].id, '1'); + expect(rootRegions[0].name, 'Region 1'); + expect(rootRegions[0].description, 'Root region'); + expect(rootRegions[0].latitude, 45.0); + expect(rootRegions[0].longitude, -75.0); + expect(rootRegions[0].parentRegion, null); + expect(rootRegions[0].children.length, 1); + + Region region2 = rootRegions[0].children[0]; + expect(region2.id, '2'); + expect(region2.name, 'Region 2'); + expect(region2.description, 'Child of Region 1'); + expect(region2.parentRegion, rootRegions[0]); + expect(region2.children.length, 1); + + Region region3 = region2.children[0]; + expect(region3.id, '3'); + expect(region3.name, 'Region 3'); + expect(region3.description, 'Child of Region 2'); + expect(region3.parentRegion, region2); + expect(region3.children.length, 0); + }); + }); +} diff --git a/waydowntown_app/test/test_helpers.dart b/waydowntown_app/test/test_helpers.dart index d643ef6a..1bb63aa6 100644 --- a/waydowntown_app/test/test_helpers.dart +++ b/waydowntown_app/test/test_helpers.dart @@ -168,6 +168,7 @@ class TestHelpers { DateTime? startedAt, int? durationSeconds = 300, List? answers, + Region? region, }) { return Run( id: '22261813-2171-453f-a669-db08edc70d6d', @@ -184,17 +185,18 @@ class TestHelpers { const Answer(id: '3', label: 'Answer 3'), ], duration: durationSeconds, - region: Region( - id: '324fd8f9-cd25-48be-a761-b8680fa72737', - name: 'Test Region', - latitude: latitude, - longitude: longitude, - parentRegion: Region( - id: '67cc2c5c-06c2-4e86-9aac-b575fc712862', - name: 'Parent Region', - description: null, - ), - ), + region: region ?? + Region( + id: '324fd8f9-cd25-48be-a761-b8680fa72737', + name: 'Test Region', + latitude: latitude, + longitude: longitude, + parentRegion: Region( + id: '67cc2c5c-06c2-4e86-9aac-b575fc712862', + name: 'Parent Region', + description: null, + ), + ), ), correctSubmissions: correctAnswers, totalAnswers: totalAnswers, diff --git a/waydowntown_app/test/widgets/edit_specification_widget_test.dart b/waydowntown_app/test/widgets/edit_specification_widget_test.dart index ad373666..a0bccc62 100644 --- a/waydowntown_app/test/widgets/edit_specification_widget_test.dart +++ b/waydowntown_app/test/widgets/edit_specification_widget_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; +import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; import 'package:waydowntown/widgets/edit_specification_widget.dart'; @@ -80,8 +81,43 @@ another_concept: concept: 'bluetooth_collector', start: 'This is the start', description: 'This is the task', - durationSeconds: 100) + durationSeconds: 100, + region: Region(id: 'region1', name: 'Region 1')) .specification; + + dioAdapter.onGet( + '/waydowntown/regions', + (server) => server.reply(200, { + 'data': [ + { + 'id': 'region1', + 'type': 'regions', + 'attributes': {'name': 'Region 1'}, + 'relationships': { + 'parent': {'data': null} + } + }, + { + 'id': 'region2', + 'type': 'regions', + 'attributes': {'name': 'Region 2'}, + 'relationships': { + 'parent': { + 'data': {'id': 'region1', 'type': 'regions'} + }, + } + }, + { + 'id': 'region3', + 'type': 'regions', + 'attributes': {'name': 'Region 3'}, + 'relationships': { + 'parent': {'data': null}, + } + } + ] + }), + ); }); tearDown(() { @@ -105,12 +141,19 @@ another_concept: expect(find.text(specification.startDescription!), findsOneWidget); expect(find.text(specification.taskDescription!), findsOneWidget); expect(find.text(specification.duration.toString()), findsOneWidget); + expect(find.text('Region 1'), findsOneWidget); - await tester.tap(find.byType(DropdownButtonFormField)); + await tester.tap(find.byType(DropdownButtonFormField).first); await tester.pumpAndSettle(); await tester.tap(find.text('Fill in the Blank').last); await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownButtonFormField).last); + await tester.pumpAndSettle(); + expect(find.text(' Region 2'), findsOneWidget); // Assert on nesting + await tester.tap(find.text('Region 3').last); + await tester.pumpAndSettle(); + await tester.enterText( find.widgetWithText(TextField, 'Start Description'), 'New start'); await tester.enterText( @@ -132,6 +175,7 @@ another_concept: 'start_description': 'New start', 'task_description': 'New task', 'duration': 60, + 'region_id': 'region3', }, }, },