import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/data/models/amicale_model.dart'; import 'package:geosector_app/core/services/api_service.dart'; import 'package:geosector_app/core/services/current_amicale_service.dart'; import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart'; import 'package:geosector_app/app.dart'; import 'package:sensors_plus/sensors_plus.dart'; class UserFieldModePage extends StatefulWidget { const UserFieldModePage({super.key}); @override State createState() => _UserFieldModePageState(); } class _UserFieldModePageState extends State with TickerProviderStateMixin { // Controllers final MapController _mapController = MapController(); final TextEditingController _searchController = TextEditingController(); // Animation controllers pour le clignotement late AnimationController _gpsBlinkController; late AnimationController _networkBlinkController; late Animation _gpsBlinkAnimation; late Animation _networkBlinkAnimation; // Position et tracking Position? _currentPosition; StreamSubscription? _positionStreamSubscription; Timer? _qualityUpdateTimer; // Qualité des signaux double _gpsAccuracy = 999; ConnectivityResult _connectivityResult = ConnectivityResult.none; bool _isGpsEnabled = false; // Mode boussole bool _compassMode = false; double _heading = 0; StreamSubscription? _magnetometerSubscription; // Filtrage et recherche String _searchQuery = ''; List _nearbyPassages = []; // État de chargement bool _isLoading = true; bool _locationPermissionGranted = false; String _statusMessage = ''; @override void initState() { super.initState(); _initializeAnimations(); if (kIsWeb) { // Sur web, utiliser une position simulée pour éviter le blocage _initializeWebMode(); } else { // Sur mobile, utiliser le GPS réel _checkPermissionsAndStartTracking(); _startQualityMonitoring(); } } void _initializeWebMode() async { // Essayer d'obtenir la position réelle depuis le navigateur try { setState(() { _statusMessage = "Demande d'autorisation de géolocalisation..."; }); // Demander la permission et obtenir la position final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); setState(() { _currentPosition = position; _gpsAccuracy = position.accuracy; _isGpsEnabled = true; _connectivityResult = ConnectivityResult.wifi; _isLoading = false; _locationPermissionGranted = true; _statusMessage = ""; }); // Charger les passages proches de la position réelle _updateNearbyPassages(); // Démarrer le suivi de position même sur web _startLocationTracking(); } catch (e) { debugPrint('Erreur géolocalisation web: $e'); // Essayer d'utiliser les coordonnées GPS de l'amicale double fallbackLat = 46.603354; // Centre de la France par défaut double fallbackLng = 1.888334; String statusMessage = "Position approximative"; try { final amicale = CurrentAmicaleService.instance.currentAmicale; if (amicale != null && amicale.gpsLat.isNotEmpty && amicale.gpsLng.isNotEmpty) { final amicaleLat = double.tryParse(amicale.gpsLat); final amicaleLng = double.tryParse(amicale.gpsLng); if (amicaleLat != null && amicaleLng != null) { fallbackLat = amicaleLat; fallbackLng = amicaleLng; statusMessage = "Position de l'amicale"; debugPrint('Utilisation des coordonnées de l\'amicale: $fallbackLat, $fallbackLng'); } } } catch (amicaleError) { debugPrint('Erreur récupération coordonnées amicale: $amicaleError'); } // Utiliser la position de fallback (amicale ou centre France) setState(() { _currentPosition = Position( latitude: fallbackLat, longitude: fallbackLng, timestamp: DateTime.now(), accuracy: 100.0, altitude: 0.0, heading: 0.0, speed: 0.0, speedAccuracy: 0.0, altitudeAccuracy: 0.0, headingAccuracy: 0.0, ); _gpsAccuracy = 100.0; _isGpsEnabled = false; _connectivityResult = ConnectivityResult.wifi; _isLoading = false; _locationPermissionGranted = false; _statusMessage = statusMessage; }); _updateNearbyPassages(); } } void _initializeAnimations() { // Animation pour GPS faible/perdu _gpsBlinkController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _gpsBlinkAnimation = Tween( begin: 1.0, end: 0.3, ).animate(CurvedAnimation( parent: _gpsBlinkController, curve: Curves.easeInOut, )); // Animation pour réseau faible/perdu _networkBlinkController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _networkBlinkAnimation = Tween( begin: 1.0, end: 0.3, ).animate(CurvedAnimation( parent: _networkBlinkController, curve: Curves.easeInOut, )); } Future _checkPermissionsAndStartTracking() async { // Les permissions GPS sont déjà vérifiées obligatoirement dans splash_page.dart // On peut donc directement commencer le tracking setState(() { _locationPermissionGranted = true; }); _startLocationTracking(); } void _startLocationTracking() { const locationSettings = LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 5, // Mise à jour tous les 5 mètres ); _positionStreamSubscription = Geolocator.getPositionStream( locationSettings: locationSettings, ).listen((Position position) { setState(() { _currentPosition = position; _gpsAccuracy = position.accuracy; _isGpsEnabled = true; _isLoading = false; }); _updateNearbyPassages(); _updateBlinkAnimations(); // Centrer la carte sur la nouvelle position if (_mapController.mapEventStream != null && !_compassMode) { _mapController.move(LatLng(position.latitude, position.longitude), 17); } }, onError: (error) { setState(() { _isGpsEnabled = false; }); }); } void _startQualityMonitoring() { // Mise à jour toutes les 5 secondes _qualityUpdateTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { // Vérifier la connexion réseau final connectivityResults = await Connectivity().checkConnectivity(); setState(() { // Prendre le premier résultat de la liste _connectivityResult = connectivityResults.isNotEmpty ? connectivityResults.first : ConnectivityResult.none; }); // Vérifier si le GPS est activé final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); setState(() { _isGpsEnabled = isLocationServiceEnabled; }); _updateBlinkAnimations(); }); } void _updateBlinkAnimations() { // GPS: clignoter si précision > 30m ou GPS désactivé if (!_isGpsEnabled || _gpsAccuracy > 30) { _gpsBlinkController.repeat(reverse: true); } else { _gpsBlinkController.stop(); _gpsBlinkController.value = 1.0; } // Réseau: clignoter si connexion faible ou absente if (_connectivityResult == ConnectivityResult.none || _connectivityResult == ConnectivityResult.mobile) { _networkBlinkController.repeat(reverse: true); } else { _networkBlinkController.stop(); _networkBlinkController.value = 1.0; } } void _updateNearbyPassages() { if (_currentPosition == null) return; final passagesBox = Hive.box(AppKeys.passagesBoxName); final allPassages = passagesBox.values.where((p) => p.fkType == 2).toList(); // Calculer les distances et trier final passagesWithDistance = allPassages.map((passage) { // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat ?? '0') ?? 0; final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0; final distance = _calculateDistance( _currentPosition!.latitude, _currentPosition!.longitude, lat, lng, ); return MapEntry(passage, distance); }).toList(); passagesWithDistance.sort((a, b) => a.value.compareTo(b.value)); setState(() { _nearbyPassages = passagesWithDistance .take(50) // Limiter à 50 passages .where((entry) => entry.value <= 2000) // Max 2km .map((entry) => entry.key) .toList(); }); } double _calculateDistance(double lat1, double lon1, double lat2, double lon2) { const distance = Distance(); return distance.as( LengthUnit.Meter, LatLng(lat1, lon1), LatLng(lat2, lon2), ); } void _toggleCompassMode() { // Mode boussole désactivé sur web if (kIsWeb) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Le mode boussole nécessite un appareil mobile'), duration: Duration(seconds: 2), ), ); return; } setState(() { _compassMode = !_compassMode; }); if (_compassMode) { _startCompass(); // Vibration légère pour feedback HapticFeedback.lightImpact(); } else { _stopCompass(); } } void _startCompass() { _magnetometerSubscription = magnetometerEvents.listen((MagnetometerEvent event) { setState(() { // Calculer l'orientation à partir du magnétomètre _heading = math.atan2(event.y, event.x) * (180 / math.pi); }); }); } void _stopCompass() { _magnetometerSubscription?.cancel(); _magnetometerSubscription = null; setState(() { _heading = 0; }); } void _recenterMap() { if (_currentPosition != null) { _mapController.move( LatLng(_currentPosition!.latitude, _currentPosition!.longitude), 17, ); HapticFeedback.lightImpact(); } } void _openPassageForm(PassageModel passage) { showDialog( context: context, builder: (context) => PassageFormDialog( passage: passage, title: 'Modifier le passage', readOnly: false, passageRepository: passageRepository, userRepository: userRepository, operationRepository: operationRepository, onSuccess: () { // Rafraîchir les passages après modification _updateNearbyPassages(); }, ), ); } @override void dispose() { _positionStreamSubscription?.cancel(); _qualityUpdateTimer?.cancel(); _magnetometerSubscription?.cancel(); _gpsBlinkController.dispose(); _networkBlinkController.dispose(); _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( title: Row( children: [ const Text('Mode terrain'), if (_currentPosition != null) ...[ const SizedBox(width: 16), Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Text( kIsWeb ? (_locationPermissionGranted ? 'GPS: ${_currentPosition!.latitude.toStringAsFixed(4)}, ${_currentPosition!.longitude.toStringAsFixed(4)}' : _statusMessage.isNotEmpty ? _statusMessage : 'Position approximative') : '', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), ), ), ], ], ), backgroundColor: Colors.green, foregroundColor: Colors.white, elevation: 0, actions: [ // Indicateur GPS _buildGpsIndicator(), const SizedBox(width: 8), // Indicateur réseau _buildNetworkIndicator(), const SizedBox(width: 16), ], ), body: _isLoading ? const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Recherche de votre position...'), ], ), ) : Column( children: [ // Carte (40% de la hauteur) SizedBox( height: MediaQuery.of(context).size.height * 0.4, child: _buildMap(), ), // Barre de recherche Container( color: Colors.white, padding: const EdgeInsets.all(12), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Rechercher une rue...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _searchController.clear(); _searchQuery = ''; }); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none, ), filled: true, fillColor: Colors.grey[100], contentPadding: const EdgeInsets.symmetric(horizontal: 20), ), onChanged: (value) { setState(() { _searchQuery = value.toLowerCase(); }); }, ), ), // En-tête de la liste Container( color: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Icon(Icons.location_on, color: Colors.green[600], size: 20), const SizedBox(width: 8), Text( '${_getFilteredPassages().length} passage${_getFilteredPassages().length > 1 ? 's' : ''} à proximité', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey[800], ), ), ], ), ), // Liste des passages (reste de la hauteur) Expanded( child: _buildPassagesList(), ), ], ), ); } Widget _buildGpsIndicator() { IconData icon; Color color; String tooltip; if (!_isGpsEnabled) { icon = Icons.gps_off; color = Colors.black54; tooltip = 'GPS désactivé'; } else if (_gpsAccuracy <= 5) { icon = Icons.gps_fixed; color = Colors.green; tooltip = 'GPS: Excellent (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else if (_gpsAccuracy <= 15) { icon = Icons.gps_fixed; color = Colors.yellow[700]!; tooltip = 'GPS: Bon (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else if (_gpsAccuracy <= 30) { icon = Icons.gps_not_fixed; color = Colors.orange; tooltip = 'GPS: Moyen (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else { icon = Icons.gps_not_fixed; color = Colors.red; tooltip = 'GPS: Faible (${_gpsAccuracy.toStringAsFixed(0)}m)'; } return AnimatedBuilder( animation: _gpsBlinkAnimation, builder: (context, child) { return Tooltip( message: tooltip, child: Opacity( opacity: _gpsBlinkAnimation.value, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.2), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 4), Text( '${_gpsAccuracy.toStringAsFixed(0)}m', style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12), ), ], ), ), ), ); }, ); } Widget _buildNetworkIndicator() { IconData icon; Color color; String label; String tooltip; switch (_connectivityResult) { case ConnectivityResult.wifi: icon = Icons.wifi; color = Colors.green; label = 'WiFi'; tooltip = 'Connexion WiFi'; break; case ConnectivityResult.ethernet: icon = Icons.cable; color = Colors.green; label = '4G'; tooltip = 'Connexion Ethernet'; break; case ConnectivityResult.mobile: icon = Icons.signal_cellular_alt; color = Colors.yellow[700]!; label = '3G'; tooltip = 'Connexion mobile'; break; case ConnectivityResult.none: default: icon = Icons.signal_cellular_off; color = Colors.red; label = 'Hors ligne'; tooltip = 'Aucune connexion'; break; } return AnimatedBuilder( animation: _networkBlinkAnimation, builder: (context, child) { return Tooltip( message: tooltip, child: Opacity( opacity: _networkBlinkAnimation.value, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.2), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 4), Text( label, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 12), ), ], ), ), ), ); }, ); } Widget _buildMap() { if (_currentPosition == null) { return Container( color: Colors.grey[200], child: const Center( child: CircularProgressIndicator(), ), ); } final apiService = ApiService.instance; final mapboxApiKey = AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment()); return Stack( children: [ Transform.rotate( angle: _compassMode ? _heading * (math.pi / 180) : 0, child: FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), initialZoom: 17, maxZoom: 19, minZoom: 10, interactionOptions: const InteractionOptions( enableMultiFingerGestureRace: true, flags: InteractiveFlag.all & ~InteractiveFlag.rotate, ), ), children: [ TileLayer( // Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback urlTemplate: kIsWeb ? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey' : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile userAgentPackageName: 'app.geosector.fr', additionalOptions: const { 'attribution': '© OpenStreetMap contributors', }, ), // Cercles de distance en mode boussole if (_compassMode) CircleLayer( circles: [ CircleMarker( point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), radius: 50, color: Colors.blue.withOpacity(0.1), borderColor: Colors.blue.withOpacity(0.3), borderStrokeWidth: 1, ), CircleMarker( point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), radius: 100, color: Colors.transparent, borderColor: Colors.blue.withOpacity(0.2), borderStrokeWidth: 1, ), CircleMarker( point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), radius: 250, color: Colors.transparent, borderColor: Colors.blue.withOpacity(0.15), borderStrokeWidth: 1, ), ], ), // Markers des passages MarkerLayer( markers: _buildPassageMarkers(), ), // Position actuelle MarkerLayer( markers: [ Marker( point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), width: 30, height: 30, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue, border: Border.all(color: Colors.white, width: 3), boxShadow: [ BoxShadow( color: Colors.blue.withOpacity(0.3), blurRadius: 10, spreadRadius: 5, ), ], ), child: const Icon( Icons.person_pin, color: Colors.white, size: 16, ), ), ), ], ), ], ), ), // Bouton recentrage (bas gauche) Positioned( bottom: 16, left: 16, child: FloatingActionButton.small( backgroundColor: Colors.white, foregroundColor: Colors.green[700], onPressed: _recenterMap, child: const Icon(Icons.my_location), ), ), // Bouton boussole (bas droite) Positioned( bottom: 16, right: 16, child: FloatingActionButton.small( backgroundColor: _compassMode ? Colors.green : Colors.white, foregroundColor: _compassMode ? Colors.white : Colors.grey[700], onPressed: _toggleCompassMode, child: Transform.rotate( angle: _compassMode ? _heading * (math.pi / 180) : 0, child: const Icon(Icons.explore), ), ), ), // Indicateur de mode boussole if (_compassMode) Positioned( top: 16, right: 16, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.green, borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.explore, color: Colors.white, size: 16), const SizedBox(width: 4), Text( 'Mode boussole', style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), ), ], ); } List _buildPassageMarkers() { if (_currentPosition == null) return []; return _nearbyPassages.map((passage) { // Déterminer la couleur selon nbPassages Color fillColor; if (passage.nbPassages == 0) { fillColor = const Color(0xFFFFFFFF); // couleur1: Blanc } else if (passage.nbPassages == 1) { fillColor = const Color(0xFFF7A278); // couleur2: Orange } else { fillColor = const Color(0xFFE65100); // couleur3: Orange foncé } // Bordure toujours orange (couleur2) const borderColor = Color(0xFFF7A278); // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat ?? '0') ?? 0; final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0; return Marker( point: LatLng(lat, lng), width: 40, height: 40, child: GestureDetector( onTap: () => _openPassageForm(passage), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: fillColor, border: Border.all(color: borderColor, width: 3), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Center( child: Text( '${passage.numero ?? ''}${(passage.rueBis != null && passage.rueBis!.isNotEmpty) ? passage.rueBis!.substring(0, 1).toLowerCase() : ''}', style: TextStyle( color: fillColor == Colors.white ? Colors.black : Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ), ); }).toList(); } List _getFilteredPassages() { if (_searchQuery.isEmpty) { return _nearbyPassages; } return _nearbyPassages.where((passage) { // Construire l'adresse à partir des champs disponibles final address = '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim().toLowerCase(); return address.contains(_searchQuery); }).toList(); } Widget _buildPassagesList() { final filteredPassages = _getFilteredPassages(); if (filteredPassages.isEmpty) { return Container( color: Colors.white, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( _searchQuery.isNotEmpty ? 'Aucun passage trouvé pour "$_searchQuery"' : 'Aucun passage à proximité', style: TextStyle(color: Colors.grey[600]), ), ], ), ), ); } return Container( color: Colors.white, child: ListView.separated( padding: const EdgeInsets.only(bottom: 20), itemCount: filteredPassages.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final passage = filteredPassages[index]; // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat ?? '0') ?? 0; final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0; final distance = _currentPosition != null ? _calculateDistance( _currentPosition!.latitude, _currentPosition!.longitude, lat, lng, ) : 0.0; // Formater la distance String distanceText; if (distance < 1000) { distanceText = '${distance.toStringAsFixed(0)} m'; } else { distanceText = '${(distance / 1000).toStringAsFixed(1)} km'; } // Couleur selon nbPassages Color indicatorColor; if (passage.nbPassages == 0) { indicatorColor = Colors.grey[400]!; } else if (passage.nbPassages == 1) { indicatorColor = const Color(0xFFF7A278); } else { indicatorColor = const Color(0xFFE65100); } return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, color: indicatorColor, border: Border.all(color: const Color(0xFFF7A278), width: 2), // couleur2: Orange ), ), title: Text( '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim().isEmpty ? 'Adresse inconnue' : '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim(), style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 15, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Row( children: [ Icon(Icons.navigation, size: 14, color: Colors.green[600]), const SizedBox(width: 4), Text( distanceText, style: TextStyle( color: Colors.green[700], fontWeight: FontWeight.w500, fontSize: 13, ), ), if (passage.name != null && passage.name!.isNotEmpty) ...[ const SizedBox(width: 12), Expanded( child: Text( passage.name!, style: TextStyle( color: Colors.grey[600], fontSize: 13, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ], ), trailing: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.green[50], shape: BoxShape.circle, ), child: Icon( Icons.arrow_forward_ios, size: 16, color: Colors.green[700], ), ), onTap: () { HapticFeedback.lightImpact(); _openPassageForm(passage); }, ); }, ), ); } }