visioglobe_flutter_plugin 0.0.1
visioglobe_flutter_plugin: ^0.0.1 copied to clipboard
A Flutter plugin providing an idiomatic Dart interface to the Visioglobe VisioMoveEssential Android SDK for indoor mapping and navigation.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:geolocator/geolocator.dart';
import 'package:visioglobe_flutter_plugin/visioglobe_flutter_plugin.dart';
import 'package:visioglobe_flutter_plugin_example/showcase_map_adapter.dart';
import 'package:visioglobe_flutter_plugin_example/showcase_drawer.dart';
const _simulationMarkerId = 'showcase_simulated_person';
class _SimulationPoint {
const _SimulationPoint({
required this.location,
this.placeId,
this.buildingId,
this.floorId,
});
final UserLocation location;
final String? placeId;
final String? buildingId;
final String? floorId;
}
void main() {
runApp(const VisioglobeExampleApp());
}
// ---------------------------------------------------------------------------
// Maneuver localization map (raw SDK enum -> human-readable string)
// ---------------------------------------------------------------------------
const Map<String, Map<String, String>> _maneuverLocale = {
'en': {
'UNKNOWN': 'Follow the route',
'TURN_LEFT': 'Turn left',
'TURN_RIGHT': 'Turn right',
'TURN_SHARP_LEFT': 'Sharp left',
'TURN_SHARP_RIGHT': 'Sharp right',
'TURN_SLIGHT_LEFT': 'Slight left',
'TURN_SLIGHT_RIGHT': 'Slight right',
'STRAIGHT': 'Continue straight',
'HEAD': 'Head towards',
'TAKE_ELEVATOR': 'Take the elevator',
'TAKE_STAIRS': 'Take the stairs',
'TAKE_ESCALATOR': 'Take the escalator',
'TAKE_TRAVELATOR': 'Take the travelator',
'ARRIVE': 'You have arrived',
'ARRIVE_LEFT': 'Destination on the left',
'ARRIVE_RIGHT': 'Destination on the right',
'DEPART': 'Depart',
'ROUNDABOUT_LEFT': 'At the roundabout, turn left',
'ROUNDABOUT_RIGHT': 'At the roundabout, turn right',
'U_TURN': 'Make a U-turn',
},
'ar': {
'UNKNOWN': 'اتبع المسار',
'TURN_LEFT': 'انعطف يساراً',
'TURN_RIGHT': 'انعطف يميناً',
'TURN_SHARP_LEFT': 'انعطف بشدة يساراً',
'TURN_SHARP_RIGHT': 'انعطف بشدة يميناً',
'TURN_SLIGHT_LEFT': 'انعطف قليلاً يساراً',
'TURN_SLIGHT_RIGHT': 'انعطف قليلاً يميناً',
'STRAIGHT': 'استمر بشكل مستقيم',
'HEAD': 'توجه نحو',
'TAKE_ELEVATOR': 'خذ المصعد',
'TAKE_STAIRS': 'اصعد السلالم',
'TAKE_ESCALATOR': 'اصعد السلم المتحرك',
'TAKE_TRAVELATOR': 'استخدم الرصيف المتحرك',
'ARRIVE': 'لقد وصلت',
'ARRIVE_LEFT': 'وجهتك على اليسار',
'ARRIVE_RIGHT': 'وجهتك على اليمين',
'DEPART': 'ابدأ الرحلة',
'ROUNDABOUT_LEFT': 'في الدوار، اتجه يساراً',
'ROUNDABOUT_RIGHT': 'في الدوار، اتجه يميناً',
'U_TURN': 'أدر استدارة كاملة',
},
};
class VisioglobeExampleApp extends StatelessWidget {
const VisioglobeExampleApp({super.key, this.adapter});
final ShowcaseMapAdapter? adapter;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Visioglobe Flutter Plugin',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1E88E5), // Visioglobe Blue
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1E88E5),
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: ThemeMode.system,
home: MapScreen(adapter: adapter),
);
}
}
class MapScreen extends StatefulWidget {
const MapScreen({super.key, this.adapter});
final ShowcaseMapAdapter? adapter;
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
late final ShowcaseMapAdapter _adapter =
widget.adapter ?? const PluginShowcaseMapAdapter();
ShowcaseMapController? _controller;
// State Variables
String _mapStatus = 'Initializing map';
GuidanceState? _guidanceState;
RouteResult? _currentRoute;
int _currentInstructionIndex = 0;
final String _locale = 'en';
// User Location & Tracking State
LocationTrackingMode _trackingMode = LocationTrackingMode.none;
// Compass Stream State
double _compassHeading = 0.0;
UserLocation? _latestUserLocation;
UserLocation? _failedLocationUpdate;
String? _locationError;
Future<void> Function()? _locationErrorRetry;
// Voice Guidance (TTS) State
final FlutterTts _tts = FlutterTts();
// Feature states
bool _isCompassEnabled = true;
bool _isLiveTracking = false;
StreamSubscription<Position>? _positionStream;
Timer? _simulationTimer;
bool _isSimulatingMovement = false;
List<_SimulationPoint> _simulationLocations = const <_SimulationPoint>[];
int _simulationIndex = 0;
final List<StreamSubscription<dynamic>> _controllerSubscriptions = [];
ShowcaseDrawerState _drawerState = ShowcaseDrawerState.collapsed;
ShowcaseFeature? _activeFeature;
int _mapGeneration = 0;
bool _isReloadingMap = false;
static const int _maxDiagnosticEvents = 50;
final List<DiagnosticEvent> _diagnosticEvents = <DiagnosticEvent>[];
int _nextDiagnosticId = 1;
@override
void dispose() {
_positionStream?.cancel();
_simulationTimer?.cancel();
for (final subscription in _controllerSubscriptions) {
subscription.cancel();
}
_controllerSubscriptions.clear();
_controller?.dispose();
super.dispose();
}
void _recordDiagnostic(
String channel,
String summary, {
String details = '',
DiagnosticSeverity severity = DiagnosticSeverity.info,
Future<void> Function()? retry,
}) {
if (!mounted) return;
final event = DiagnosticEvent(
id: _nextDiagnosticId++,
timestamp: DateTime.now(),
channel: channel,
summary: summary,
details: details.isEmpty ? summary : details,
severity: severity,
retry: retry,
);
setState(() {
_diagnosticEvents.add(event);
if (_diagnosticEvents.length > _maxDiagnosticEvents) {
_diagnosticEvents.removeRange(
0,
_diagnosticEvents.length - _maxDiagnosticEvents,
);
}
});
}
void _clearDiagnostics() {
if (!mounted) return;
setState(_diagnosticEvents.clear);
}
void _openFeature(ShowcaseFeature feature) {
setState(() {
_activeFeature = feature;
_drawerState = ShowcaseDrawerState.activePanel;
});
}
Future<void> _openFeatureForRetry(ShowcaseFeature feature) async {
_openFeature(feature);
}
Future<void> Function()? _diagnosticRetry(String channel) =>
switch (channel) {
'lifecycle' => _reloadMap,
'places' => _loadAllPlaces,
'camera' ||
'scene' => () => _openFeatureForRetry(ShowcaseFeature.camera),
'tracking' ||
'compass' => () => _openFeatureForRetry(ShowcaseFeature.location),
'markers' => () => _openFeatureForRetry(ShowcaseFeature.markers),
'guidance' => () => _openFeatureForRetry(ShowcaseFeature.routing),
_ => _reloadMap,
};
void _openDiagnostics() => _openFeature(ShowcaseFeature.diagnostics);
Future<void> _toggleCompass() async {
final controller = _controller;
if (controller == null) throw StateError('Map controller is not ready');
final enabled = !_isCompassEnabled;
await controller.setCompass(enabled);
if (!mounted) return;
setState(() => _isCompassEnabled = enabled);
}
Future<void> _toggleLiveTracking() async {
if (_isLiveTracking) {
await _positionStream?.cancel();
_positionStream = null;
if (mounted) setState(() => _isLiveTracking = false);
return;
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
throw StateError(
'Location services are disabled. Enable them on the device and retry.',
);
}
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
throw StateError(
'Location permission was denied. Grant permission and retry.',
);
}
}
if (permission == LocationPermission.deniedForever) {
throw StateError(
'Location permission is permanently denied. Enable it in system settings, then retry.',
);
}
if (!mounted) return;
setState(() => _isLiveTracking = true);
_positionStream =
Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 2,
),
).listen(
(Position position) {
final location = UserLocation(
lat: position.latitude,
lng: position.longitude,
alt: position.altitude,
bearing: position.heading > 0 ? position.heading : null,
);
unawaited(_pushLiveLocation(location));
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() {
_locationError = 'Live location stream failed: $error';
_failedLocationUpdate = null;
_locationErrorRetry = _retryLiveTracking;
});
},
);
}
Future<void> _pushLiveLocation(UserLocation location) async {
try {
await _controller?.updateUserLocation(location);
if (!mounted) return;
setState(() {
_latestUserLocation = location;
_failedLocationUpdate = null;
_locationError = null;
_locationErrorRetry = null;
});
} catch (error) {
if (!mounted) return;
setState(() {
_failedLocationUpdate = location;
_locationError = 'Live location update failed: $error';
_locationErrorRetry = _retryLiveLocation;
});
}
}
Future<void> _retryLiveLocation() async {
final location = _failedLocationUpdate;
if (location == null) return;
await _pushLiveLocation(location);
}
Future<void> _retryLiveTracking() async {
await _positionStream?.cancel();
_positionStream = null;
if (mounted) setState(() => _isLiveTracking = false);
await _toggleLiveTracking();
}
Future<void> _stopLocationSecondaryActions() async {
await _positionStream?.cancel();
_positionStream = null;
_simulationTimer?.cancel();
_simulationTimer = null;
await _removeSimulationMarker();
if (!mounted) return;
setState(() {
_isLiveTracking = false;
_isSimulatingMovement = false;
_simulationLocations = const <_SimulationPoint>[];
_simulationIndex = 0;
_latestUserLocation = null;
_failedLocationUpdate = null;
_locationError = null;
_locationErrorRetry = null;
});
}
Future<void> _toggleSimulation() async {
if (_isSimulatingMovement) {
_simulationTimer?.cancel();
_simulationTimer = null;
await _removeSimulationMarker();
if (mounted) {
setState(() {
_isSimulatingMovement = false;
_simulationLocations = const <_SimulationPoint>[];
_simulationIndex = 0;
});
}
return;
}
final locations = await _buildSimulationLocations();
if (locations.isEmpty) {
throw StateError(
'No venue positions are available for simulation. Update a user location or load the map again.',
);
}
if (mounted) {
setState(() {
_simulationLocations = locations;
_simulationIndex = 0;
_isSimulatingMovement = true;
});
}
// Render the person immediately; subsequent ticks continue the loop.
unawaited(_pushSimulationPoint(locations.first));
_simulationTimer = Timer.periodic(const Duration(milliseconds: 1000), (
timer,
) {
if (_simulationLocations.isEmpty) return;
final location = _simulationLocations[_simulationIndex];
_simulationIndex = (_simulationIndex + 1) % _simulationLocations.length;
unawaited(_pushSimulationPoint(location));
});
}
Future<void> _pushSimulationPoint(_SimulationPoint point) async {
await _pushLiveLocation(point.location);
if (!mounted || !_isSimulatingMovement) return;
final controller = _controller;
if (controller == null) return;
try {
await controller.removeMarker(_simulationMarkerId);
await controller.addMarker(
MarkerOptions(
markerId: _simulationMarkerId,
placeId: point.placeId,
lat: point.placeId == null ? point.location.lat : null,
lng: point.placeId == null ? point.location.lng : null,
alt: point.location.alt,
buildingId: point.buildingId,
floorId: point.floorId,
label: '🧍',
anchorMode: AnchorMode.bottomCenter,
),
);
} catch (error) {
if (!mounted) return;
setState(() {
_locationError = 'Simulation person marker failed: $error';
_locationErrorRetry = () => _pushSimulationPoint(point);
});
}
}
Future<void> _removeSimulationMarker() async {
try {
await _controller?.removeMarker(_simulationMarkerId);
} catch (error) {
debugPrint('Unable to remove simulation person marker: $error');
}
}
Future<List<_SimulationPoint>> _buildSimulationLocations() async {
final route = _currentRoute;
if (route != null && route.segments.isNotEmpty) {
final routeLocations = <RouteLocation>[];
for (final segment in route.segments) {
routeLocations.addAll(segment.path);
}
final locations = _locationsWithBearings(routeLocations);
if (locations.isNotEmpty) {
return locations
.map((location) => _SimulationPoint(location: location))
.toList();
}
}
final controller = _controller;
if (controller == null) throw StateError('Map controller is not ready');
// Map data and the camera become usable on separate SDK callbacks. A tap
// immediately after “Map Ready” can therefore briefly see no positions.
// Poll the two authoritative sources for a short, bounded window instead
// of failing a valid one-tap showcase action.
for (var attempt = 0; attempt < 5; attempt++) {
try {
final places = (await controller.getAllPlaces())
.where((place) => place.lat != null && place.lng != null)
.take(12)
.toList();
final venueLocations = _locationsWithBearings(
places.map(
(place) => RouteLocation(
lat: place.lat,
lng: place.lng,
alt: _latestUserLocation?.alt ?? 12.0,
buildingId: place.buildingId,
floorId: place.floorId,
),
),
);
if (venueLocations.isNotEmpty) {
return [
for (var index = 0; index < venueLocations.length; index++)
_SimulationPoint(
location: venueLocations[index],
placeId: places[index].placeId,
buildingId: places[index].buildingId,
floorId: places[index].floorId,
),
];
}
} catch (_) {
// Place data can still be transitioning from dataLoaded to viewLoaded.
}
// The current camera target is guaranteed to be inside the loaded map,
// unlike a hard-coded geographic coordinate.
var current = _latestUserLocation;
if (current == null) {
try {
final camera = await controller.getCurrentCamera();
if (camera.targetLat != null && camera.targetLng != null) {
current = UserLocation(
lat: camera.targetLat!,
lng: camera.targetLng!,
alt: 0.0,
bearing: camera.bearing,
);
}
} catch (_) {
// Retry below while the SDK finishes preparing its camera context.
}
}
if (current != null) {
return _locationsWithBearings([
RouteLocation(lat: current.lat, lng: current.lng, alt: current.alt),
RouteLocation(
lat: current.lat + 0.00002,
lng: current.lng,
alt: current.alt,
),
RouteLocation(
lat: current.lat + 0.00002,
lng: current.lng + 0.00002,
alt: current.alt,
),
RouteLocation(
lat: current.lat,
lng: current.lng + 0.00002,
alt: current.alt,
),
]).map((location) => _SimulationPoint(location: location)).toList();
}
if (attempt < 4) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
return const <_SimulationPoint>[];
}
List<UserLocation> _locationsWithBearings(Iterable<RouteLocation> points) {
final locations = points
.where((point) => point.lat != null && point.lng != null)
.map(
(point) => UserLocation(
lat: point.lat!,
lng: point.lng!,
alt: point.alt ?? _latestUserLocation?.alt ?? 12.0,
),
)
.toList();
return [
for (var index = 0; index < locations.length; index++)
UserLocation(
lat: locations[index].lat,
lng: locations[index].lng,
alt: locations[index].alt,
bearing: locations.length < 2
? locations[index].bearing
: Geolocator.bearingBetween(
locations[index].lat,
locations[index].lng,
locations[(index + 1) % locations.length].lat,
locations[(index + 1) % locations.length].lng,
) %
360,
),
];
}
String _localizeManeuver(String rawType) {
return _maneuverLocale[_locale]?[rawType] ??
_maneuverLocale['en']?[rawType] ??
rawType;
}
Future<void> _speak(String text) async {
if (text.trim().isEmpty) return;
try {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(0.5);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
await _tts.speak(text);
} catch (e) {
debugPrint('TTS speak error: $e');
}
}
// Place catalog state is owned by the example app; the Places panel applies
// filtering and recent-history policy over this immutable snapshot.
List<PlaceInfo> _allPlaces = <PlaceInfo>[];
final List<String> _recentPlaceIds = <String>[];
final String _mapHash =
'dev-mf7b5b91d733d84d3b1ae439aebd8a9a9125d88e3'; // visio_island_v5
Future<void> _loadAllPlaces() async {
try {
final places = await _controller?.getAllPlaces() ?? <PlaceInfo>[];
if (!mounted) return;
setState(() {
_allPlaces = places;
});
_recordDiagnostic(
'places',
'Place catalog loaded',
details: 'Loaded ${places.length} places from the Venue.',
);
} on PlaceException catch (error) {
debugPrint('Unable to load Places: $error');
_recordDiagnostic(
'places',
'Place catalog failed',
details: error.toString(),
severity: DiagnosticSeverity.error,
retry: _loadAllPlaces,
);
} catch (error) {
_recordDiagnostic(
'places',
'Place catalog failed',
details: error.toString(),
severity: DiagnosticSeverity.error,
retry: _loadAllPlaces,
);
}
}
void _recordRecentPlace(String placeId) {
_recentPlaceIds.remove(placeId);
_recentPlaceIds.insert(0, placeId);
if (_recentPlaceIds.length > 10) {
_recentPlaceIds.removeLast();
}
}
Future<void> _focusPlace(PlaceInfo place) async {
final controller = _controller;
if (controller == null) throw StateError('Map controller is not ready');
await controller.animateCamera(
CameraContext(targetId: place.placeId, pitch: -30),
);
if (!mounted) return;
setState(() => _recordRecentPlace(place.placeId));
}
void _resetPlaceHistory() {
if (!mounted) return;
setState(_recentPlaceIds.clear);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
// 1. The Map View
_buildMapHost(),
// 2. Top HUD and venue actions
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTopHUD(theme),
const SizedBox(height: 8),
_buildMapHostActions(theme),
],
),
),
),
),
// 3. Navigation / Guidance Card (Shows whenever a route is active)
if (_currentRoute != null)
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 160.0),
child: _buildGuidanceOverlay(theme),
),
),
),
ShowcaseCapabilityDrawer(
state: _drawerState,
panel: _buildFeaturePanel(theme),
onOpen: () =>
setState(() => _drawerState = ShowcaseDrawerState.featureList),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onSelect: (feature) => setState(() {
_activeFeature = feature;
_drawerState = ShowcaseDrawerState.activePanel;
}),
),
],
),
);
}
Widget _buildMapHost() {
if (_isReloadingMap) {
return const ColoredBox(color: Color(0xFFCFD8DC));
}
return KeyedSubtree(
key: ValueKey<String>('showcase-map-$_mapGeneration'),
child: _adapter.buildMap(
mapHash: _mapHash,
mapSecretCode: 0,
onMapCreated: _onMapCreated,
),
);
}
void _onMapCreated(ShowcaseMapController controller) {
void listenToController<T>(
Stream<T> stream,
void Function(T) listener, {
required String channel,
}) {
_controllerSubscriptions.add(
stream.listen(
listener,
onError: (Object error, StackTrace stack) {
_recordDiagnostic(
channel,
'$channel stream failed',
details: '$error\n$stack',
severity: DiagnosticSeverity.error,
retry: _diagnosticRetry(channel),
);
},
),
);
}
setState(() {
_controller = controller;
for (final subscription in _controllerSubscriptions) {
subscription.cancel();
}
_controllerSubscriptions.clear();
});
// Listen to Map Lifecycle Events
listenToController(_controller!.onLifecycleEvent, (event) {
_recordDiagnostic(
'lifecycle',
'Map lifecycle: ${event.name}',
details: 'MapLifecycleEvent.${event.name}',
severity: event == MapLifecycleEvent.failed
? DiagnosticSeverity.error
: DiagnosticSeverity.info,
retry: event == MapLifecycleEvent.failed ? _reloadMap : null,
);
setState(() {
switch (event) {
case MapLifecycleEvent.initializing:
_mapStatus = 'Loading Map Data...';
break;
case MapLifecycleEvent.dataLoaded:
_mapStatus = 'Rendering Engine...';
break;
case MapLifecycleEvent.viewLoaded:
_mapStatus = 'Map Ready';
// Explicitly disable the native SDK navigation header UI
_controller!.setNavigationHeaderViewVisible(false);
break;
case MapLifecycleEvent.failed:
_mapStatus = 'Map Load Failed';
break;
}
});
if (event == MapLifecycleEvent.dataLoaded) {
_loadAllPlaces();
}
}, channel: 'lifecycle');
// Listen to Camera and scene context updates for diagnostics.
listenToController(_controller!.onCameraChangedStream, (camera) {
_recordDiagnostic(
'camera',
'Camera changed',
details:
'targetId=${camera.targetId}, target=${camera.targetLat},${camera.targetLng}, pitch=${camera.pitch}, bearing=${camera.bearing}',
);
}, channel: 'camera');
listenToController(_controller!.onSceneChanged, (scene) {
_recordDiagnostic(
'scene',
'Scene changed',
details: 'buildingId=${scene.buildingId}, floorId=${scene.floorId}',
);
}, channel: 'scene');
// Listen to Location Tracking Mode updates
listenToController(_controller!.onTrackingModeChanged, (mode) {
_recordDiagnostic(
'tracking',
'Tracking mode changed',
details: 'mode=${mode.name}',
);
setState(() {
_trackingMode = mode;
});
}, channel: 'tracking');
// Listen to Custom Marker Tap events
listenToController(_controller!.onCustomMarkerTapped, (markerId) {
_recordDiagnostic(
'markers',
'Custom marker tapped',
details: 'markerId=$markerId',
);
if (mounted) {
_showSnackBar(context, '📍 Custom marker tapped: $markerId');
}
}, channel: 'markers');
// Listen to Compass Heading Stream
listenToController(_controller!.onCompassHeadingChanged, (heading) {
_recordDiagnostic(
'compass',
'Compass heading changed',
details: 'heading=$heading°',
);
setState(() {
_compassHeading = heading;
});
}, channel: 'compass');
// Listen to Turn-by-Turn Guidance Updates
listenToController(_controller!.onGuidanceChanged, (state) {
_recordDiagnostic(
'guidance',
state.reachedDestination ? 'Destination reached' : 'Guidance updated',
details:
'instruction=${state.currentInstruction}, next=${state.nextManeuverType}, distance=${state.distanceToNextManeuver}',
);
setState(() {
_guidanceState = state;
if (state.reachedDestination) {
_speak('You have arrived at your destination.');
} else if (state.currentInstruction.isNotEmpty) {
_speak(state.currentInstruction);
}
});
}, channel: 'guidance');
}
Widget _buildTopHUD(ThemeData theme) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withValues(alpha: 0.18),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [
Semantics(
button: true,
label: 'Map status: $_mapStatus. Open diagnostics.',
child: Tooltip(
message: 'Open diagnostics',
child: InkWell(
key: const ValueKey<String>('map-status-button'),
borderRadius: BorderRadius.circular(18),
onTap: _openDiagnostics,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 14,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_mapStatus == 'Map Ready'
? Icons.check_circle
: Icons.sync,
color: _mapStatus == 'Map Ready'
? theme.colorScheme.primary
: theme.colorScheme.tertiary,
size: 20,
),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 150),
child: Text(
_mapStatus,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
),
),
),
Semantics(
button: true,
label: 'Search places',
child: TextButton.icon(
onPressed: () => setState(() {
_activeFeature = ShowcaseFeature.places;
_drawerState = ShowcaseDrawerState.activePanel;
}),
icon: const Icon(Icons.search, size: 16),
label: const Text('Search places'),
),
),
IconButton(
tooltip: 'Open SDK capabilities',
onPressed: () =>
setState(() => _drawerState = ShowcaseDrawerState.featureList),
icon: const Icon(Icons.widgets_outlined),
),
if (_mapStatus == 'Map Ready')
Semantics(
label: _isCompassEnabled
? 'Compass heading ${_compassHeading.toStringAsFixed(0)} degrees'
: 'Compass off',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isCompassEnabled) ...[
Transform.rotate(
angle: (_compassHeading * 3.141592653589793 / 180.0),
child: Icon(
Icons.navigation,
size: 16,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 4),
Text(
'${_compassHeading.toStringAsFixed(0)}°',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
] else
const Text(
'Compass off',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
Widget _buildMapHostActions(ThemeData theme) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Expanded(child: Text('Venue controls')),
Semantics(
button: true,
label: _isReloadingMap ? 'Reloading map' : 'Reload map',
child: TextButton(
key: const ValueKey<String>('reload-map-button'),
onPressed: _isReloadingMap ? null : _reloadMap,
child: Text(_isReloadingMap ? 'Reloading…' : 'Reload map'),
),
),
],
),
),
);
}
Widget _buildFeaturePanel(ThemeData theme) {
final feature = _activeFeature ?? ShowcaseFeature.map;
if (feature == ShowcaseFeature.camera) {
return CameraFeaturePanel(
controller: _controller,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
if (feature == ShowcaseFeature.places) {
return PlacesFeaturePanel(
controller: _controller,
catalog: _allPlaces,
recentPlaceIds: List<String>.unmodifiable(_recentPlaceIds),
onPlaceSelected: _focusPlace,
onHistoryReset: _resetPlaceHistory,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
if (feature == ShowcaseFeature.markers) {
return MarkersFeaturePanel(
controller: _controller,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
if (feature == ShowcaseFeature.diagnostics) {
return DiagnosticsFeaturePanel(
events: List<DiagnosticEvent>.unmodifiable(_diagnosticEvents),
onClear: _clearDiagnostics,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
if (feature == ShowcaseFeature.routing) {
return RoutingFeaturePanel(
controller: _controller,
places: List<PlaceInfo>.unmodifiable(_allPlaces),
guidance: _guidanceState,
onRouteChanged: (route) => setState(() {
_currentRoute = route;
_guidanceState = null;
_currentInstructionIndex = 0;
}),
onNavigationStarted: (route) => setState(() {
_currentRoute = route;
_currentInstructionIndex = 0;
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
onNavigationStopped: () => setState(() {}),
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
if (feature == ShowcaseFeature.location) {
return LocationFeaturePanel(
controller: _controller,
initialTrackingMode: _trackingMode,
latestLocation: _latestUserLocation,
externalError: _locationError,
externalRetry: _locationErrorRetry,
onLocationUpdated: (location) => setState(() {
_latestUserLocation = location;
_failedLocationUpdate = null;
_locationError = null;
}),
onLocationCleared: () => setState(() {
_latestUserLocation = null;
_failedLocationUpdate = null;
_locationError = null;
_locationErrorRetry = null;
_trackingMode = LocationTrackingMode.none;
}),
onResetSecondaryActions: _stopLocationSecondaryActions,
compassEnabled: _isCompassEnabled,
liveTracking: _isLiveTracking,
simulatingMovement: _isSimulatingMovement,
onToggleCompass: _toggleCompass,
onToggleLiveTracking: _toggleLiveTracking,
onToggleSimulation: _toggleSimulation,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
);
}
return ShowcaseFeaturePanel(
feature: feature,
onBack: () => setState(() {
_drawerState = ShowcaseDrawerState.featureList;
_activeFeature = null;
}),
onClose: () => setState(() {
_drawerState = ShowcaseDrawerState.collapsed;
_activeFeature = null;
}),
liveResult: _mapStatus,
rawValues: 'mapStatus: $_mapStatus\ntrackingMode: $_trackingMode',
onRetry: _reloadMap,
onReset: () => setState(() {}),
);
}
Future<void> _reloadMap() async {
if (_isReloadingMap) return;
_positionStream?.cancel();
_positionStream = null;
_simulationTimer?.cancel();
_simulationTimer = null;
await _removeSimulationMarker();
_simulationLocations = const <_SimulationPoint>[];
_simulationIndex = 0;
final controller = _controller;
if (controller != null) {
try {
await controller.stopNavigation();
await controller.clearRoute();
await controller.clearUserLocation();
await controller.clearAllMarkers();
} catch (error) {
debugPrint('Unable to reset map session: $error');
}
}
// Cancel listeners before removing the PlatformView. The native SDK owns a
// renderer thread, so loading again on the same controller is unsafe while
// that view is being torn down. A keyed replacement gives Flutter a chance
// to dispose the old native view before creating a fresh controller.
for (final subscription in _controllerSubscriptions) {
subscription.cancel();
}
_controllerSubscriptions.clear();
_controller = null;
if (!mounted) return;
setState(() {
_isReloadingMap = true;
_mapStatus = 'Initializing map';
_guidanceState = null;
_currentRoute = null;
_currentInstructionIndex = 0;
_trackingMode = LocationTrackingMode.none;
_latestUserLocation = null;
_failedLocationUpdate = null;
_locationError = null;
_locationErrorRetry = null;
_compassHeading = 0;
_isLiveTracking = false;
_isSimulatingMovement = false;
_simulationLocations = const <_SimulationPoint>[];
_simulationIndex = 0;
_allPlaces = [];
_recentPlaceIds.clear();
});
// Wait for the old PlatformView's dispose callback before creating another
// Visioglobe renderer. This avoids overlapping Vulkan/render-thread state.
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
setState(() {
_mapGeneration++;
_isReloadingMap = false;
});
}
Widget _buildGuidanceOverlay(ThemeData theme) {
if (_currentRoute == null) return const SizedBox.shrink();
final segments = _currentRoute!.segments;
final currentSeg =
(segments.isNotEmpty && _currentInstructionIndex < segments.length)
? segments[_currentInstructionIndex]
: null;
final rawManeuver = currentSeg?.maneuverType ?? 'UNKNOWN';
final instructionText =
_guidanceState?.currentInstruction.isNotEmpty == true
? _guidanceState!.currentInstruction
: _localizeManeuver(rawManeuver);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 16,
offset: Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Distance, ETA, Speaker, and Close Button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: const Icon(
Icons.navigation,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_currentRoute!.length.toStringAsFixed(0)} m',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer,
),
),
Text(
'ETA: ${(_currentRoute!.duration / 60).ceil()} min',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
],
),
],
),
Row(
children: [
IconButton.filledTonal(
icon: Icon(Icons.volume_up, size: 20),
tooltip: 'Speak current instruction',
onPressed: () => _speak(instructionText),
),
const SizedBox(width: 4),
IconButton.filledTonal(
icon: const Icon(Icons.close, size: 20),
tooltip: 'Clear Route',
onPressed: () {
_controller?.stopNavigation();
_controller?.clearRoute();
setState(() {
_currentRoute = null;
_guidanceState = null;
_currentInstructionIndex = 0;
});
},
),
],
),
],
),
const Divider(height: 20),
// Main Instruction Body
Text(
instructionText,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer,
),
),
if (_guidanceState?.nextManeuverType != null &&
_guidanceState!.nextManeuverType != 'UNKNOWN')
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${_locale == 'ar' ? 'التالي' : 'Next'}: ${_localizeManeuver(_guidanceState!.nextManeuverType)} in ${_guidanceState!.distanceToNextManeuver.toStringAsFixed(0)}m',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer.withValues(
alpha: 0.7,
),
),
),
),
const SizedBox(height: 12),
// Step Stepper Navigation Controls
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FilledButton.tonalIcon(
icon: const Icon(Icons.arrow_back, size: 18),
label: Text(_locale == 'ar' ? 'السابق' : 'Prev'),
onPressed: _currentInstructionIndex > 0
? () {
setState(() {
_currentInstructionIndex--;
_controller?.setNavigationIndex(
_currentInstructionIndex,
);
});
final seg = segments[_currentInstructionIndex];
_speak(_localizeManeuver(seg.maneuverType));
}
: null,
),
Text(
_locale == 'ar'
? 'خطوة ${_currentInstructionIndex + 1} من ${segments.isNotEmpty ? segments.length : 1}'
: 'Step ${_currentInstructionIndex + 1} of ${segments.isNotEmpty ? segments.length : 1}',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onPrimaryContainer,
),
),
FilledButton.tonalIcon(
icon: const Icon(Icons.arrow_forward, size: 18),
label: Text(_locale == 'ar' ? 'التالي' : 'Next'),
onPressed: _currentInstructionIndex < segments.length - 1
? () {
setState(() {
_currentInstructionIndex++;
_controller?.setNavigationIndex(
_currentInstructionIndex,
);
});
final seg = segments[_currentInstructionIndex];
_speak(_localizeManeuver(seg.maneuverType));
}
: null,
),
],
),
],
),
);
}
void _showSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}
}
class CircleColor extends StatelessWidget {
const CircleColor({super.key, required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
);
}
}