visioglobe_flutter_plugin 0.0.1 copy "visioglobe_flutter_plugin: ^0.0.1" to clipboard
visioglobe_flutter_plugin: ^0.0.1 copied to clipboard

PlatformAndroid

A Flutter plugin providing an idiomatic Dart interface to the Visioglobe VisioMoveEssential Android SDK for indoor mapping and navigation.

example/lib/main.dart

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),
      ),
    );
  }
}
3
likes
160
points
78
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin providing an idiomatic Dart interface to the Visioglobe VisioMoveEssential Android SDK for indoor mapping and navigation.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on visioglobe_flutter_plugin

Packages that implement visioglobe_flutter_plugin