diff --git a/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher.dart b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher.dart index 6bbdf2623..1d6e4fc7a 100644 --- a/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher.dart +++ b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher.dart @@ -1,11 +1,18 @@ import 'dart:convert'; import 'package:latlong2/latlong.dart'; +import 'package:uni/model/entities/faculty_config.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'; abstract class LocationFetcher { + LocationFetcher(this.facultyConfig); + + final FacultyConfig facultyConfig; + Future> getLocations(); + Future> getIndoorFloorPlans(); Future> getFromJSON(String jsonStr) async { final json = jsonDecode(jsonStr) as Map; diff --git a/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_asset.dart b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_asset.dart index 621adf857..dcc9d6c60 100644 --- a/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_asset.dart +++ b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_asset.dart @@ -1,11 +1,23 @@ import 'package:flutter/services.dart' show rootBundle; 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_group.dart'; class LocationFetcherAsset extends LocationFetcher { + LocationFetcherAsset(super.facultyConfig); + @override Future> getLocations() async { - final json = await rootBundle.loadString('assets/text/locations/feup.json'); + if (facultyConfig.assetPath == null) { + throw Exception('No asset fallback available for ${facultyConfig.name}'); + } + final json = await rootBundle.loadString(facultyConfig.assetPath!); return getFromJSON(json); } + + @override + Future> getIndoorFloorPlans() async { + // No asset-based indoor floor plans available yet. + return []; + } } 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..da45916b4 --- /dev/null +++ b/packages/uni_app/lib/controller/fetchers/location_fetcher/location_fetcher_osm.dart @@ -0,0 +1,504 @@ +import 'dart:convert'; +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/parking.dart'; +import 'package:uni/model/entities/locations/printer.dart'; +import 'package:uni/model/entities/locations/restaurant_location.dart'; +import 'package:uni/model/entities/locations/store_location.dart'; +import 'package:uni/model/entities/locations/vending_machine.dart'; +import 'package:uni/model/entities/locations/wc_location.dart'; + +class LocationFetcherOSM extends LocationFetcher { + LocationFetcherOSM(super.facultyConfig); + + Future? _response; + + @override + Future> getLocations() async { + try { + final response = await (_response ??= _queryOverpass()); + return _parseLocations(response); + } catch (err) { + _response = null; + throw Exception('[OSM] Failed to fetch locations: $err'); + } + } + + @override + Future> getIndoorFloorPlans() async { + try { + final response = await (_response ??= _queryOverpass()); + return _parseIndoorData(response); + } catch (err) { + _response = null; + throw Exception('[OSM] Failed to fetch indoor data: $err'); + } + } + + Future _queryOverpass() async { + const overpassUrl = 'https://overpass-api.de/api/interpreter'; + const maxRetries = 10; + + final bounds = facultyConfig.bounds; + final query = + ''' + [out:json][timeout:25]; + ( + // Get ${facultyConfig.name} buildings + way["building"]["name"~"${facultyConfig.name}|Faculdade"](${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon}); + + // Get indoor features + node["indoor"](${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon}); + way["indoor"](${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon}); + + // Get amenities + node["amenity"](${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon}); + way["amenity"](${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon}); + ); + out body; + >; + out skel qt; + '''; + + for (var attempt = 0; attempt < maxRetries; attempt++) { + try { + final response = await http + .post( + Uri.parse(overpassUrl), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'uni_app/map_fetcher (uni)', + }, + body: 'data=$query', + ) + .timeout(const Duration(seconds: 90)); + + if (response.statusCode == 200) { + return response; + } + + if (response.statusCode == 504 && attempt < maxRetries - 1) { + continue; + } + + throw Exception('[OSM] Overpass API returned ${response.statusCode}'); + } on Exception { + if (attempt >= maxRetries - 1) { + rethrow; + } + } + } + + throw Exception('[OSM] Overpass API failed after $maxRetries retries'); + } + + List _parseIndoorData(http.Response response) { + final json = jsonDecode(response.body) as Map; + final elements = json['elements'] as List; + + 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); + } + } + } + + // Map: BuildingCode -> Floor -> FloorData + final buildingFloorMap = >{}; + + for (final elem in elements) { + final element = _OSMElement.fromJson(elem as Map); + + final isAmenity = + element.tags['amenity'] != null && + (element.lat != null && element.lon != null || + element.nodes != null && element.nodes!.isNotEmpty); + + if (isAmenity) { + final buildingCode = _extractBuildingCode(element) ?? 'NO_BUILDING'; + final floor = _extractFloor(element); + + LatLng? position; + if (element.lat != null && element.lon != null) { + position = LatLng(element.lat!, element.lon!); + } else if (element.nodes != null && element.nodes!.isNotEmpty) { + // Calculate centroid from node positions + final nodePositions = element.nodes! + .map((id) => nodeMap[id]) + .where((pos) => pos != null) + .cast() + .toList(); + if (nodePositions.isNotEmpty) { + final avgLat = + nodePositions.map((p) => p.latitude).reduce((a, b) => a + b) / + nodePositions.length; + final avgLon = + nodePositions.map((p) => p.longitude).reduce((a, b) => a + b) / + nodePositions.length; + position = LatLng(avgLat, avgLon); + } + } + + if (position != null) { + buildingFloorMap.putIfAbsent(buildingCode, () => {}); + buildingFloorMap[buildingCode]!.putIfAbsent( + floor, + () => _FloorData(rooms: [], corridors: [], amenities: []), + ); + + buildingFloorMap[buildingCode]![floor]!.amenities.add( + IndoorAmenity( + position: position, + type: element.tags['amenity']!, + name: element.tags['name'], + ), + ); + } + continue; + } + + // 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.type == 'way' && 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; + + for (final floorEntry in buildingEntry.value.entries) { + final floor = floorEntry.key; + final data = floorEntry.value; + + allPlans.add( + IndoorFloorPlan( + buildingId: buildingCode, + floor: floor, + outline: [], + rooms: data.rooms, + corridors: data.corridors, + amenities: data.amenities, + ), + ); + } + } + + 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; + } + + List _parseLocations(http.Response response) { + final json = jsonDecode(response.body) as Map; + final elements = json['elements'] as List; + + // Build node map so way centroids can be computed. + final nodeMap = {}; + final allElements = <_OSMElement>[]; + for (final elem in elements) { + final element = _OSMElement.fromJson(elem as Map); + allElements.add(element); + if (element.type == 'node' && + element.lat != null && + element.lon != null) { + nodeMap[element.id] = LatLng(element.lat!, element.lon!); + } + } + + final locationGroups = []; + + for (final element in allElements) { + final amenityType = element.tags['amenity']; + if (amenityType == null) { + continue; // rooms / buildings handled by indoor layer + } + + final position = _resolvePosition(element, nodeMap); + if (position == null) { + continue; + } + + final floor = _extractFloor(element); + if (_shouldSkipLocationMarker(element, floor)) { + continue; + } + final location = _createLocation(element, floor); + if (location == null) { + continue; + } + + final isFloorless = + element.tags['level'] == null && element.tags['floor'] == null; + + locationGroups.add( + LocationGroup( + position, + locations: [location], + isFloorless: isFloorless, + id: locationGroups.length, + ), + ); + } + return locationGroups; + } + + bool _shouldSkipLocationMarker(_OSMElement element, int floor) { + // Library only have toilets in -1 + if (facultyConfig.id != 'feup' || element.tags['amenity'] != 'toilets') { + return false; + } + + if (floor < 0 || floor > 6) { + return false; + } + + return _extractBuildingCode(element) == 'C'; + } + + // For nodes: the direct lat/lon. For ways: the centroid of their nodes. + LatLng? _resolvePosition(_OSMElement element, Map nodeMap) { + if (element.lat != null && element.lon != null) { + return LatLng(element.lat!, element.lon!); + } + if (element.nodes != null && element.nodes!.isNotEmpty) { + final pts = element.nodes! + .map((id) => nodeMap[id]) + .whereType() + .toList(); + if (pts.isEmpty) { + return null; + } + return _centroidOf(pts); + } + return null; + } + + LatLng _centroidOf(List positions) { + final avgLat = + positions.map((p) => p.latitude).reduce((a, b) => a + b) / + positions.length; + final avgLon = + positions.map((p) => p.longitude).reduce((a, b) => a + b) / + positions.length; + return LatLng(avgLat, avgLon); + } + + String? _extractBuildingCode(_OSMElement element) { + final ref = + element.tags['ref'] ?? + element.tags['addr:unit'] ?? + element.tags['name']; + + if (ref != null) { + final match = facultyConfig.buildingCodePattern.firstMatch(ref); + if (match != null) { + return match.group(1); + } + } + + return null; + } + + 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' || + tags['amenity'] == 'canteen' || + tags['amenity'] == 'fast_food') { + 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['amenity'] == 'parking') { + final name = tags['name'] ?? 'parking'; + return CarPark(floor, name); + } + + if (tags['amenity'] == 'shop') { + final name = tags['name'] ?? 'shop'; + return StoreLocation(floor, name); + } + + return 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/controller/local_storage/migrations/migration_controller.dart b/packages/uni_app/lib/controller/local_storage/migrations/migration_controller.dart index ce024a11a..cf59bbd5c 100644 --- a/packages/uni_app/lib/controller/local_storage/migrations/migration_controller.dart +++ b/packages/uni_app/lib/controller/local_storage/migrations/migration_controller.dart @@ -3,7 +3,7 @@ import 'package:uni/controller/local_storage/migrations/migrations.dart'; import 'package:uni/controller/local_storage/preferences_controller.dart'; class MigrationController { - static const currentPreferencesVersion = 2; + static const currentPreferencesVersion = 3; static Future runMigrations() async { final storedVersion = PreferencesController.getPreferencesVersion(); @@ -26,6 +26,9 @@ class MigrationController { case 1: Logger().d('Migrating Shared Preferences Version (1 -> 2)'); await Migrations.migrateToV2(); + case 2: + Logger().d('Migrating Shared Preferences Version (2 -> 3)'); + await Migrations.migrateToV3(); default: Logger().d('No migration found'); } diff --git a/packages/uni_app/lib/controller/local_storage/migrations/migrations.dart b/packages/uni_app/lib/controller/local_storage/migrations/migrations.dart index fa0c2f431..b445d6c94 100644 --- a/packages/uni_app/lib/controller/local_storage/migrations/migrations.dart +++ b/packages/uni_app/lib/controller/local_storage/migrations/migrations.dart @@ -9,4 +9,10 @@ class Migrations { // the easiest solution is to reset only this preference to the default one, which is not that bad as the homepage is fresh new await PreferencesController.setDefaultCards(); } + + static Future migrateToV3() async { + // Clear the map cache update times so that the app fetches the new locations/markers from remote + // instead of waiting for the cache to expire. + await PreferencesController.clearMapCache(); + } } diff --git a/packages/uni_app/lib/controller/local_storage/preferences_controller.dart b/packages/uni_app/lib/controller/local_storage/preferences_controller.dart index e79d98d95..3653c8ba4 100644 --- a/packages/uni_app/lib/controller/local_storage/preferences_controller.dart +++ b/packages/uni_app/lib/controller/local_storage/preferences_controller.dart @@ -94,6 +94,11 @@ class PreferencesController { ); } + static Future clearMapCache() async { + await prefs.remove('FacultyLocationsNotifier$_lastUpdateTimeKeySuffix'); + await prefs.remove('IndoorFloorPlansNotifier$_lastUpdateTimeKeySuffix'); + } + /// Saves the user's student number, password and faculties. static const _secureStorage = FlutterSecureStorage(); static Future saveSession(Session session) async { diff --git a/packages/uni_app/lib/generated/intl/messages_en.dart b/packages/uni_app/lib/generated/intl/messages_en.dart index d46e7c889..d8253e94c 100644 --- a/packages/uni_app/lib/generated/intl/messages_en.dart +++ b/packages/uni_app/lib/generated/intl/messages_en.dart @@ -63,6 +63,7 @@ class MessageLookup extends MessageLookupByLibrary { "at_least_one_college": MessageLookupByLibrary.simpleMessage( "Select at least one college", ), + "atm": MessageLookupByLibrary.simpleMessage("ATM"), "available_amount": MessageLookupByLibrary.simpleMessage( "Available amount", ), @@ -119,6 +120,7 @@ class MessageLookup extends MessageLookupByLibrary { "Class Registration", ), "close": MessageLookupByLibrary.simpleMessage("Close"), + "coffee_machine": MessageLookupByLibrary.simpleMessage("Coffee Machine"), "collect_usage_stats": MessageLookupByLibrary.simpleMessage( "Collect usage statistics", ), @@ -408,6 +410,7 @@ class MessageLookup extends MessageLookupByLibrary { "Error opening the file", ), "other_links": MessageLookupByLibrary.simpleMessage("Other links"), + "parking": MessageLookupByLibrary.simpleMessage("Parking"), "pass_change_request": MessageLookupByLibrary.simpleMessage( "For security reasons, passwords must be changed periodically.", ), @@ -430,6 +433,7 @@ class MessageLookup extends MessageLookupByLibrary { "press_again": MessageLookupByLibrary.simpleMessage("Press again to exit"), "print": MessageLookupByLibrary.simpleMessage("Print"), "print_balance": MessageLookupByLibrary.simpleMessage("Print balance"), + "printer": MessageLookupByLibrary.simpleMessage("Printer"), "prints": MessageLookupByLibrary.simpleMessage("Prints"), "problem_id": MessageLookupByLibrary.simpleMessage( "Brief identification of the problem", @@ -457,7 +461,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"), @@ -467,6 +471,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "services": MessageLookupByLibrary.simpleMessage("Services"), "settings": MessageLookupByLibrary.simpleMessage("Settings"), + "shop": MessageLookupByLibrary.simpleMessage("Shop"), "snackbar": MessageLookupByLibrary.simpleMessage("Snackbar"), "some_error": MessageLookupByLibrary.simpleMessage("Some error!"), "spotted_an_error": MessageLookupByLibrary.simpleMessage( @@ -506,9 +511,11 @@ class MessageLookup extends MessageLookupByLibrary { "valid_email": MessageLookupByLibrary.simpleMessage( "Please enter a valid email", ), + "vending_machine": MessageLookupByLibrary.simpleMessage("Vending Machine"), "view_course_details": MessageLookupByLibrary.simpleMessage( "View course details", ), + "wc": MessageLookupByLibrary.simpleMessage("WC"), "widget_prompt": MessageLookupByLibrary.simpleMessage( "Choose a widget to add to your personal area:", ), 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 8b45709a3..5eae9c7bd 100644 --- a/packages/uni_app/lib/generated/intl/messages_pt_PT.dart +++ b/packages/uni_app/lib/generated/intl/messages_pt_PT.dart @@ -67,6 +67,7 @@ class MessageLookup extends MessageLookupByLibrary { "at_least_one_college": MessageLookupByLibrary.simpleMessage( "Seleciona pelo menos uma faculdade", ), + "atm": MessageLookupByLibrary.simpleMessage("Caixa Multibanco"), "available_amount": MessageLookupByLibrary.simpleMessage( "Valor disponível", ), @@ -127,6 +128,7 @@ class MessageLookup extends MessageLookupByLibrary { "Inscrição de Turmas", ), "close": MessageLookupByLibrary.simpleMessage("Fechar"), + "coffee_machine": MessageLookupByLibrary.simpleMessage("Máquina de café"), "collect_usage_stats": MessageLookupByLibrary.simpleMessage( "Partilhar estatísticas de uso", ), @@ -430,6 +432,7 @@ class MessageLookup extends MessageLookupByLibrary { "Erro ao abrir o ficheiro", ), "other_links": MessageLookupByLibrary.simpleMessage("Outros links"), + "parking": MessageLookupByLibrary.simpleMessage("parque automóvel"), "pass_change_request": MessageLookupByLibrary.simpleMessage( "Por razões de segurança, as palavras-passe têm de ser alteradas periodicamente.", ), @@ -452,6 +455,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "print": MessageLookupByLibrary.simpleMessage("Impressão"), "print_balance": MessageLookupByLibrary.simpleMessage("Saldo impressões"), + "printer": MessageLookupByLibrary.simpleMessage("Impressora"), "prints": MessageLookupByLibrary.simpleMessage("Impressões"), "problem_id": MessageLookupByLibrary.simpleMessage( "Breve identificação do problema", @@ -481,7 +485,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"), @@ -491,6 +495,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "services": MessageLookupByLibrary.simpleMessage("Serviços"), "settings": MessageLookupByLibrary.simpleMessage("Definições"), + "shop": MessageLookupByLibrary.simpleMessage("loja"), "snackbar": MessageLookupByLibrary.simpleMessage("Snackbar"), "some_error": MessageLookupByLibrary.simpleMessage("Algum erro!"), "spotted_an_error": MessageLookupByLibrary.simpleMessage( @@ -534,9 +539,11 @@ class MessageLookup extends MessageLookupByLibrary { "valid_email": MessageLookupByLibrary.simpleMessage( "Por favor insere um email válido", ), + "vending_machine": MessageLookupByLibrary.simpleMessage("Máquina de venda"), "view_course_details": MessageLookupByLibrary.simpleMessage( "Ver detalhes da Unidade Curricular", ), + "wc": MessageLookupByLibrary.simpleMessage("Casa de banho"), "widget_prompt": MessageLookupByLibrary.simpleMessage( "Escolhe um widget para adicionares à tua área pessoal:", ), diff --git a/packages/uni_app/lib/generated/l10n.dart b/packages/uni_app/lib/generated/l10n.dart index 2f83c2931..f91fbbdeb 100644 --- a/packages/uni_app/lib/generated/l10n.dart +++ b/packages/uni_app/lib/generated/l10n.dart @@ -1503,9 +1503,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.` @@ -2031,6 +2031,16 @@ class S { ); } + /// `Vending Machine` + String get vending_machine { + return Intl.message( + 'Vending Machine', + name: 'vending_machine', + desc: '', + args: [], + ); + } + /// `Select the type of feedback` String get feedback_type_title_section { return Intl.message( @@ -2041,6 +2051,16 @@ class S { ); } + /// `Coffee Machine` + String get coffee_machine { + return Intl.message( + 'Coffee Machine', + name: 'coffee_machine', + desc: '', + args: [], + ); + } + /// `Please choose the category that best describes your feedback to help us address it effectively.` String get feedback_type_description_section { return Intl.message( @@ -2051,6 +2071,31 @@ class S { ); } + /// `ATM` + String get atm { + return Intl.message('ATM', name: 'atm', desc: '', args: []); + } + + /// `Printer` + String get printer { + return Intl.message('Printer', name: 'printer', desc: '', args: []); + } + + /// `WC` + String get wc { + return Intl.message('WC', name: 'wc', desc: '', args: []); + } + + /// `Parking` + String get parking { + return Intl.message('Parking', name: 'parking', desc: '', args: []); + } + + /// `Shop` + String get shop { + return Intl.message('Shop', name: 'shop', desc: '', args: []); + } + /// `Provide a clear and concise title along with a detailed description of the issue or suggestion. The more information you provide, the better we can understand and address your feedback.` String get feedback_description_section { return Intl.message( diff --git a/packages/uni_app/lib/l10n/intl_en.arb b/packages/uni_app/lib/l10n/intl_en.arb index b19e4cd39..1593727c7 100644 --- a/packages/uni_app/lib/l10n/intl_en.arb +++ b/packages/uni_app/lib/l10n/intl_en.arb @@ -354,7 +354,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": {}, @@ -484,6 +484,20 @@ "@no_restaurants_available": {}, "no_restaurants_available_sublabel": "Bring your lunchbox from home.", "@no_restaurants_available_sublabel": {}, + "vending_machine": "Vending Machine", + "@vending_machine": {}, + "coffee_machine": "Coffee Machine", + "@coffee_machine": {}, + "atm": "ATM", + "@atm": {}, + "printer": "Printer", + "@printer": {}, + "wc": "WC", + "@wc": {}, + "parking": "Parking", + "@parking": {}, + "shop": "Shop", + "@shop": {}, "feedback_type_title_section": "Select the type of feedback", "@feedback_type_title_section": {}, "feedback_type_description_section": "Please choose the category that best describes your feedback to help us address it effectively.", diff --git a/packages/uni_app/lib/l10n/intl_pt_PT.arb b/packages/uni_app/lib/l10n/intl_pt_PT.arb index 255d763ba..6ec2c0094 100644 --- a/packages/uni_app/lib/l10n/intl_pt_PT.arb +++ b/packages/uni_app/lib/l10n/intl_pt_PT.arb @@ -352,7 +352,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": {}, @@ -484,6 +484,20 @@ "@no_restaurants_available": {}, "no_restaurants_available_sublabel": "Traz a tua marmita de casa.", "@no_restaurants_available_sublabel": {}, + "vending_machine": "Máquina de venda", + "@vending_machine": {}, + "coffee_machine": "Máquina de café", + "@coffee_machine": {}, + "atm": "Caixa Multibanco", + "@atm": {}, + "printer": "Impressora", + "@printer": {}, + "wc": "Casa de banho", + "@wc": {}, + "parking": "parque automóvel", + "@parking": {}, + "shop": "loja", + "@shop": {}, "feedback_type_title_section": "Seleciona o tipo de feedback", "@feedback_type_title_section": {}, "feedback_type_description_section": "Escolhe a categoria que melhor descreve o teu feedback para nos ajudar a processá-lo de forma eficiente.", diff --git a/packages/uni_app/lib/model/entities/faculty_config.dart b/packages/uni_app/lib/model/entities/faculty_config.dart new file mode 100644 index 000000000..fe8b193e4 --- /dev/null +++ b/packages/uni_app/lib/model/entities/faculty_config.dart @@ -0,0 +1,57 @@ +class FacultyConfig { + const FacultyConfig({ + required this.id, + required this.name, + required this.bounds, + required this.buildingCodePattern, + this.assetPath, + }); + + final String id; + final String name; + final FacultyBounds bounds; + final RegExp buildingCodePattern; + final String? assetPath; // Optional fallback JSON + + static final feup = FacultyConfig( + id: 'feup', + name: 'FEUP', + bounds: const FacultyBounds( + minLat: 41.176, + maxLat: 41.179, + minLon: -8.598, + maxLon: -8.594, + ), + buildingCodePattern: RegExp('^([A-Z])'), + assetPath: 'assets/text/locations/feup.json', + ); + + static final fep = FacultyConfig( + id: 'fep', + name: 'FEP', + bounds: const FacultyBounds( + minLat: 41.154, + maxLat: 41.156, + minLon: -8.639, + maxLon: -8.636, + ), + buildingCodePattern: RegExp('^(FEP[A-Z]?)'), + // No fallback yet + ); + + static final all = [feup, fep]; +} + +class FacultyBounds { + const FacultyBounds({ + required this.minLat, + required this.maxLat, + required this.minLon, + required this.maxLon, + }); + + final double minLat; + final double maxLat; + final double minLon; + final double maxLon; +} 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..89832766d --- /dev/null +++ b/packages/uni_app/lib/model/entities/indoor_floor_plan.dart @@ -0,0 +1,42 @@ +import 'package:latlong2/latlong.dart'; + +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; + 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; + final List polygon; + final String? name; + final String? type; +} + +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; + final String? name; +} diff --git a/packages/uni_app/lib/model/entities/location.dart b/packages/uni_app/lib/model/entities/location.dart index 6e2822395..ad070dba3 100644 --- a/packages/uni_app/lib/model/entities/location.dart +++ b/packages/uni_app/lib/model/entities/location.dart @@ -1,5 +1,7 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/locations/atm.dart'; import 'package:uni/model/entities/locations/coffee_machine.dart'; +import 'package:uni/model/entities/locations/parking.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_group_location.dart'; @@ -21,6 +23,7 @@ enum LocationType { specialRoom, store, wc, + carPark, } String locationTypeToString(LocationType type) { @@ -45,6 +48,8 @@ String locationTypeToString(LocationType type) { return 'STORE'; case LocationType.wc: return 'WC'; + case LocationType.carPark: + return 'CAR_PARK'; } } @@ -56,7 +61,9 @@ abstract class Location { final int weight; final dynamic icon; - String description(); + String description(BuildContext context); + + String dedupKey(); Map toMap({int? groupId}); @@ -91,6 +98,8 @@ abstract class Location { return StoreLocation(floor, args['name'].toString()); case 'WC': return WcLocation(floor); + case 'CAR_PARK': + return CarPark(floor, args['name'].toString()); default: return UnknownLocation(floor, json['type'].toString()); } diff --git a/packages/uni_app/lib/model/entities/location_group.dart b/packages/uni_app/lib/model/entities/location_group.dart index 926bc44d6..3e93669c2 100644 --- a/packages/uni_app/lib/model/entities/location_group.dart +++ b/packages/uni_app/lib/model/entities/location_group.dart @@ -33,5 +33,12 @@ class LocationGroup { ); } + Location? getLocationForFloor(int? floor) { + if (floor != null && floors.containsKey(floor)) { + return floors[floor]!.reduce((a, b) => a.weight > b.weight ? a : b); + } + return getLocationWithMostWeight(); + } + Map toJson() => _$LocationGroupToJson(this); } diff --git a/packages/uni_app/lib/model/entities/locations/atm.dart b/packages/uni_app/lib/model/entities/locations/atm.dart index cde494816..5c09ed43b 100644 --- a/packages/uni_app/lib/model/entities/locations/atm.dart +++ b/packages/uni_app/lib/model/entities/locations/atm.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -15,8 +17,13 @@ class Atm implements Location { final int? locationGroupId; @override - String description() { - return 'Atm'; + String description(BuildContext context) { + return S.of(context).atm; + } + + @override + String dedupKey() { + return 'atm|$floor'; } @override diff --git a/packages/uni_app/lib/model/entities/locations/coffee_machine.dart b/packages/uni_app/lib/model/entities/locations/coffee_machine.dart index 4a00f52e6..1ff3cf38f 100644 --- a/packages/uni_app/lib/model/entities/locations/coffee_machine.dart +++ b/packages/uni_app/lib/model/entities/locations/coffee_machine.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -15,8 +17,13 @@ class CoffeeMachine implements Location { final int? locationGroupId; @override - String description() { - return 'Máquina de café'; + String description(BuildContext context) { + return S.of(context).coffee_machine; + } + + @override + String dedupKey() { + return 'coffee_machine|$floor'; } @override diff --git a/packages/uni_app/lib/model/entities/locations/parking.dart b/packages/uni_app/lib/model/entities/locations/parking.dart new file mode 100644 index 000000000..4e673e0d0 --- /dev/null +++ b/packages/uni_app/lib/model/entities/locations/parking.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; +import 'package:uni/model/entities/location.dart'; +import 'package:uni_ui/icons.dart'; + +class CarPark implements Location { + CarPark(this.floor, this.name, {this.locationGroupId}); + @override + final int floor; + final String name; + + @override + final weight = 1; + + @override + final icon = UniIcons.carPark; + + final int? locationGroupId; + + @override + String description(BuildContext context) { + if (name.toLowerCase() == 'parking') { + return S.of(context).parking; + } + return name; + } + + @override + String dedupKey() { + return 'car_park|$floor|${name.trim().toLowerCase()}'; + } + + @override + Map toMap({int? groupId}) { + return { + 'floor': floor, + 'name': name, + 'type': locationTypeToString(LocationType.carPark), + }; + } +} diff --git a/packages/uni_app/lib/model/entities/locations/printer.dart b/packages/uni_app/lib/model/entities/locations/printer.dart index 3f893b989..83c50d20f 100644 --- a/packages/uni_app/lib/model/entities/locations/printer.dart +++ b/packages/uni_app/lib/model/entities/locations/printer.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -15,8 +17,13 @@ class Printer implements Location { final int? locationGroupId; @override - String description() { - return 'Impressora'; + String description(BuildContext context) { + return S.of(context).printer; + } + + @override + String dedupKey() { + return 'printer|$floor'; } @override diff --git a/packages/uni_app/lib/model/entities/locations/restaurant_location.dart b/packages/uni_app/lib/model/entities/locations/restaurant_location.dart index c17645da3..a2f2f6b3b 100644 --- a/packages/uni_app/lib/model/entities/locations/restaurant_location.dart +++ b/packages/uni_app/lib/model/entities/locations/restaurant_location.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -17,10 +18,15 @@ class RestaurantLocation implements Location { final int? locationGroupId; @override - String description() { + String description(BuildContext context) { return name; } + @override + String dedupKey() { + return 'restaurant|$floor|${name.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return { diff --git a/packages/uni_app/lib/model/entities/locations/room_group_location.dart b/packages/uni_app/lib/model/entities/locations/room_group_location.dart index e999039a9..a7df3d24f 100644 --- a/packages/uni_app/lib/model/entities/locations/room_group_location.dart +++ b/packages/uni_app/lib/model/entities/locations/room_group_location.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -22,10 +23,15 @@ class RoomGroupLocation implements Location { final int? locationGroupId; @override - String description() { + String description(BuildContext context) { return '''$firstRoomNumber -> $secondRoomNumber'''; } + @override + String dedupKey() { + return 'room_group|$floor|${firstRoomNumber.trim().toLowerCase()}|${secondRoomNumber.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return { diff --git a/packages/uni_app/lib/model/entities/locations/room_location.dart b/packages/uni_app/lib/model/entities/locations/room_location.dart index f43cc29b5..20aa99012 100644 --- a/packages/uni_app/lib/model/entities/locations/room_location.dart +++ b/packages/uni_app/lib/model/entities/locations/room_location.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -16,10 +17,15 @@ class RoomLocation implements Location { final int? locationGroupId; @override - String description() { + String description(BuildContext context) { return roomNumber; } + @override + String dedupKey() { + return 'room|$floor|${roomNumber.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return { diff --git a/packages/uni_app/lib/model/entities/locations/special_room_location.dart b/packages/uni_app/lib/model/entities/locations/special_room_location.dart index 47b4dfff8..4f797ad0b 100644 --- a/packages/uni_app/lib/model/entities/locations/special_room_location.dart +++ b/packages/uni_app/lib/model/entities/locations/special_room_location.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -22,10 +23,15 @@ class SpecialRoomLocation implements Location { final int? locationGroupId; @override - String description() { + String description(BuildContext context) { return '''$roomNumber - $name'''; } + @override + String dedupKey() { + return 'special_room|$floor|${roomNumber.trim().toLowerCase()}|${name.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return { diff --git a/packages/uni_app/lib/model/entities/locations/store_location.dart b/packages/uni_app/lib/model/entities/locations/store_location.dart index b6b6a4a1b..bd98fc328 100644 --- a/packages/uni_app/lib/model/entities/locations/store_location.dart +++ b/packages/uni_app/lib/model/entities/locations/store_location.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -16,10 +18,18 @@ class StoreLocation implements Location { final int? locationGroupId; @override - String description() { + String description(BuildContext context) { + if (name.toLowerCase() == 'shop') { + return S.of(context).shop; + } return name; } + @override + String dedupKey() { + return 'store|$floor|${name.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return { diff --git a/packages/uni_app/lib/model/entities/locations/unknown_location.dart b/packages/uni_app/lib/model/entities/locations/unknown_location.dart index 7b9cb27dd..678ba301c 100644 --- a/packages/uni_app/lib/model/entities/locations/unknown_location.dart +++ b/packages/uni_app/lib/model/entities/locations/unknown_location.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -17,10 +18,15 @@ class UnknownLocation implements Location { final String type; @override - String description() { + String description(BuildContext context) { return type; } + @override + String dedupKey() { + return 'unknown|$floor|${type.trim().toLowerCase()}'; + } + @override Map toMap({int? groupId}) { return {'floor': floor, 'type': type}; diff --git a/packages/uni_app/lib/model/entities/locations/vending_machine.dart b/packages/uni_app/lib/model/entities/locations/vending_machine.dart index 43a1f4842..c21a4533f 100644 --- a/packages/uni_app/lib/model/entities/locations/vending_machine.dart +++ b/packages/uni_app/lib/model/entities/locations/vending_machine.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -15,8 +17,13 @@ class VendingMachine implements Location { final int? locationGroupId; @override - String description() { - return 'Máquina de venda'; + String description(BuildContext context) { + return S.of(context).vending_machine; + } + + @override + String dedupKey() { + return 'vending_machine|$floor'; } @override diff --git a/packages/uni_app/lib/model/entities/locations/wc_location.dart b/packages/uni_app/lib/model/entities/locations/wc_location.dart index bf8de621c..8b656f9b6 100644 --- a/packages/uni_app/lib/model/entities/locations/wc_location.dart +++ b/packages/uni_app/lib/model/entities/locations/wc_location.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; +import 'package:uni/generated/l10n.dart'; import 'package:uni/model/entities/location.dart'; import 'package:uni_ui/icons.dart'; @@ -15,12 +17,17 @@ class WcLocation implements Location { final int? locationGroupId; @override - String description() { - return 'Casa de banho'; + String description(BuildContext context) { + return S.of(context).wc; + } + + @override + String dedupKey() { + return 'wc|$floor'; } @override Map toMap({int? groupId}) { - return {'floor': floor, 'type': locationTypeToString(LocationType.atm)}; + return {'floor': floor, 'type': locationTypeToString(LocationType.wc)}; } } diff --git a/packages/uni_app/lib/model/providers/riverpod/cached_async_notifier.dart b/packages/uni_app/lib/model/providers/riverpod/cached_async_notifier.dart index 3c331d6d2..bd5e4ce7b 100644 --- a/packages/uni_app/lib/model/providers/riverpod/cached_async_notifier.dart +++ b/packages/uni_app/lib/model/providers/riverpod/cached_async_notifier.dart @@ -67,7 +67,9 @@ abstract class CachedAsyncNotifier extends AsyncNotifier { }) async { try { final result = await operation(); - if (result != null && ref.mounted) { + // Guard against both a disposed notifier (HEAD) and empty/null results + // (develop) — both checks are necessary and independent. + if (result != null && ref.mounted && !_invalidLocalData(result)) { _updateState(result, updateTimestamp: updateTimestamp); } return result; 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 92e791f94..6f419e77b 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,26 +1,78 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.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/faculty_config.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'; +final _osmFetcherProvider = Provider((ref) { + return LocationFetcherOSM(ref.read(selectedFacultyProvider)); +}); + final locationsProvider = AsyncNotifierProvider?>( FacultyLocationsNotifier.new, ); +final indoorFloorPlansProvider = + AsyncNotifierProvider?>( + IndoorFloorPlansNotifier.new, + ); + +final selectedFacultyProvider = StateProvider( + (_) => FacultyConfig.feup, +); + class FacultyLocationsNotifier extends CachedAsyncNotifier> { @override + Duration? get cacheDuration => null; + + FacultyConfig get _faculty => ref.read(selectedFacultyProvider); + + @override + Future> loadFromStorage() async { + return []; + } + + @override + Future> loadFromRemote() async { + try { + final osmData = await ref.read(_osmFetcherProvider).getLocations(); + if (osmData.isNotEmpty) { + return osmData; + } + } catch (err) { + // not fetching from cache yet, ignore error and fall back to asset. + } + return LocationFetcherAsset(_faculty).getLocations(); + } +} + +class IndoorFloorPlansNotifier + extends CachedAsyncNotifier> { + @override Duration? get cacheDuration => const Duration(days: 30); + FacultyConfig get _faculty => ref.read(selectedFacultyProvider); + @override - Future> loadFromStorage() { - return LocationFetcherAsset().getLocations(); + Future> loadFromStorage() async { + try { + return await LocationFetcherAsset(_faculty).getIndoorFloorPlans(); + } catch (err) { + return []; + } } @override - Future> loadFromRemote() { - //since locations are stored in assets, we don't need internet for this. - return LocationFetcherAsset().getLocations(); + Future> loadFromRemote() async { + try { + return await ref.read(_osmFetcherProvider).getIndoorFloorPlans(); + } catch (err) { + return loadFromStorage(); + } } } diff --git a/packages/uni_app/lib/view/map/helpers/collapsed_location_cluster.dart b/packages/uni_app/lib/view/map/helpers/collapsed_location_cluster.dart new file mode 100644 index 000000000..4ab901cb0 --- /dev/null +++ b/packages/uni_app/lib/view/map/helpers/collapsed_location_cluster.dart @@ -0,0 +1,113 @@ +import 'package:latlong2/latlong.dart'; +import 'package:uni/model/entities/location.dart'; +import 'package:uni/model/entities/location_group.dart'; + +class CollapsedLocationCluster { + CollapsedLocationCluster({ + required this.locationGroup, + required this.additionalAmenities, + }); + + factory CollapsedLocationCluster.fromGroups(List groups) { + final allLocations = []; + var allFloorless = true; + var latitudeSum = 0.0; + var longitudeSum = 0.0; + + for (final group in groups) { + latitudeSum += group.latlng.latitude; + longitudeSum += group.latlng.longitude; + allFloorless = allFloorless && group.isFloorless; + group.floors.values.forEach(allLocations.addAll); + } + + final deduplicatedLocations = _deduplicateLocations(allLocations); + final locationTypes = deduplicatedLocations + .map((location) => location.runtimeType) + .toSet(); + final totalAmenities = deduplicatedLocations.length; + + return CollapsedLocationCluster( + locationGroup: LocationGroup( + LatLng(latitudeSum / groups.length, longitudeSum / groups.length), + locations: deduplicatedLocations, + isFloorless: allFloorless, + id: groups.first.id, + ), + additionalAmenities: locationTypes.length > 1 && totalAmenities > 1 + ? totalAmenities - 1 + : 0, + ); + } + static const double _amenityCollapseDistanceMeters = 5; + + static List _deduplicateLocations(List locations) { + final seen = {}; + final deduplicated = []; + + for (final location in locations) { + final key = location.dedupKey(); + if (seen.add(key)) { + deduplicated.add(location); + } + } + + return deduplicated; + } + + static List collapseNearbyAmenities( + List groups, + ) { + if (groups.isEmpty) { + return []; + } + + final visited = List.filled(groups.length, false); + final clusters = []; + const distance = Distance(); + + for (var index = 0; index < groups.length; index++) { + if (visited[index]) { + continue; + } + + final pending = [index]; + final clusterIndexes = []; + + while (pending.isNotEmpty) { + final currentIndex = pending.removeLast(); + if (visited[currentIndex]) { + continue; + } + + visited[currentIndex] = true; + clusterIndexes.add(currentIndex); + + for (var otherIndex = 0; otherIndex < groups.length; otherIndex++) { + if (visited[otherIndex] || currentIndex == otherIndex) { + continue; + } + + final meters = distance.as( + LengthUnit.Meter, + groups[currentIndex].latlng, + groups[otherIndex].latlng, + ); + + if (meters <= _amenityCollapseDistanceMeters) { + pending.add(otherIndex); + } + } + } + + final clusterGroups = clusterIndexes + .map((clusterIndex) => groups[clusterIndex]) + .toList(); + clusters.add(CollapsedLocationCluster.fromGroups(clusterGroups)); + } + return clusters; + } + + final LocationGroup locationGroup; + final int additionalAmenities; +} diff --git a/packages/uni_app/lib/view/map/helpers/room_label_info.dart b/packages/uni_app/lib/view/map/helpers/room_label_info.dart new file mode 100644 index 000000000..efe59011e --- /dev/null +++ b/packages/uni_app/lib/view/map/helpers/room_label_info.dart @@ -0,0 +1,107 @@ +import 'dart:math' as math; +import 'package:latlong2/latlong.dart'; + +class RoomLabelInfo { + RoomLabelInfo({ + required this.center, + required this.angle, + required this.fontSize, + }); + + final LatLng center; + final double angle; + final double fontSize; +} + +RoomLabelInfo getRoomLabelInfo(List polygon, String text, double zoom) { + if (polygon.isEmpty || text.isEmpty) { + return RoomLabelInfo(center: const LatLng(0, 0), angle: 0, fontSize: 6); + } + + final originLat = polygon.first.latitude; + final cosLat = math.cos(originLat * math.pi / 180.0); + + var maxDistSq = -1.0; + var bestAngle = 0.0; + + for (var i = 0; i < polygon.length; i++) { + final p1 = polygon[i]; + final p2 = polygon[(i + 1) % polygon.length]; + + final dLat = p2.latitude - p1.latitude; + final dLng = (p2.longitude - p1.longitude) * cosLat; + final distSq = dLat * dLat + dLng * dLng; + + if (distSq > maxDistSq) { + maxDistSq = distSq; + bestAngle = math.atan2(-dLat, dLng); + } + } + + while (bestAngle > math.pi / 2.0) { + bestAngle -= math.pi; + } + while (bestAngle < -math.pi / 2.0) { + bestAngle += math.pi; + } + + double minLocalX = double.infinity; + double maxLocalX = -double.infinity; + double minLocalY = double.infinity; + double maxLocalY = -double.infinity; + + var pts = polygon; + if (pts.length > 1 && + pts.first.latitude == pts.last.latitude && + pts.first.longitude == pts.last.longitude) { + pts = pts.sublist(0, pts.length - 1); + } + + for (final p in pts) { + final dx = (p.longitude - polygon.first.longitude) * cosLat; + final dy = -(p.latitude - originLat); + + final rx = dx * math.cos(-bestAngle) - dy * math.sin(-bestAngle); + final ry = dx * math.sin(-bestAngle) + dy * math.cos(-bestAngle); + + if (rx < minLocalX) { + minLocalX = rx; + } + if (rx > maxLocalX) { + maxLocalX = rx; + } + if (ry < minLocalY) { + minLocalY = ry; + } + if (ry > maxLocalY) { + maxLocalY = ry; + } + } + + final centerRx = (minLocalX + maxLocalX) / 2.0; + final centerRy = (minLocalY + maxLocalY) / 2.0; + + final cdx = centerRx * math.cos(bestAngle) - centerRy * math.sin(bestAngle); + final cdy = centerRx * math.sin(bestAngle) + centerRy * math.cos(bestAngle); + + final centerLat = originLat - cdy; + final centerLng = (cdx / cosLat) + polygon.first.longitude; + final finalCenter = LatLng(centerLat, centerLng); + + final double scale = (256.0 * math.pow(2, zoom)) / 360.0; + final double widthPx = (maxLocalX - minLocalX).abs() * scale; + final double heightPx = (maxLocalY - minLocalY).abs() * scale; + + const charWidthFactor = 0.55; + final double maxFontSizeByWidth = widthPx / (text.length * charWidthFactor); + final double maxFontSizeByHeight = heightPx * 0.85; + + final double optimalSize = + math.min(maxFontSizeByWidth, maxFontSizeByHeight) * 0.70; + + return RoomLabelInfo( + center: finalCenter, + angle: bestAngle, + fontSize: optimalSize.clamp(2.0, 10.0), + ); +} diff --git a/packages/uni_app/lib/view/map/map.dart b/packages/uni_app/lib/view/map/map.dart index 8d11aed3e..4899d2ef0 100644 --- a/packages/uni_app/lib/view/map/map.dart +++ b/packages/uni_app/lib/view/map/map.dart @@ -5,12 +5,17 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:latlong2/latlong.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/helpers/collapsed_location_cluster.dart'; +import 'package:uni/view/map/widgets/amenity_filter_bar.dart'; +import 'package:uni/view/map/widgets/floor_selector_button.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 +34,17 @@ class MapPageStateView extends ConsumerState { var _searchTerms = ''; late final PopupController _popupLayerController; LatLngBounds? _bounds; + int? _selectedFloor; + bool _showIndoorLayer = false; + AmenityFilter? _selectedAmenity; @override void initState() { super.initState(); _searchTerms = ''; _popupLayerController = PopupController(); + _selectedFloor = null; + _selectedAmenity = null; } @override @@ -45,135 +55,220 @@ class MapPageStateView extends ConsumerState { @override Widget build(BuildContext context) { - return DefaultConsumer>( - provider: locationsProvider, - builder: (context, ref, locations) { - var bounds = _bounds; - bounds ??= LatLngBounds.fromPoints( - locations.map((location) => location.latlng).toList(), - drawInSingleWorld: true, - ); - _bounds ??= bounds; - - final filteredLocations = List.from(locations); - if (_searchTerms.trim().isNotEmpty) { - filteredLocations.retainWhere((location) { - final allLocations = location.floors.values.expand((x) => x); - return allLocations.any((location) { - return removeDiacritics( - location.description().toLowerCase().trim(), - ).contains(_searchTerms); - }); - }); + final locationsAsync = ref.watch(locationsProvider); + final List locations = locationsAsync.when( + data: (data) => data ?? [], + loading: () => [], + error: (_, _) => [], + ); + final isLocationsLoading = locationsAsync.isLoading; + + final indoorPlansAsync = ref.watch(indoorFloorPlansProvider); + final List indoorPlans = indoorPlansAsync.when( + data: (data) => data ?? [], + loading: () => [], + error: (_, _) => [], + ); + final isIndoorPlansLoaded = indoorPlansAsync.hasValue; + + final isMapLoading = isLocationsLoading || !isIndoorPlansLoaded; + final normalizedSearchTerm = _searchTerms.trim(); + + final matchingRoomsByFloor = >{}; + if (normalizedSearchTerm.isNotEmpty) { + for (final plan in indoorPlans) { + final matchingRooms = plan.rooms + .where((room) => _matchesRoomSearch(room, normalizedSearchTerm)) + .toList(); + if (matchingRooms.isNotEmpty) { + matchingRoomsByFloor + .putIfAbsent(plan.floor, () => []) + .addAll(matchingRooms); } + } + } + + final hasRoomSearchResults = matchingRoomsByFloor.isNotEmpty; + var effectiveFloor = _selectedFloor; + if (hasRoomSearchResults) { + if (_selectedFloor != null && + matchingRoomsByFloor.containsKey(_selectedFloor)) { + effectiveFloor = _selectedFloor; + } else { + final candidateFloors = matchingRoomsByFloor.keys.toList() + ..sort((a, b) => b.compareTo(a)); + effectiveFloor = candidateFloors.first; + } + } + final shouldShowIndoorLayer = _showIndoorLayer || hasRoomSearchResults; - return AnnotatedRegion( - value: AppSystemOverlayStyles.base.copyWith( - statusBarIconBrightness: - Theme.of(context).brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, - statusBarBrightness: Theme.of(context).brightness == Brightness.dark - ? Brightness.dark - : Brightness.light, - systemNavigationBarIconBrightness: - Theme.of(context).brightness == Brightness.dark - ? Brightness.light - : Brightness.dark, + // Fall back to faculty bounds while locations are loading + final faculty = ref.watch(selectedFacultyProvider); + final fallbackBounds = LatLngBounds( + LatLng(faculty.bounds.minLat, faculty.bounds.minLon), + LatLng(faculty.bounds.maxLat, faculty.bounds.maxLon), + ); + if (locations.isNotEmpty) { + _bounds ??= LatLngBounds.fromPoints( + locations.map((l) => l.latlng).toList(), + drawInSingleWorld: true, + ); + } + final bounds = _bounds ?? fallbackBounds; + + final filteredLocations = List.from(locations); + if (normalizedSearchTerm.isNotEmpty) { + filteredLocations.retainWhere((location) { + final allLocations = location.floors.values.expand((x) => x); + return allLocations.any((loc) { + return _normalizeSearchText( + loc.description(context), + ).contains(normalizedSearchTerm); + }); + }); + } + if (_selectedFloor != null) { + filteredLocations.retainWhere((location) { + return location.floors.containsKey(_selectedFloor); + }); + } + if (_selectedAmenity != null) { + filteredLocations.retainWhere((locationGroup) { + final allLocations = locationGroup.floors.values.expand((x) => x); + return allLocations.any((loc) => _selectedAmenity!.matches(loc)); + }); + } + + final collapsedMarkerGroups = + CollapsedLocationCluster.collapseNearbyAmenities(filteredLocations); + + // Combine floors from location groups and indoor floor plans. + final locationFloors = locations + .expand((group) => group.floors.keys) + .toSet(); + final indoorFloors = indoorPlans.map((plan) => plan.floor).toSet(); + final List allFloors = { + ...locationFloors, + ...indoorFloors, + }.where((f) => f != 7).toList()..sort((a, b) => b.compareTo(a)); + + return AnnotatedRegion( + value: AppSystemOverlayStyles.base.copyWith( + statusBarIconBrightness: Theme.of(context).brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + statusBarBrightness: Theme.of(context).brightness == Brightness.dark + ? Brightness.dark + : Brightness.light, + systemNavigationBarIconBrightness: + Theme.of(context).brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + ), + child: Scaffold( + resizeToAvoidBottomInset: false, + extendBody: true, + bottomNavigationBar: const AppBottomNavbar(), + body: FlutterMap( + options: MapOptions( + minZoom: 16, + maxZoom: 20, + initialCenter: bounds.center, + initialZoom: 17, + initialCameraFit: CameraFit.insideBounds(bounds: bounds), + cameraConstraint: CameraConstraint.containCenter(bounds: bounds), + onTap: (tapPosition, latlng) { + _popupLayerController.hideAllPopups(); + FocusScope.of(context).unfocus(); + }, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all - InteractiveFlag.rotate, + ), ), - child: Scaffold( - resizeToAvoidBottomInset: false, - extendBody: true, - bottomNavigationBar: const AppBottomNavbar(), - body: FlutterMap( - options: MapOptions( - minZoom: 16, - maxZoom: 19, - initialCenter: bounds.center, - initialCameraFit: CameraFit.insideBounds(bounds: bounds), - cameraConstraint: CameraConstraint.containCenter( - bounds: bounds, - ), - onTap: (tapPosition, latlng) => - _popupLayerController.hideAllPopups(), - interactionOptions: const InteractionOptions( - flags: InteractiveFlag.all - InteractiveFlag.rotate, - ), + children: [ + TileLayer( + urlTemplate: Theme.of(context).brightness == Brightness.dark + ? 'https://basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' + : 'https://basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', + tileProvider: NetworkTileProvider( + cachingProvider: + BuiltInMapCachingProvider.getOrCreateInstance(), ), - children: [ - TileLayer( - urlTemplate: Theme.of(context).brightness == Brightness.dark - ? 'https://basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' - : 'https://basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', - tileProvider: NetworkTileProvider( - cachingProvider: - BuiltInMapCachingProvider.getOrCreateInstance(), - ), - retinaMode: RetinaMode.isHighDensity(context), - maxNativeZoom: 20, - ), - PopupMarkerLayer( - options: PopupMarkerLayerOptions( - markers: filteredLocations.map((location) { - return LocationMarker(location.latlng, location); - }).toList(), - popupDisplayOptions: PopupDisplayOptions( - animation: const PopupAnimation.fade( - duration: Duration(milliseconds: 400), - ), - builder: (_, marker) { - if (marker is LocationMarker) { - return marker.locationGroup.isFloorless - ? FloorlessLocationMarkerPopup( - marker.locationGroup, - ) - : LocationMarkerPopup(marker.locationGroup); - } - return const Card(child: Text('')); - }, - ), + retinaMode: RetinaMode.isHighDensity(context), + maxNativeZoom: 20, + ), + if (shouldShowIndoorLayer && effectiveFloor != null) + IndoorFloorLayer( + floorPlans: indoorPlans, + selectedFloor: effectiveFloor, + roomFilter: hasRoomSearchResults + ? (room) => _matchesRoomSearch(room, normalizedSearchTerm) + : null, + hasSearchContent: hasRoomSearchResults, + ), + PopupMarkerLayer( + options: PopupMarkerLayerOptions( + markers: collapsedMarkerGroups.map((cluster) { + return LocationMarker( + cluster.locationGroup.latlng, + cluster.locationGroup, + selectedFloor: _selectedFloor, + additionalCount: cluster.additionalAmenities, + ); + }).toList(), + popupController: _popupLayerController, + popupDisplayOptions: PopupDisplayOptions( + animation: const PopupAnimation.fade( + duration: Duration(milliseconds: 400), ), + builder: (_, marker) { + if (marker is LocationMarker) { + return marker.locationGroup.isFloorless + ? FloorlessLocationMarkerPopup(marker.locationGroup) + : LocationMarkerPopup(marker.locationGroup); + } + return const Card(child: Text('')); + }, ), - 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), - ), - builder: (_, marker) { - if (marker is LocationMarker) { - return marker.locationGroup.isFloorless - ? FloorlessLocationMarkerPopup( - marker.locationGroup, - ) - : LocationMarkerPopup(marker.locationGroup); - } - return const Card(child: Text('')); + ), + ), + if (isIndoorPlansLoaded) + Align( + alignment: Alignment.bottomRight, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(right: 20, bottom: 6), + child: FloorSelectorButton( + floors: allFloors, + selectedFloor: effectiveFloor, + onFloorSelected: (floor) { + setState(() { + _selectedFloor = floor; + _showIndoorLayer = true; + _popupLayerController.hideAllPopups(); + }); }, ), ), ), - SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - child: PhysicalModel( + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 10, right: 10, top: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PhysicalModel( borderRadius: BorderRadius.circular(10), - color: Colors.white, - elevation: 3, + color: Theme.of(context).colorScheme.secondary, + elevation: 4, child: TextFormField( + cursorColor: Theme.of(context).colorScheme.onSecondary, key: searchFormKey, onChanged: (text) { setState(() { - _searchTerms = removeDiacritics( - text.trim().toLowerCase(), - ); + _searchTerms = _normalizeSearchText(text); }); }, decoration: InputDecoration( @@ -188,7 +283,8 @@ class MapPageStateView extends ConsumerState { ), 'assets/images/logo_dark.svg', semanticsLabel: 'search', - width: 10, + width: 44, + height: 25, ), ), border: OutlineInputBorder( @@ -196,51 +292,78 @@ class MapPageStateView extends ConsumerState { borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.all(10), - hintText: '${S.of(context).search}...', - hintStyle: Theme.of(context).textTheme.bodyLarge, + hintText: S.of(context).search_here, + hintStyle: TextStyle( + fontFamily: 'Roboto', + fontSize: 9, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSecondary, + ), ), ), ), - ), + const SizedBox(height: 10), + AmenityFilterBar( + selectedAmenity: _selectedAmenity, + onAmenitySelected: (amenity) { + setState(() { + _selectedAmenity = amenity; + _popupLayerController.hideAllPopups(); + }); + }, + ), + ], ), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewPadding.bottom + 110, - left: 20, - ), - child: Align( - alignment: Alignment.bottomLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 5), - child: GestureDetector( - onTap: () => launchUrlWithToast( - context, - 'https://www.openstreetmap.org/copyright', - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 5, - horizontal: 8, - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Text( - '©OpenStreetMap @CARTO', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), + ), + ), + if (isMapLoading) const Center(child: CircularProgressIndicator()), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 110, + left: 20, + ), + child: Align( + alignment: Alignment.bottomLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 5), + child: GestureDetector( + onTap: () => launchUrlWithToast( + context, + 'https://www.openstreetmap.org/copyright', + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 5, + horizontal: 8, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Text( + '©OpenStreetMap @CARTO', + style: Theme.of(context).textTheme.bodyMedium, ), ), ), ), ), - ], + ), ), - ), - ); - }, - nullContentWidget: Center(child: Text(S.of(context).no_places_info)), - hasContent: (locations) => locations.isNotEmpty, + ], + ), + ), ); } + + String _normalizeSearchText(String? text) { + return removeDiacritics((text ?? '').toLowerCase().trim()); + } + + bool _matchesRoomSearch(IndoorRoom room, String normalizedSearchTerm) { + if (normalizedSearchTerm.isEmpty) { + return false; + } + + return _normalizeSearchText(room.name).contains(normalizedSearchTerm) || + _normalizeSearchText(room.ref).contains(normalizedSearchTerm); + } } diff --git a/packages/uni_app/lib/view/map/widgets/amenity_filter_bar.dart b/packages/uni_app/lib/view/map/widgets/amenity_filter_bar.dart new file mode 100644 index 000000000..d800256c1 --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/amenity_filter_bar.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:uni/model/entities/location.dart'; +import 'package:uni/model/entities/locations/atm.dart'; +import 'package:uni/model/entities/locations/coffee_machine.dart'; +import 'package:uni/model/entities/locations/parking.dart'; +import 'package:uni/model/entities/locations/printer.dart'; +import 'package:uni/model/entities/locations/restaurant_location.dart'; +import 'package:uni/model/entities/locations/store_location.dart'; +import 'package:uni/model/entities/locations/vending_machine.dart'; +import 'package:uni/model/entities/locations/wc_location.dart'; +import 'package:uni_ui/icons.dart'; + +enum AmenityFilter { + coffee('Coffee', UniIcons.coffee), + restaurants('Restaurants', UniIcons.restaurant), + snacks('Snacks', UniIcons.cookie), + atm('ATM', UniIcons.money), + printer('Printer', UniIcons.printer), + wc('WC', UniIcons.toilet), + parking('Parking', UniIcons.carPark), + store('Stores', UniIcons.storefront); + + const AmenityFilter(this.label, this.icon); + + final String label; + final IconData icon; + + bool matches(Location location) { + return switch (this) { + AmenityFilter.coffee => location is CoffeeMachine, + AmenityFilter.restaurants => location is RestaurantLocation, + AmenityFilter.snacks => location is VendingMachine, + AmenityFilter.atm => location is Atm, + AmenityFilter.printer => location is Printer, + AmenityFilter.wc => location is WcLocation, + AmenityFilter.store => location is StoreLocation, + AmenityFilter.parking => location is CarPark, + }; + } +} + +class AmenityFilterBar extends StatelessWidget { + const AmenityFilterBar({ + required this.selectedAmenity, + required this.onAmenitySelected, + super.key, + }); + + final AmenityFilter? selectedAmenity; + final void Function(AmenityFilter?) onAmenitySelected; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + child: Row( + mainAxisSize: MainAxisSize.min, + children: AmenityFilter.values.indexed.map((entry) { + final (index, amenity) = entry; + final isLast = index == AmenityFilter.values.length - 1; + final isSelected = selectedAmenity == amenity; + return Padding( + padding: EdgeInsets.only(right: isLast ? 0 : 8, bottom: 8), + child: _AmenityChip( + amenity: amenity, + isSelected: isSelected, + onTap: () => onAmenitySelected(isSelected ? null : amenity), + ), + ); + }).toList(), + ), + ); + } +} + +class _AmenityChip extends StatelessWidget { + const _AmenityChip({ + required this.amenity, + required this.isSelected, + required this.onTap, + }); + + final AmenityFilter amenity; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final primaryColor = colorScheme.primary; + final secondaryColor = colorScheme.secondary; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow, + offset: const Offset(0, 4), + blurRadius: 4, + ), + ], + ), + child: Material( + color: isSelected ? primaryColor : secondaryColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + amenity.icon, + size: 18, + color: isSelected + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSecondary, + ), + const SizedBox(width: 6), + Text( + amenity.label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/uni_app/lib/view/map/widgets/floor_selector_button.dart b/packages/uni_app/lib/view/map/widgets/floor_selector_button.dart new file mode 100644 index 000000000..3877f8904 --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/floor_selector_button.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'floor_selector_menu.dart'; + +class FloorSelectorButton extends StatefulWidget { + const FloorSelectorButton({ + required this.floors, + required this.selectedFloor, + required this.onFloorSelected, + super.key, + }); + + final List floors; + final int? selectedFloor; + final void Function(int?) onFloorSelected; + + @override + State createState() => _FloorSelectorButtonState(); +} + +class _FloorSelectorButtonState extends State { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + bool _isOpen = false; + + void _toggle() { + _isOpen ? _close() : _open(); + } + + void _open() { + _overlayEntry = _createOverlay(); + Overlay.of(context).insert(_overlayEntry!); + _isOpen = true; + } + + void _close() { + _overlayEntry?.remove(); + _overlayEntry = null; + _isOpen = false; + } + + @override + void dispose() { + _close(); + super.dispose(); + } + + OverlayEntry _createOverlay() { + return OverlayEntry( + builder: (_) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _close, + child: Stack( + children: [ + CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + followerAnchor: Alignment.bottomLeft, + offset: const Offset(0, -8), + child: Material( + color: Colors.transparent, + child: FloorSelectorMenu( + floors: widget.floors, + selectedFloor: widget.selectedFloor, + onFloorSelected: (floor) { + widget.onFloorSelected(floor); + _close(); + }, + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: GestureDetector( + onTap: _toggle, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow, + blurRadius: 4, + ), + ], + ), + alignment: Alignment.center, + child: Text( + widget.selectedFloor?.toString() ?? '-', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + ), + ); + } +} diff --git a/packages/uni_app/lib/view/map/widgets/floor_selector_menu.dart b/packages/uni_app/lib/view/map/widgets/floor_selector_menu.dart new file mode 100644 index 000000000..722a159a5 --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/floor_selector_menu.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class FloorSelectorMenu extends StatelessWidget { + const FloorSelectorMenu({ + 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: Theme.of(context).colorScheme.secondary, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow(color: Theme.of(context).colorScheme.shadow, 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: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: isSelected + ? Theme.of(context).focusColor + : Colors.transparent, + ), + child: Center( + child: Text( + floor.toString(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSecondary, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/packages/uni_app/lib/view/map/widgets/floorless_marker_popup.dart b/packages/uni_app/lib/view/map/widgets/floorless_marker_popup.dart index 97b3db202..0586ba6d1 100644 --- a/packages/uni_app/lib/view/map/widgets/floorless_marker_popup.dart +++ b/packages/uni_app/lib/view/map/widgets/floorless_marker_popup.dart @@ -39,7 +39,7 @@ class FloorlessLocationMarkerPopup extends StatelessWidget { return locations .map( (location) => Text( - location.description(), + location.description(context), textAlign: TextAlign.left, style: TextStyle(color: Theme.of(context).colorScheme.onSecondary), ), @@ -55,7 +55,7 @@ class LocationRow extends StatelessWidget { @override Widget build(BuildContext context) { return Text( - location.description(), + location.description(context), textAlign: TextAlign.left, style: TextStyle(color: Theme.of(context).colorScheme.onSecondary), ); 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..56d60a5a4 --- /dev/null +++ b/packages/uni_app/lib/view/map/widgets/indoor_floor_layer.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:uni/model/entities/indoor_floor_plan.dart'; +import 'package:uni/view/map/helpers/room_label_info.dart'; + +class IndoorFloorLayer extends StatelessWidget { + const IndoorFloorLayer({ + required this.floorPlans, + required this.selectedFloor, + this.roomFilter, + this.hasSearchContent = false, + super.key, + }); + + final List floorPlans; + final int? selectedFloor; + final bool Function(IndoorRoom room)? roomFilter; + final bool hasSearchContent; + + @override + Widget build(BuildContext context) { + if (selectedFloor == null) { + return const SizedBox.shrink(); + } + + final camera = MapCamera.of(context); + if (camera.zoom < 17.8 && !hasSearchContent) { + return const SizedBox.shrink(); + } + + final currentFloorPlans = floorPlans + .where((plan) => plan.floor == selectedFloor) + .toList(); + final visibleRooms = currentFloorPlans + .expand((plan) => plan.rooms) + .where((room) => roomFilter?.call(room) ?? true) + .toList(); + + if (currentFloorPlans.isEmpty) { + return const SizedBox.shrink(); + } + + return Stack( + children: [ + // Rooms layer + PolygonLayer( + polygons: visibleRooms + .map( + (room) => Polygon( + points: room.polygon, + color: Theme.of(context).colorScheme.secondary, + borderColor: Theme.of(context).colorScheme.primary, + borderStrokeWidth: 2, + ), + ) + .toList(), + ), + // Corridors layer + PolygonLayer( + polygons: currentFloorPlans + .expand( + (plan) => plan.corridors.map( + (corridor) => Polygon( + points: corridor.polygon, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + borderColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + borderStrokeWidth: 1, + ), + ), + ) + .toList(), + ), + // Labels layer + MarkerLayer( + markers: visibleRooms.map((room) { + final labelProps = getRoomLabelInfo( + room.polygon, + room.ref, + camera.zoom, + ); + + return Marker( + point: labelProps.center, + width: 100, + alignment: Alignment.center, + child: Transform.rotate( + angle: labelProps.angle, + child: Center( + child: Text( + room.ref, + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondary, + fontSize: labelProps.fontSize, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/packages/uni_app/lib/view/map/widgets/marker.dart b/packages/uni_app/lib/view/map/widgets/marker.dart index fe94c1986..c6e945aee 100644 --- a/packages/uni_app/lib/view/map/widgets/marker.dart +++ b/packages/uni_app/lib/view/map/widgets/marker.dart @@ -6,41 +6,69 @@ import 'package:uni/model/entities/location_group.dart'; import 'package:uni_ui/icons.dart'; class LocationMarker extends Marker { - LocationMarker(this.latlng, this.locationGroup) - : super( - alignment: Alignment.center, - height: 20, - width: 20, - point: latlng, - child: Builder( - builder: (context) => DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSecondary, - border: Border.all( - color: Theme.of(context).colorScheme.onSecondary, - ), - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: MarkerIcon( - location: locationGroup.getLocationWithMostWeight(), - ), - ), - ), - ); + LocationMarker( + this.latlng, + this.locationGroup, { + this.selectedFloor, + this.additionalCount = 0, + }) : super( + alignment: Alignment.center, + height: 20, + width: 20, + point: latlng, + child: Builder( + builder: (context) => DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSecondary, + border: Border.all( + color: Theme.of(context).colorScheme.onSecondary, + ), + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + child: MarkerIcon( + location: locationGroup.getLocationForFloor(selectedFloor), + additionalCount: additionalCount, + ), + ), + ), + ); final LocationGroup locationGroup; final LatLng latlng; + final int? selectedFloor; + final int additionalCount; } class MarkerIcon extends StatelessWidget { - const MarkerIcon({super.key, this.location}); + const MarkerIcon({super.key, this.location, this.additionalCount = 0}); final Location? location; + final int additionalCount; @override Widget build(BuildContext context) { if (location == null) { - return Container(); + return const SizedBox.shrink(); } + final markerIcon = _buildLocationIcon(context); + if (additionalCount <= 0) { + return markerIcon; + } + + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + markerIcon, + Positioned( + top: -7, + right: -9, + child: _MarkerAdditionalCountBadge(count: additionalCount), + ), + ], + ); + } + + Widget _buildLocationIcon(BuildContext context) { if (location?.icon is IconData) { return UniIcon( location?.icon as IconData, @@ -48,13 +76,46 @@ class MarkerIcon extends StatelessWidget { size: 12, solid: true, ); - } else { - return UniIcon( - Icons.device_unknown, - color: Theme.of(context).colorScheme.secondary, - size: 12, - solid: true, - ); } + + return UniIcon( + Icons.device_unknown, + color: Theme.of(context).colorScheme.primary, + size: 12, + solid: true, + ); + } +} + +class _MarkerAdditionalCountBadge extends StatelessWidget { + const _MarkerAdditionalCountBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(9), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 0.5), + child: Text( + '+$count', + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onError, + fontSize: 7, + fontWeight: FontWeight.w700, + ) ?? + TextStyle( + color: Theme.of(context).colorScheme.onError, + fontSize: 7, + fontWeight: FontWeight.w700, + ), + ), + ), + ); } } diff --git a/packages/uni_app/lib/view/map/widgets/marker_popup.dart b/packages/uni_app/lib/view/map/widgets/marker_popup.dart index 0ef6de8db..751545e8a 100644 --- a/packages/uni_app/lib/view/map/widgets/marker_popup.dart +++ b/packages/uni_app/lib/view/map/widgets/marker_popup.dart @@ -104,7 +104,7 @@ class LocationRow extends StatelessWidget { @override Widget build(BuildContext context) { return Text( - location.description(), + location.description(context), textAlign: TextAlign.left, overflow: TextOverflow.ellipsis, style: TextStyle(color: color), diff --git a/packages/uni_ui/lib/icons.dart b/packages/uni_ui/lib/icons.dart index 662e5c79a..8ac2737c9 100644 --- a/packages/uni_ui/lib/icons.dart +++ b/packages/uni_ui/lib/icons.dart @@ -47,6 +47,7 @@ class UniIcons { // Locations pins icons static const money = PhosphorIconsDuotone.money; static const coffee = PhosphorIconsDuotone.coffee; + static const cookie = PhosphorIconsDuotone.cookie; static const printer = PhosphorIconsDuotone.printer; static const lockers = PhosphorIconsDuotone.lockers; static const bookOpen = PhosphorIconsDuotone.bookOpen; @@ -55,6 +56,7 @@ class UniIcons { static const storefront = PhosphorIconsDuotone.storefront; static const questionMark = PhosphorIconsDuotone.questionMark; static const toilet = PhosphorIconsDuotone.toiletPaper; + static const carPark = PhosphorIconsDuotone.letterCircleP; // Restaurants icons static const canteen = PhosphorIconsDuotone.cookingPot; @@ -112,6 +114,5 @@ class UniIcon extends PhosphorIcon { color: color, semanticLabel: semanticLabel, textDirection: textDirection, - duotoneSecondaryOpacity: solid ? 1 : 0.20, ); }