diff --git a/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_osm.dart b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_osm.dart new file mode 100644 index 000000000..c67873f47 --- /dev/null +++ b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_osm.dart @@ -0,0 +1,432 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; +import 'package:uni/controller/fetchers/location_fetcher/location_fetcher.dart'; +import 'package:uni/model/entities/indoor_floor_plan.dart'; +import 'package:uni/model/entities/location.dart'; +import 'package:uni/model/entities/location_group.dart'; +import 'package:uni/model/entities/locations/atm.dart'; +import 'package:uni/model/entities/locations/coffee_machine.dart'; +import 'package:uni/model/entities/locations/printer.dart'; +import 'package:uni/model/entities/locations/restaurant_location.dart'; +import 'package:uni/model/entities/locations/room_location.dart'; +import 'package:uni/model/entities/locations/special_room_location.dart'; +import 'package:uni/model/entities/locations/vending_machine.dart'; +import 'package:uni/model/entities/locations/wc_location.dart'; + +class LocationFetcherOSM extends LocationFetcher { + static const double minLat = 41.176; + static const double maxLat = 41.179; + static const double minLon = -8.598; + static const double maxLon = -8.594; + + @override + Future> getLocations() async { + try { + final response = await getData(); + return _parseOSMResponse(response); + } catch (e) { + throw Exception('Failed to fetch from OSM: $e'); + } + } + + Future> getIndoorFloorPlans() async { + try { + final response = await getData(); + return _parseIndoorData(response); + } catch (e) { + throw Exception('Failed to fetch indoor data: $e'); + } + } + + Future getData() async { + debugPrint('⬇️ Fetching OSM data from Overpass API...'); + final response = await _queryOverpass(); + debugPrint('✓ OSM data fetched successfully (${response.body.length} bytes)'); + return response; +} + + Future _queryOverpass() async { + const overpassUrl = 'https://overpass-api.de/api/interpreter'; + + const query = ''' + [out:json][timeout:25]; + ( + // Get FEUP buildings + way["building"]["name"~"FEUP|Faculdade de Engenharia"]($minLat,$minLon,$maxLat,$maxLon); + + // Get indoor features (rooms, corridors, areas) + node["indoor"]($minLat,$minLon,$maxLat,$maxLon); + way["indoor"]($minLat,$minLon,$maxLat,$maxLon); + + // Get amenities + node["amenity"]($minLat,$minLon,$maxLat,$maxLon); + way["amenity"]($minLat,$minLon,$maxLat,$maxLon); + ); + out body; + >; + out skel qt; + '''; + + final response = await http.post( + Uri.parse(overpassUrl), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'data=$query', + ); + + if (response.statusCode != 200) { + throw Exception('Overpass API returned ${response.statusCode}'); + } + + debugPrint('OSM query returned ${response.body.length} bytes'); + return response; + } + + List _parseIndoorData(http.Response response) { + final json = jsonDecode(response.body) as Map; + final elements = json['elements'] as List; + + debugPrint('📍 Parsing indoor data for ALL buildings from ${elements.length} elements'); + + // Build node map ONCE for all buildings + final nodeMap = {}; + for (final elem in elements) { + final element = elem as Map; + if (element['type'] == 'node') { + final id = element['id'] as int; + final lat = (element['lat'] as num?)?.toDouble(); + final lon = (element['lon'] as num?)?.toDouble(); + if (lat != null && lon != null) { + nodeMap[id] = LatLng(lat, lon); + } + } + } + debugPrint(' Built node map with ${nodeMap.length} nodes'); + + // Map: BuildingCode -> Floor -> FloorData + final buildingFloorMap = >{}; + + for (final elem in elements) { + final element = _OSMElement.fromJson(elem as Map); + + // Extract building code from ref + final ref = element.tags['ref'] ?? ''; + if (ref.isEmpty) { + continue; + } + + final buildingCode = _extractBuildingCode(element); + if (buildingCode == null) { + continue; + } + + final floor = _extractFloor(element); + + // Initialize building and floor if needed + buildingFloorMap.putIfAbsent(buildingCode, () => {}); + buildingFloorMap[buildingCode]!.putIfAbsent( + floor, + () => _FloorData(rooms: [], corridors: [], amenities: []), + ); + + // Parse features + if (element.type == 'way' && element.nodes != null) { + final polygon = _buildPolygonFromNodeMap(element.nodes!, nodeMap); + if (polygon.isEmpty) { + continue; + } + + final indoorType = element.tags['indoor']; + + if (indoorType == 'room') { + final roomRef = element.tags['ref'] ?? + element.tags['name'] ?? + 'Room ${element.id}'; + buildingFloorMap[buildingCode]![floor]!.rooms.add( + IndoorRoom( + ref: roomRef, + polygon: polygon, + name: element.tags['name'], + type: element.tags['room'] ?? + element.tags['office'] ?? + element.tags['amenity'], + ), + ); + } else if (indoorType == 'corridor' || indoorType == 'area') { + buildingFloorMap[buildingCode]![floor]!.corridors.add( + IndoorCorridor(polygon: polygon), + ); + } + } else if (element.type == 'node' && element.lat != null && element.lon != null) { + final amenityType = element.tags['amenity']; + if (amenityType != null) { + buildingFloorMap[buildingCode]![floor]!.amenities.add( + IndoorAmenity( + position: LatLng(element.lat!, element.lon!), + type: amenityType, + name: element.tags['name'], + ), + ); + } + } + } + + // Convert to flat list of IndoorFloorPlan + final allPlans = []; + for (final buildingEntry in buildingFloorMap.entries) { + final buildingCode = buildingEntry.key; + debugPrint('Building $buildingCode has ${buildingEntry.value.length} floors'); + + for (final floorEntry in buildingEntry.value.entries) { + final floor = floorEntry.key; + final data = floorEntry.value; + + debugPrint(' Floor $floor: ${data.rooms.length} rooms, ' + '${data.corridors.length} corridors, ${data.amenities.length} amenities'); + + allPlans.add( + IndoorFloorPlan( + buildingId: buildingCode, + floor: floor, + outline: [], + rooms: data.rooms, + corridors: data.corridors, + amenities: data.amenities, + ), + ); + } + } + + debugPrint('✅ Total: ${allPlans.length} floor plans from ${buildingFloorMap.length} buildings'); + return allPlans; + } + + /// Build polygon from node IDs using pre-built node map (performance optimization) + List _buildPolygonFromNodeMap(List nodeIds, Map nodeMap) { + final polygon = []; + + // Build polygon from node IDs + for (final nodeId in nodeIds) { + if (nodeMap.containsKey(nodeId)) { + polygon.add(nodeMap[nodeId]!); + } + } + + return polygon; + } + + Future> _parseOSMResponse(http.Response response) async { + final json = jsonDecode(response.body) as Map; + final elements = json['elements'] as List; + + debugPrint('Parsing ${elements.length} OSM elements for location groups'); + + final Map> buildingGroups = {}; + + for (final elem in elements) { + final element = _OSMElement.fromJson(elem as Map); + final buildingCode = _extractBuildingCode(element); + + if (buildingCode != null) { + buildingGroups.putIfAbsent(buildingCode, () => []); + buildingGroups[buildingCode]!.add(element); + } + } + + final locationGroups = []; + var groupId = 0; + + for (final entry in buildingGroups.entries) { + final elements = entry.value; + final center = _calculateCenter(elements); + final locations = _convertToLocations(elements); + + if (locations.isNotEmpty) { + locationGroups.add( + LocationGroup( + center, + locations: locations, + isFloorless: !_hasFloorData(elements), + id: groupId++, + ), + ); + } + } + + debugPrint('Created ${locationGroups.length} location groups'); + return locationGroups; + } + + String? _extractBuildingCode(_OSMElement element) { + final ref = + element.tags['ref'] ?? element.tags['addr:unit'] ?? element.tags['name']; + + if (ref != null) { + final match = RegExp('^([A-Z])').firstMatch(ref); + if (match != null) { + return match.group(1); + } + } + + return null; + } + + LatLng _calculateCenter(List<_OSMElement> elements) { + var sumLat = 0.0; + var sumLon = 0.0; + var count = 0; + + for (final element in elements) { + if (element.lat != null && element.lon != null) { + sumLat += element.lat!; + sumLon += element.lon!; + count++; + } + } + + if (count == 0) { + return const LatLng(41.1775, -8.596); + } + + return LatLng(sumLat / count, sumLon / count); + } + + List _convertToLocations(List<_OSMElement> elements) { + final locations = []; + + for (final element in elements) { + final floor = _extractFloor(element); + final location = _createLocation(element, floor); + + if (location != null) { + locations.add(location); + } + } + + return locations; + } + + int _extractFloor(_OSMElement element) { + final level = element.tags['level'] ?? + element.tags['floor'] ?? + element.tags['building:levels']; + + if (level != null) { + final parts = level.split(';'); + final firstLevel = int.tryParse(parts.first.trim()); + if (firstLevel != null) { + return firstLevel; + } + } + + final ref = element.tags['ref']; + if (ref != null && ref.length >= 2) { + final floorDigit = ref[1]; + final floor = int.tryParse(floorDigit); + if (floor != null) { + return floor; + } + } + + return 0; + } + + Location? _createLocation(_OSMElement element, int floor) { + final tags = element.tags; + + if (tags['amenity'] == 'vending_machine') { + if (tags['vending'] == 'coffee') { + return CoffeeMachine(floor); + } + return VendingMachine(floor); + } + + if (tags['amenity'] == 'cafe' || tags['amenity'] == 'restaurant') { + final name = tags['name'] ?? 'Café'; + return RestaurantLocation(floor, name); + } + + if (tags['amenity'] == 'toilets') { + return WcLocation(floor); + } + + if (tags['amenity'] == 'atm') { + return Atm(floor); + } + + if (tags['amenity'] == 'printer') { + return Printer(floor); + } + + if (tags['indoor'] == 'room') { + final ref = tags['ref'] ?? tags['name']; + if (ref != null) { + final description = tags['description'] ?? tags['office']; + + if (description != null && description.isNotEmpty) { + return SpecialRoomLocation(floor, ref, description); + } + + return RoomLocation(floor, ref); + } + } + + return null; + } + + bool _hasFloorData(List<_OSMElement> elements) { + return elements.any( + (e) => + e.tags['level'] != null || + e.tags['floor'] != null || + e.tags['building:levels'] != null, + ); + } +} + +class _OSMElement { + _OSMElement({ + required this.id, + required this.type, + required this.tags, + this.lat, + this.lon, + this.nodes, + }); + + factory _OSMElement.fromJson(Map json) { + return _OSMElement( + id: json['id'] as int, + type: json['type'] as String, + tags: Map.from( + (json['tags'] as Map?)?.map( + (key, value) => MapEntry(key, value.toString()), + ) ?? + {}, + ), + lat: (json['lat'] as num?)?.toDouble(), + lon: (json['lon'] as num?)?.toDouble(), + nodes: (json['nodes'] as List?)?.cast(), + ); + } + + final int id; + final String type; + final Map tags; + final double? lat; + final double? lon; + final List? nodes; +} + +class _FloorData { + _FloorData({ + required this.rooms, + required this.corridors, + required this.amenities, + }); + + final List rooms; + final List corridors; + final List amenities; +} diff --git a/packages/uni_app/lib/generated/intl/messages_en.dart b/packages/uni_app/lib/generated/intl/messages_en.dart index 6fb90730a..2d5b27a37 100644 --- a/packages/uni_app/lib/generated/intl/messages_en.dart +++ b/packages/uni_app/lib/generated/intl/messages_en.dart @@ -403,7 +403,7 @@ class MessageLookup extends MessageLookupByLibrary { "save": MessageLookupByLibrary.simpleMessage("Save"), "schedule": MessageLookupByLibrary.simpleMessage("Schedule"), "school_calendar": MessageLookupByLibrary.simpleMessage("School Calendar"), - "search": MessageLookupByLibrary.simpleMessage("Search"), + "search_here": MessageLookupByLibrary.simpleMessage("Search here"), "see_more": MessageLookupByLibrary.simpleMessage("See more"), "select_all": MessageLookupByLibrary.simpleMessage("Select All"), "semester": MessageLookupByLibrary.simpleMessage("Semester"), diff --git a/packages/uni_app/lib/generated/intl/messages_pt_PT.dart b/packages/uni_app/lib/generated/intl/messages_pt_PT.dart index 0d32594cb..b8d91c23a 100644 --- a/packages/uni_app/lib/generated/intl/messages_pt_PT.dart +++ b/packages/uni_app/lib/generated/intl/messages_pt_PT.dart @@ -423,7 +423,7 @@ class MessageLookup extends MessageLookupByLibrary { "school_calendar": MessageLookupByLibrary.simpleMessage( "Calendário Escolar", ), - "search": MessageLookupByLibrary.simpleMessage("Pesquisar"), + "search_here": MessageLookupByLibrary.simpleMessage("Pesquisar aqui"), "see_more": MessageLookupByLibrary.simpleMessage("Ver mais"), "select_all": MessageLookupByLibrary.simpleMessage("Selecionar Todos"), "semester": MessageLookupByLibrary.simpleMessage("Semestre"), diff --git a/packages/uni_app/lib/generated/l10n.dart b/packages/uni_app/lib/generated/l10n.dart index d713a336e..a773afe06 100644 --- a/packages/uni_app/lib/generated/l10n.dart +++ b/packages/uni_app/lib/generated/l10n.dart @@ -1483,9 +1483,9 @@ class S { return Intl.message('See more', name: 'see_more', desc: '', args: []); } - /// `Search` - String get search { - return Intl.message('Search', name: 'search', desc: '', args: []); + /// `Search_here` + String get search_here { + return Intl.message('Search here', name: 'search_here', desc: '', args: []); } /// `Do you really want to log out? Your local data will be deleted and you will have to log in again.` diff --git a/packages/uni_app/lib/l10n/intl_en.arb b/packages/uni_app/lib/l10n/intl_en.arb index e7e9a27a7..e0602cfdf 100644 --- a/packages/uni_app/lib/l10n/intl_en.arb +++ b/packages/uni_app/lib/l10n/intl_en.arb @@ -350,7 +350,7 @@ "@year": {}, "see_more": "See more", "@see_more": {}, - "search": "Search", + "search_here": "Search here", "@search": {}, "confirm_logout": "Do you really want to log out? Your local data will be deleted and you will have to log in again.", "@confirm_logout": {}, diff --git a/packages/uni_app/lib/l10n/intl_pt_PT.arb b/packages/uni_app/lib/l10n/intl_pt_PT.arb index 10587cafd..d39239b94 100644 --- a/packages/uni_app/lib/l10n/intl_pt_PT.arb +++ b/packages/uni_app/lib/l10n/intl_pt_PT.arb @@ -350,7 +350,7 @@ "@widget_prompt": {}, "year": "Ano", "@year": {}, - "search": "Pesquisar", + "search_here": "Pesquisar aqui", "@search": {}, "confirm_logout": "Tens a certeza de que queres terminar sessão? Os teus dados locais serão apagados e terás de iniciar sessão novamente.", "@confirm_logout": {}, diff --git a/packages/uni_app/lib/model/entities/indoor_floor_plan.dart b/packages/uni_app/lib/model/entities/indoor_floor_plan.dart new file mode 100644 index 000000000..371a6fac0 --- /dev/null +++ b/packages/uni_app/lib/model/entities/indoor_floor_plan.dart @@ -0,0 +1,52 @@ +import 'package:latlong2/latlong.dart'; + +/// Represents an indoor floor plan with its geometry and features +class IndoorFloorPlan { + IndoorFloorPlan({ + required this.buildingId, + required this.floor, + required this.outline, + required this.rooms, + required this.corridors, + required this.amenities, + }); + + final String buildingId; + final int floor; + final List outline; // Building outline for this floor + final List rooms; + final List corridors; + final List amenities; +} + +class IndoorRoom { + IndoorRoom({ + required this.ref, + required this.polygon, + this.name, + this.type, + }); + + final String ref; // Room number (e.g., "B101") + final List polygon; // Room outline + final String? name; + final String? type; // office, classroom, lab, etc. +} + +class IndoorCorridor { + IndoorCorridor({required this.polygon}); + + final List polygon; +} + +class IndoorAmenity { + IndoorAmenity({ + required this.position, + required this.type, + this.name, + }); + + final LatLng position; + final String type; // toilets, vending_machine, etc. + final String? name; +} diff --git a/packages/uni_app/lib/model/providers/riverpod/faculty_locations_provider.dart b/packages/uni_app/lib/model/providers/riverpod/faculty_locations_provider.dart index f12ba3db7..993a092dc 100644 --- a/packages/uni_app/lib/model/providers/riverpod/faculty_locations_provider.dart +++ b/packages/uni_app/lib/model/providers/riverpod/faculty_locations_provider.dart @@ -1,13 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uni/controller/fetchers/location_fetcher/location_fetcher_asset.dart'; +import 'package:uni/controller/fetchers/location_fetcher/location_fetcher_osm.dart'; +import 'package:uni/model/entities/indoor_floor_plan.dart'; import 'package:uni/model/entities/location_group.dart'; import 'package:uni/model/providers/riverpod/cached_async_notifier.dart'; +// Provider for location groups (map markers) final locationsProvider = AsyncNotifierProvider?>( FacultyLocationsNotifier.new, ); +// Provider for indoor floor plans (building layouts) +final indoorFloorPlansProvider = + AsyncNotifierProvider?>( + IndoorFloorPlansNotifier.new, + ); + class FacultyLocationsNotifier extends CachedAsyncNotifier> { @override @@ -20,6 +29,37 @@ class FacultyLocationsNotifier @override Future> loadFromRemote() async { - return state.value!; + try { + final osmData = await LocationFetcherOSM().getLocations(); + + if (osmData.isNotEmpty) { + return osmData; + } + + return await loadFromStorage(); + } catch (e) { + return loadFromStorage(); + } + } +} + +class IndoorFloorPlansNotifier + extends CachedAsyncNotifier> { + @override + Duration? get cacheDuration => const Duration(days: 30); + + @override + Future> loadFromStorage() async { + // TODO: Load from asset JSON as fallback + return []; + } + + @override + Future> loadFromRemote() async { + try { + return await LocationFetcherOSM().getIndoorFloorPlans(); + } catch (e) { + return loadFromStorage(); + } } } diff --git a/packages/uni_app/lib/view/map/map.dart b/packages/uni_app/lib/view/map/map.dart index 5bb598369..a891e19c0 100644 --- a/packages/uni_app/lib/view/map/map.dart +++ b/packages/uni_app/lib/view/map/map.dart @@ -7,10 +7,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:uni/controller/networking/url_launcher.dart'; import 'package:uni/generated/l10n.dart'; +import 'package:uni/model/entities/indoor_floor_plan.dart'; import 'package:uni/model/entities/location_group.dart'; import 'package:uni/model/providers/riverpod/default_consumer.dart'; import 'package:uni/model/providers/riverpod/faculty_locations_provider.dart'; +import 'package:uni/view/map/widgets/floor_selector.dart'; import 'package:uni/view/map/widgets/floorless_marker_popup.dart'; +import 'package:uni/view/map/widgets/indoor_floor_layer.dart'; import 'package:uni/view/map/widgets/marker.dart'; import 'package:uni/view/map/widgets/marker_popup.dart'; import 'package:uni/view/widgets/pages_layouts/general/widgets/bottom_navigation_bar.dart'; @@ -29,12 +32,15 @@ class MapPageStateView extends ConsumerState { var _searchTerms = ''; late final PopupController _popupLayerController; LatLngBounds? _bounds; + int? _selectedFloor; + bool _showIndoorLayer = false; @override void initState() { super.initState(); _searchTerms = ''; _popupLayerController = PopupController(); + _selectedFloor = null; } @override @@ -45,9 +51,17 @@ class MapPageStateView extends ConsumerState { @override Widget build(BuildContext context) { + + final indoorPlansAsync = ref.watch(indoorFloorPlansProvider); + return DefaultConsumer>( provider: locationsProvider, builder: (context, ref, locations) { + final indoorPlans = indoorPlansAsync.when( + data: (plans) => plans ?? [], + loading: () => [], + error: (_, _) => [], + ); var bounds = _bounds; bounds ??= LatLngBounds.fromPoints( locations.map((location) => location.latlng).toList(), @@ -67,6 +81,16 @@ class MapPageStateView extends ConsumerState { }); } + if (_selectedFloor != null) { + filteredLocations.retainWhere((location) { + return location.floors.containsKey(_selectedFloor); + }); + } + + final allFloors = + locations.expand((group) => group.floors.keys).toSet().toList() + ..sort((a, b) => b.compareTo(a)); + return AnnotatedRegion( value: AppSystemOverlayStyles.base.copyWith( statusBarIconBrightness: Brightness.dark, @@ -82,13 +106,14 @@ class MapPageStateView extends ConsumerState { minZoom: 16, maxZoom: 19, initialCenter: bounds.center, - initialCameraFit: CameraFit.insideBounds(bounds: bounds), + initialZoom: 17, cameraConstraint: CameraConstraint.containCenter( bounds: bounds, ), - onTap: - (tapPosition, latlng) => - _popupLayerController.hideAllPopups(), + onTap: (tapPosition, latlng) { + _popupLayerController.hideAllPopups(); + FocusScope.of(context).unfocus(); + }, interactionOptions: const InteractionOptions( flags: InteractiveFlag.all - InteractiveFlag.rotate, ), @@ -104,6 +129,11 @@ class MapPageStateView extends ConsumerState { retinaMode: RetinaMode.isHighDensity(context), maxNativeZoom: 20, ), + if (_showIndoorLayer && _selectedFloor != null) + IndoorFloorLayer( + floorPlans: indoorPlans, + selectedFloor: _selectedFloor, + ), PopupMarkerLayer( options: PopupMarkerLayerOptions( markers: @@ -128,40 +158,50 @@ class MapPageStateView extends ConsumerState { ), ), ), - PopupMarkerLayer( - options: PopupMarkerLayerOptions( - markers: - filteredLocations.map((location) { - return LocationMarker(location.latlng, location); - }).toList(), - popupController: _popupLayerController, - popupDisplayOptions: PopupDisplayOptions( - animation: const PopupAnimation.fade( - duration: Duration(milliseconds: 400), + Positioned( + right: 10, + bottom: 650, + child: SafeArea( + child: FloatingActionButton( + mini: true, + onPressed: () { + setState(() { + _showIndoorLayer = !_showIndoorLayer; + }); + }, + child: Icon( + _showIndoorLayer ? Icons.layers_clear : Icons.layers, ), - builder: (_, marker) { - if (marker is LocationMarker) { - return marker.locationGroup.isFloorless - ? FloorlessLocationMarkerPopup( - marker.locationGroup, - ) - : LocationMarkerPopup(marker.locationGroup); - } - return const Card(child: Text('')); + ), + ), + ), + Positioned( + right: 10, + top: 400, + child: SafeArea( + child: FloorSelector( + floors: allFloors, + selectedFloor: _selectedFloor, + onFloorSelected: (floor) { + setState(() { + _selectedFloor = floor; + _popupLayerController.hideAllPopups(); + }); }, ), ), ), SafeArea( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, + padding: const EdgeInsets.only( + left: 10, + right: 10, + top: 12, ), child: PhysicalModel( borderRadius: BorderRadius.circular(10), - color: Colors.white, - elevation: 3, + color: const Color(0xFFFFF5F3), + elevation: 4, child: TextFormField( key: searchFormKey, onChanged: (text) { @@ -179,7 +219,8 @@ class MapPageStateView extends ConsumerState { child: SvgPicture.asset( 'assets/images/logo_dark.svg', semanticsLabel: 'search', - width: 10, + width: 44, + height: 25, ), ), border: OutlineInputBorder( @@ -187,7 +228,13 @@ class MapPageStateView extends ConsumerState { borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.all(10), - hintText: '${S.of(context).search}...', + hintText: S.of(context).search_here, + hintStyle: const TextStyle( + fontFamily: 'Roboto', + fontSize: 9, + fontWeight: FontWeight.w400, + color: Color(0xFF7F7F7F), + ), ), ), ), diff --git a/packages/uni_app/lib/view/map/widgets/floor_selector.dart b/packages/uni_app/lib/view/map/widgets/floor_selector.dart new file mode 100644 index 000000000..4770e6fa3 --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/floor_selector.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:uni_ui/theme.dart'; + +class FloorSelector extends StatelessWidget { + const FloorSelector({ + required this.floors, + required this.selectedFloor, + required this.onFloorSelected, + super.key, + }); + + final List floors; + final int? selectedFloor; + final void Function(int?) onFloorSelected; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: const Color(0xFFFFF5F3), + borderRadius: BorderRadius.circular(8), + boxShadow: const [BoxShadow(color: Color(0x40000000), blurRadius: 4)], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: + floors.map((floor) { + final isSelected = selectedFloor == floor; + return InkWell( + onTap: () => onFloorSelected(isSelected ? null : floor), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: + isSelected + ? const Color(0x40B14D54) + : Colors.transparent, + ), + child: Center( + child: Text( + floor.toString(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? primaryVibrant : grayText, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/packages/uni_app/lib/view/map/widgets/indoor_floor_layer.dart b/packages/uni_app/lib/view/map/widgets/indoor_floor_layer.dart new file mode 100644 index 000000000..6ac79787d --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/indoor_floor_layer.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:uni/model/entities/indoor_floor_plan.dart'; + +class IndoorFloorLayer extends StatelessWidget { + const IndoorFloorLayer({ + required this.floorPlans, + required this.selectedFloor, + super.key, + }); + + final List floorPlans; + final int? selectedFloor; + + @override + Widget build(BuildContext context) { + if (selectedFloor == null) { + return const SizedBox.shrink(); + } + + // Filter floor plans for selected floor + final currentFloorPlans = floorPlans + .where((plan) => plan.floor == selectedFloor) + .toList(); + + if (currentFloorPlans.isEmpty) { + return const SizedBox.shrink(); + } + + return Stack( + children: [ + // Rooms layer + PolygonLayer( + polygons: currentFloorPlans + .expand( + (plan) => plan.rooms.map( + (room) => Polygon( + points: room.polygon, + color: Colors.blue.withValues(alpha: 0.2), + borderColor: Colors.blue, + borderStrokeWidth: 2, + label: room.ref, + labelStyle: const TextStyle( + color: Colors.black87, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + .toList(), + ), + // Corridors layer + PolygonLayer( + polygons: currentFloorPlans + .expand( + (plan) => plan.corridors.map( + (corridor) => Polygon( + points: corridor.polygon, + color: Colors.grey.withOpacity(0.1), + borderColor: Colors.grey, + borderStrokeWidth: 1, + ), + ), + ) + .toList(), + ), + // Amenities markers + MarkerLayer( + markers: currentFloorPlans + .expand( + (plan) => plan.amenities.map( + (amenity) => Marker( + point: amenity.position, + child: Icon( + _getAmenityIcon(amenity.type), + color: _getAmenityColor(amenity.type), + size: 20, + ), + ), + ), + ) + .toList(), + ), + ], + ); + } + + IconData _getAmenityIcon(String type) { + switch (type) { + case 'toilets': + return Icons.wc; + case 'vending_machine': + return Icons.local_drink; + case 'atm': + return Icons.atm; + case 'printer': + return Icons.print; + case 'cafe': + case 'restaurant': + return Icons.restaurant; + default: + return Icons.place; + } + } + + Color _getAmenityColor(String type) { + switch (type) { + case 'toilets': + return Colors.purple; + case 'vending_machine': + return Colors.orange; + case 'atm': + return Colors.green; + case 'printer': + return Colors.blue; + case 'cafe': + case 'restaurant': + return Colors.brown; + default: + return Colors.grey; + } + } +}