From 782486d23b57699cfbfa54680fc858d830663363 Mon Sep 17 00:00:00 2001 From: 240405 <240405@epvc.pt> Date: Wed, 11 Mar 2026 17:14:25 +0000 Subject: [PATCH] =?UTF-8?q?Atualiza=C3=A7=C3=A3o=20do=20mapa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/constants/app_strings.dart | 19 + lib/screens/bluetooth_connection_screen.dart | 87 ++++ lib/screens/google_map_screen.dart | 470 +++++++++++++++---- pubspec.lock | 26 +- pubspec.yaml | 2 + 5 files changed, 493 insertions(+), 111 deletions(-) diff --git a/lib/constants/app_strings.dart b/lib/constants/app_strings.dart index b0bc29b..47ada9e 100644 --- a/lib/constants/app_strings.dart +++ b/lib/constants/app_strings.dart @@ -36,6 +36,10 @@ class AppStrings { static const String connectFail = "Falha ao conectar: "; static const String deviceIdPrefix = "Disp. ["; static const String unknownDevice = "Dispositivo Desconhecido"; + static const String sendSignal = "ENVIAR SINAL"; + static const String signalSent = "Sinal enviado com sucesso!"; + static const String signalError = "Erro ao enviar sinal: "; + static const String noWritableChar = "Nenhuma característica de escrita encontrada."; // Map Screen static const String mapTitleTracking = "TRACKING ATIVO"; @@ -43,8 +47,10 @@ class AppStrings { static const String mapTitleRunning = "CORRIDA"; static const String mapPace = "RITMO"; static const String mapRoute = "TRAJETO"; + static const String mapTime = "TEMPO"; static const String kmhUnit = "KM/H"; static const String kmUnit = "KM"; + static const String metersUnit = "M"; static const String planningInstruction = "Toque para definir Início e Fim"; static const String btnStop = "PARAR"; static const String btnSimulate = "SIMULAR"; @@ -52,4 +58,17 @@ class AppStrings { static const String btnStopRun = "PARAR CORRIDA"; static const String startPoint = "Partida"; static const String finishPoint = "Chegada"; + + // New Run Strings + static const String markDestination = "Marcar destino final"; + static const String chooseRoute = "Escolher percurso existente"; + static const String confirmDestination = "Confirmar destino?"; + static const String startRunning = "Iniciar corrida"; + static const String cancel = "Cancelar"; + static const String yes = "Sim"; + static const String runFinished = "Corrida Finalizada!"; + static const String totalDistance = "Distância total"; + static const String totalTime = "Tempo total"; + static const String saveRoute = "Salvar este percurso"; + static const String close = "Fechar"; } diff --git a/lib/screens/bluetooth_connection_screen.dart b/lib/screens/bluetooth_connection_screen.dart index f7adda7..879b0a7 100644 --- a/lib/screens/bluetooth_connection_screen.dart +++ b/lib/screens/bluetooth_connection_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -203,6 +204,63 @@ class _BluetoothConnectionScreenState extends State { } } + Future _sendSignalToDevice() async { + if (_connectedDevice == null) return; + + try { + // 1. Descobrir serviços do dispositivo + List services = await _connectedDevice!.discoverServices(); + + BluetoothCharacteristic? writableChar; + + // 2. Procurar uma característica que permita escrita + for (var service in services) { + for (var char in service.characteristics) { + if (char.properties.write || char.properties.writeWithoutResponse) { + writableChar = char; + break; + } + } + if (writableChar != null) break; + } + + if (writableChar != null) { + // 3. Enviar um sinal simples (ex: "1" em bytes) + await writableChar.write(utf8.encode("1")); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(AppStrings.signalSent), + backgroundColor: AppColors.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(AppStrings.noWritableChar), + backgroundColor: AppColors.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${AppStrings.signalError}$e'), + backgroundColor: AppColors.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -320,6 +378,35 @@ class _BluetoothConnectionScreenState extends State { ), ), const SizedBox(height: 30), + + // Se houver um dispositivo conectado, mostra o botão de enviar sinal + if (_connectedDevice != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 25), + child: SizedBox( + width: double.infinity, + height: 60, + child: ElevatedButton.icon( + onPressed: _sendSignalToDevice, + icon: const Icon(Icons.send_rounded), + label: const Text( + AppStrings.sendSignal, + style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.coral, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + elevation: 5, + ), + ), + ), + ), + + const SizedBox(height: 20), + Expanded( child: ListView.builder( itemCount: _connectedDevice != null ? 1 : _scanResults.length, diff --git a/lib/screens/google_map_screen.dart b/lib/screens/google_map_screen.dart index 8e1eb17..656dac5 100644 --- a/lib/screens/google_map_screen.dart +++ b/lib/screens/google_map_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:flutter_polyline_points/flutter_polyline_points.dart'; import '../constants/app_colors.dart'; import '../constants/app_strings.dart'; @@ -16,27 +17,31 @@ class GoogleMapScreen extends StatefulWidget { } class _GoogleMapScreenState extends State { + // CONFIGURAÇÃO: Insira aqui sua chave da Google Cloud com Directions API ativa + final String googleApiKey = "AIzaSyCk84rxmF044cxKLABf55rEKHDqOcyoV5k"; + GoogleMapController? _mapController; StreamSubscription? _positionStreamSubscription; - Timer? _simulationTimer; + Timer? _stopwatchTimer; - // Controle de frequência de atualização para evitar sobrecarga e crashes DateTime? _lastUpdate; - final List _routePoints = []; + List _remainingPlannedPoints = []; // Lista para o trajeto que encurta final Set _polylines = {}; final Set _markers = {}; - LatLng? _plannedStart; LatLng? _plannedEnd; bool _isPlanningMode = false; bool _isRunning = false; + bool _isLoading = true; double _currentSpeed = 0.0; double _totalDistance = 0.0; LatLng _currentPosition = const LatLng(38.7223, -9.1393); - bool _isLoading = true; - bool _isSimulating = false; + + int _secondsElapsed = 0; + int _countdownValue = 3; + bool _isCountingDown = false; BitmapDescriptor? _startIcon; BitmapDescriptor? _arrowIcon; @@ -49,10 +54,10 @@ class _GoogleMapScreenState extends State { } Future _setupIconsAndTracking() async { - // Marcadores premium: tamanho ideal para visibilidade e estética - _startIcon = await _createPremiumMarker(Colors.greenAccent, Icons.play_arrow_rounded, 85); - _finishIcon = await _createPremiumMarker(AppColors.coral, Icons.flag_rounded, 85); - _arrowIcon = await _createArrowMarker(Colors.black, Colors.white, 95); + _startIcon = await _createPremiumMarker(Colors.greenAccent, Icons.play_arrow_rounded, 50); + _finishIcon = await _createPremiumMarker(AppColors.coral, Icons.flag_rounded, 65); + // Borda agora é proporcional ao tamanho (25). + _arrowIcon = await _createArrowMarker(Colors.black, Colors.white, 85); await _initTracking(); } @@ -81,9 +86,14 @@ class _GoogleMapScreenState extends State { path.lineTo(size / 2, size * 0.7); path.lineTo(size * 0.1, size); path.close(); + canvas.drawPath(path.shift(const Offset(0, 4)), Paint()..color = Colors.black38..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)); canvas.drawPath(path, Paint()..color = color); - canvas.drawPath(path, Paint()..color = borderColor..style = ui.PaintingStyle.stroke..strokeWidth = 7); + + // CORREÇÃO: Borda escala proporcionalmente (12% do tamanho) + double strokeWidth = size * 0.12; + canvas.drawPath(path, Paint()..color = borderColor..style = ui.PaintingStyle.stroke..strokeWidth = strokeWidth); + final ui.Image image = await recorder.endRecording().toImage(size.toInt(), size.toInt() + 6); final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); @@ -92,7 +102,7 @@ class _GoogleMapScreenState extends State { @override void dispose() { _positionStreamSubscription?.cancel(); - _simulationTimer?.cancel(); + _stopwatchTimer?.cancel(); _mapController?.dispose(); super.dispose(); } @@ -107,58 +117,68 @@ class _GoogleMapScreenState extends State { } Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.bestForNavigation); _currentPosition = LatLng(position.latitude, position.longitude); + + // Adiciona ponto inicial para permitir rotação imediata + _routePoints.add(_currentPosition); + setState(() => _isLoading = false); + _updateMarkers(); // Força a exibição imediata _startLocationStream(); } void _startLocationStream() { _positionStreamSubscription = Geolocator.getPositionStream( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.bestForNavigation, - distanceFilter: 0 - ) + locationSettings: const LocationSettings(accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 0) ).listen((Position position) { - if (!_isSimulating) { - final now = DateTime.now(); - if (_lastUpdate == null || now.difference(_lastUpdate!).inMilliseconds > 500) { - _lastUpdate = now; - _updatePosition(LatLng(position.latitude, position.longitude), position.speed); - } + final now = DateTime.now(); + if (_lastUpdate == null || now.difference(_lastUpdate!).inMilliseconds > 500) { + _lastUpdate = now; + _updatePosition(LatLng(position.latitude, position.longitude), position.speed); } - }, onError: (error) { - debugPrint("${AppStrings.unknownDevice}: $error"); }); } void _updatePosition(LatLng newPoint, double speed) { if (!mounted) return; setState(() { - if (_routePoints.isNotEmpty) { - _totalDistance += Geolocator.distanceBetween(_currentPosition.latitude, _currentPosition.longitude, newPoint.latitude, newPoint.longitude); + if (_isRunning) { + if (_routePoints.isNotEmpty) { + _totalDistance += Geolocator.distanceBetween(_currentPosition.latitude, _currentPosition.longitude, newPoint.latitude, newPoint.longitude); + } + _routePoints.add(newPoint); + _updateTraveledPolylines(); + + // Lógica para consumir o trajeto planejado à medida que passamos por ele + if (_remainingPlannedPoints.isNotEmpty) { + while (_remainingPlannedPoints.length > 1) { + double distanceToPoint = Geolocator.distanceBetween( + newPoint.latitude, newPoint.longitude, + _remainingPlannedPoints[0].latitude, _remainingPlannedPoints[0].longitude + ); + // Se estivermos a menos de 15m do ponto do trajeto, removemo-lo + if (distanceToPoint < 15) { + _remainingPlannedPoints.removeAt(0); + } else { + break; + } + } + _updateRemainingPolyline(); + } + } else { + // Antes da corrida, mantém os últimos pontos para calcular a rotação + if (_routePoints.length >= 2) _routePoints.removeAt(0); + _routePoints.add(newPoint); } + _currentSpeed = speed >= 0 ? speed : 0; - _routePoints.add(newPoint); _currentPosition = newPoint; _updateMarkers(); - - if (_routePoints.length > 1) { - _polylines.removeWhere((p) => p.polylineId.value == 'route' || p.polylineId.value == 'route_glow'); - _polylines.add(Polyline( - polylineId: const PolylineId('route_glow'), - points: List.from(_routePoints), - color: Colors.cyanAccent.withOpacity(0.3), - width: 14, - zIndex: 9, - )); - _polylines.add(Polyline( - polylineId: const PolylineId('route'), - points: List.from(_routePoints), - color: Colors.white, - width: 6, - patterns: [PatternItem.dot, PatternItem.gap(15)], - jointType: JointType.round, - zIndex: 10, - )); + + if (_plannedEnd != null && _isRunning) { + double distToEnd = Geolocator.distanceBetween(newPoint.latitude, newPoint.longitude, _plannedEnd!.latitude, _plannedEnd!.longitude); + if (distToEnd < 15) { + _finishRun(); + } } }); @@ -167,16 +187,40 @@ class _GoogleMapScreenState extends State { } } + void _updateTraveledPolylines() { + if (_routePoints.length > 1) { + _polylines.removeWhere((p) => p.polylineId.value == 'route' || p.polylineId.value == 'route_glow'); + _polylines.add(Polyline(polylineId: const PolylineId('route_glow'), points: List.from(_routePoints), color: Colors.cyanAccent.withOpacity(0.3), width: 14, zIndex: 9)); + _polylines.add(Polyline(polylineId: const PolylineId('route'), points: List.from(_routePoints), color: Colors.white, width: 6, patterns: [PatternItem.dot, PatternItem.gap(15)], jointType: JointType.round, zIndex: 10)); + } + } + + void _updateRemainingPolyline() { + _polylines.removeWhere((p) => p.polylineId.value == 'planned_preview'); + if (_remainingPlannedPoints.isNotEmpty) { + // A linha coral começa na posição atual do usuário e segue o que resta + List pointsToDraw = [_currentPosition, ..._remainingPlannedPoints]; + _polylines.add(Polyline( + polylineId: const PolylineId('planned_preview'), + points: pointsToDraw, + color: AppColors.coral.withOpacity(0.5), + width: 4, + patterns: [PatternItem.dash(20), PatternItem.gap(10)], + zIndex: 8, + )); + } + } + void _updateMarkers() { _markers.removeWhere((m) => m.markerId.value == 'follower'); _markers.add(Marker( - markerId: const MarkerId('follower'), - position: _currentPosition, - rotation: _calculateRotation(_routePoints), - flat: true, - anchor: const Offset(0.5, 0.5), - icon: _arrowIcon ?? BitmapDescriptor.defaultMarker, - zIndex: 12, + markerId: const MarkerId('follower'), + position: _currentPosition, + rotation: _calculateRotation(_routePoints), + flat: true, + anchor: const Offset(0.5, 0.5), + icon: _arrowIcon ?? BitmapDescriptor.defaultMarker, + zIndex: 12 )); } @@ -187,76 +231,298 @@ class _GoogleMapScreenState extends State { return Geolocator.bearingBetween(p1.latitude, p1.longitude, p2.latitude, p2.longitude); } - void _onMapTap(LatLng point) { + Future> _getRoutePolyline(LatLng start, LatLng end) async { + try { + PolylinePoints polylinePoints = PolylinePoints(); + PolylineResult result = await polylinePoints.getRouteBetweenCoordinates( + request: PolylineRequest( + origin: PointLatLng(start.latitude, start.longitude), + destination: PointLatLng(end.latitude, end.longitude), + mode: TravelMode.walking, + ), + googleApiKey: googleApiKey, + ); + + List coords = []; + if (result.points.isNotEmpty) { + for (var point in result.points) { + coords.add(LatLng(point.latitude, point.longitude)); + } + } + return coords; + } catch (e) { + debugPrint("Erro ao buscar rota: $e"); + return []; + } + } + + void _onMapTap(LatLng point) async { if (!_isPlanningMode) return; + setState(() { - if (_plannedStart == null) { - _plannedStart = point; - _markers.add(Marker(markerId: const MarkerId('planned_start'), position: point, icon: _startIcon ?? BitmapDescriptor.defaultMarker, zIndex: 5, infoWindow: const InfoWindow(title: AppStrings.startPoint))); - } else if (_plannedEnd == null) { - _plannedEnd = point; - _markers.add(Marker(markerId: const MarkerId('planned_end'), position: point, icon: _finishIcon ?? BitmapDescriptor.defaultMarker, zIndex: 5, infoWindow: const InfoWindow(title: AppStrings.finishPoint))); - _polylines.add(Polyline(polylineId: const PolylineId('planned_route'), points: [_plannedStart!, _plannedEnd!], color: Colors.white.withOpacity(0.1), width: 2, zIndex: 1)); + _plannedEnd = point; + _markers.removeWhere((m) => m.markerId.value == 'planned_end'); + _markers.add(Marker(markerId: const MarkerId('planned_end'), position: point, icon: _finishIcon ?? BitmapDescriptor.defaultMarker, zIndex: 5, infoWindow: const InfoWindow(title: AppStrings.finishPoint))); + }); + + List streetPoints = await _getRoutePolyline(_currentPosition, point); + if (streetPoints.isEmpty) { + streetPoints = [_currentPosition, point]; + } + + setState(() { + _remainingPlannedPoints = List.from(streetPoints); + _updateRemainingPolyline(); + }); + + _showConfirmationDialog(); + } + + void _showStartOptions() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration(color: AppColors.backgroundGrey, borderRadius: BorderRadius.circular(30), border: Border.all(color: Colors.white10)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 15), + Container(width: 40, height: 4, decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 25), + _buildOptionTile(Icons.location_on_rounded, AppStrings.markDestination, () { + Navigator.pop(context); + setState(() => _isPlanningMode = true); + }), + const Divider(color: Colors.white10, indent: 20, endIndent: 20), + _buildOptionTile(Icons.history_rounded, AppStrings.chooseRoute, () { + Navigator.pop(context); + }), + const SizedBox(height: 20), + ], + ), + ), + ); + } + + void _showConfirmationDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppColors.backgroundGrey, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + title: const Text(AppStrings.confirmDestination, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text(AppStrings.cancel, style: TextStyle(color: Colors.white54))), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startCountdown(); + }, + style: ElevatedButton.styleFrom(backgroundColor: AppColors.coral, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), + child: const Text(AppStrings.yes, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + void _startCountdown() { + setState(() { + _isCountingDown = true; + _countdownValue = 3; + _isPlanningMode = false; + }); + + Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdownValue == 1) { + timer.cancel(); + _startRun(); } else { - _plannedStart = point; - _plannedEnd = null; - _markers.removeWhere((m) => m.markerId.value.startsWith('planned')); - _polylines.removeWhere((p) => p.polylineId.value == 'planned_route'); - _markers.add(Marker(markerId: const MarkerId('planned_start'), position: point, icon: _startIcon ?? BitmapDescriptor.defaultMarker, zIndex: 5, infoWindow: const InfoWindow(title: AppStrings.startPoint))); + setState(() => _countdownValue--); } }); } - void _toggleSimulation() { - if (_isSimulating) { - _simulationTimer?.cancel(); - setState(() => _isSimulating = false); - } else { - setState(() { - _isSimulating = true; - _isPlanningMode = false; - _routePoints.clear(); - _polylines.removeWhere((p) => p.polylineId.value == 'route' || p.polylineId.value == 'route_glow'); - _totalDistance = 0.0; - _currentPosition = _plannedStart ?? _currentPosition; - _routePoints.add(_currentPosition); - _updateMarkers(); - }); + void _startRun() { + setState(() { + _isCountingDown = false; + _isRunning = true; + _totalDistance = 0.0; + _secondsElapsed = 0; + _routePoints.clear(); + _routePoints.add(_currentPosition); + }); - _simulationTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { - double latStep = 0.000025; - double lngStep = 0.000025; + _stopwatchTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() => _secondsElapsed++); + }); + } - if (_plannedEnd != null) { - double dist = Geolocator.distanceBetween(_currentPosition.latitude, _currentPosition.longitude, _plannedEnd!.latitude, _plannedEnd!.longitude); - if (dist < 2) { - _updatePosition(_plannedEnd!, 0.0); - _toggleSimulation(); - return; - } - latStep = (_plannedEnd!.latitude - _currentPosition.latitude) / (max(dist / 0.8, 1)); - lngStep = (_plannedEnd!.longitude - _currentPosition.longitude) / (max(dist / 0.8, 1)); - } + void _finishRun() { + _stopwatchTimer?.cancel(); + setState(() { + _isRunning = false; + _remainingPlannedPoints.clear(); + _polylines.removeWhere((p) => p.polylineId.value == 'planned_preview'); + }); - LatLng nextPoint = LatLng(_currentPosition.latitude + latStep, _currentPosition.longitude + lngStep); - _updatePosition(nextPoint, 3.5 + Random().nextDouble() * 0.5); - }); - } + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + backgroundColor: AppColors.backgroundGrey, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle_rounded, color: Colors.greenAccent, size: 70), + const SizedBox(height: 20), + const Text(AppStrings.runFinished, style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w900)), + const SizedBox(height: 25), + _buildResultRow(AppStrings.totalDistance, _formatDistance(_totalDistance)), + const SizedBox(height: 15), + _buildResultRow(AppStrings.totalTime, _formatTime(_secondsElapsed)), + const SizedBox(height: 30), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom(backgroundColor: AppColors.coral, padding: const EdgeInsets.symmetric(vertical: 15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), + child: const Text(AppStrings.close, style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), + ); + } + + String _formatDistance(double meters) { + if (meters < 1000) return "${meters.toStringAsFixed(0)} ${AppStrings.metersUnit}"; + return "${(meters / 1000).toStringAsFixed(2)} ${AppStrings.kmUnit}"; + } + + String _formatTime(int seconds) { + int hours = seconds ~/ 3600; + int minutes = (seconds % 3600) ~/ 60; + int remainingSeconds = seconds % 60; + return "${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}"; } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, - appBar: AppBar(title: Text(_isPlanningMode ? AppStrings.mapTitlePlanning : AppStrings.mapTitleRunning, style: const TextStyle(fontWeight: FontWeight.w900, color: Colors.white, letterSpacing: 2)), centerTitle: true, backgroundColor: Colors.transparent, elevation: 0, leading: IconButton(icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), onPressed: () => Navigator.pop(context)), actions: [IconButton(icon: Icon(_isPlanningMode ? Icons.check_circle_rounded : Icons.add_location_alt_rounded, color: AppColors.coral, size: 30), onPressed: () => setState(() => _isPlanningMode = !_isPlanningMode))]), + appBar: AppBar(title: Text(_isRunning ? AppStrings.mapTitleRunning : AppStrings.mapTitlePlanning, style: const TextStyle(fontWeight: FontWeight.w900, color: Colors.white, letterSpacing: 2)), centerTitle: true, backgroundColor: Colors.transparent, elevation: 0, leading: IconButton(icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), onPressed: () => Navigator.pop(context))), extendBodyBehindAppBar: true, - body: Stack(children: [Center(child: Container(width: MediaQuery.of(context).size.width * 0.94, height: MediaQuery.of(context).size.height * 0.7, decoration: BoxDecoration(color: AppColors.backgroundGrey, borderRadius: BorderRadius.circular(55), border: Border.all(color: Colors.white.withOpacity(0.1), width: 3), boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.7), blurRadius: 50, offset: const Offset(0, 30))]), child: ClipRRect(borderRadius: BorderRadius.circular(52), child: _isLoading ? const Center(child: CircularProgressIndicator(color: AppColors.coral)) : GoogleMap(initialCameraPosition: CameraPosition(target: _currentPosition, zoom: 17.5), onMapCreated: (controller) => _mapController = controller, onTap: _onMapTap, markers: _markers, polylines: _polylines, zoomControlsEnabled: false, myLocationButtonEnabled: false, compassEnabled: false, mapToolbarEnabled: false)))), Positioned(top: 115, left: 45, right: 45, child: Container(padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 25), decoration: BoxDecoration(color: AppColors.background.withOpacity(0.95), borderRadius: BorderRadius.circular(25), border: Border.all(color: Colors.white10), boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10)]), child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [_buildStat(AppStrings.mapPace, "${(_currentSpeed * 3.6).toStringAsFixed(1)}", AppStrings.kmhUnit), Container(width: 1, height: 35, color: Colors.white10), _buildStat(AppStrings.mapRoute, (_totalDistance / 1000).toStringAsFixed(2), AppStrings.kmUnit)]))), if (_isPlanningMode) Positioned(bottom: 140, left: 60, right: 60, child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.black.withOpacity(0.85), borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.coral.withOpacity(0.5))), child: const Text(AppStrings.planningInstruction, textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold))))]), - floatingActionButton: FloatingActionButton.extended(onPressed: _toggleSimulation, label: Text(_isSimulating ? AppStrings.btnStop : AppStrings.btnStartRun, style: const TextStyle(fontWeight: FontWeight.w900, letterSpacing: 1.5)), icon: Icon(_isSimulating ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 32), backgroundColor: _isSimulating ? AppColors.coral : Colors.white, foregroundColor: _isSimulating ? Colors.white : AppColors.background, elevation: 15), + body: Stack( + children: [ + Center( + child: Container( + width: MediaQuery.of(context).size.width * 0.94, height: MediaQuery.of(context).size.height * 0.7, + decoration: BoxDecoration(color: AppColors.backgroundGrey, borderRadius: BorderRadius.circular(55), border: Border.all(color: Colors.white10, width: 3), boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 40)]), + child: ClipRRect(borderRadius: BorderRadius.circular(52), child: _isLoading ? const Center(child: CircularProgressIndicator(color: AppColors.coral)) : GoogleMap(initialCameraPosition: CameraPosition(target: _currentPosition, zoom: 17.5), onMapCreated: (controller) => _mapController = controller, onTap: _onMapTap, markers: _markers, polylines: _polylines, zoomControlsEnabled: false, myLocationButtonEnabled: false, compassEnabled: false, mapToolbarEnabled: false)) + ), + ), + + Positioned( + top: 115, left: 20, right: 20, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration(color: AppColors.background.withOpacity(0.95), borderRadius: BorderRadius.circular(25), border: Border.all(color: Colors.white10)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStat(AppStrings.mapPace, (_currentSpeed * 3.6).toStringAsFixed(1), AppStrings.kmhUnit), + _buildDivider(), + _buildStat(AppStrings.mapRoute, _formatDistanceValue(_totalDistance), _totalDistance < 1000 ? AppStrings.metersUnit : AppStrings.kmUnit), + _buildDivider(), + _buildStat(AppStrings.mapTime, _formatTimeShort(_secondsElapsed), ""), + ], + ), + ), + ), + + if (_isCountingDown) + Container( + color: Colors.black54, + child: Center( + child: Text("$_countdownValue", style: const TextStyle(color: Colors.white, fontSize: 150, fontWeight: FontWeight.w900)), + ), + ), + + if (_isPlanningMode) + Positioned( + bottom: 120, left: 60, right: 60, + child: Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration(color: AppColors.coral.withOpacity(0.9), borderRadius: BorderRadius.circular(20)), + child: const Text(AppStrings.markDestination, textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _isRunning ? _finishRun : _showStartOptions, + label: Text(_isRunning ? AppStrings.btnStop : AppStrings.btnStartRun, style: const TextStyle(fontWeight: FontWeight.w900, letterSpacing: 1.5)), + icon: Icon(_isRunning ? Icons.stop_rounded : Icons.play_arrow_rounded, size: 32), + backgroundColor: _isRunning ? AppColors.coral : Colors.white, + foregroundColor: _isRunning ? Colors.white : AppColors.background, + elevation: 15, + ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, ); } + Widget _buildOptionTile(IconData icon, String title, VoidCallback onTap) { + return ListTile( + leading: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: Colors.white.withOpacity(0.05), shape: BoxShape.circle), child: Icon(icon, color: AppColors.coral)), + title: Text(title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + trailing: const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white24, size: 16), + onTap: onTap, + ); + } + + Widget _buildResultRow(String label, String value) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontWeight: FontWeight.bold)), + Text(value, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w900)), + ], + ); + } + + String _formatDistanceValue(double meters) { + if (meters < 1000) return meters.toStringAsFixed(0); + return (meters / 1000).toStringAsFixed(2); + } + + String _formatTimeShort(int seconds) { + int mins = seconds ~/ 60; + int secs = seconds % 60; + return "${mins.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}"; + } + + Widget _buildDivider() => Container(width: 1, height: 30, color: Colors.white10); + Widget _buildStat(String label, String value, String unit) { - return Column(mainAxisSize: MainAxisSize.min, children: [Text(label, style: const TextStyle(color: Colors.white54, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 1.5)), const SizedBox(height: 6), Row(crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [Text(value, style: const TextStyle(color: Colors.white, fontSize: 26, fontWeight: FontWeight.w900)), const SizedBox(width: 3), Text(unit, style: const TextStyle(color: Colors.white54, fontSize: 10, fontWeight: FontWeight.bold))])]); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 9, fontWeight: FontWeight.w900, letterSpacing: 1)), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(value, style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w900)), + if (unit.isNotEmpty) ...[const SizedBox(width: 2), Text(unit, style: const TextStyle(color: Colors.white54, fontSize: 9, fontWeight: FontWeight.bold))], + ], + ), + ], + ); } } diff --git a/pubspec.lock b/pubspec.lock index d24646a..2f3eb26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -190,6 +190,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_polyline_points: + dependency: "direct main" + description: + name: flutter_polyline_points + sha256: "3a1c8c30abee9fb0fbe44c70d5d1cedb10ef28ec7ea285c669f02b3e183483aa" + url: "https://pub.dev" + source: hosted + version: "2.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -305,7 +313,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -388,18 +396,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -577,10 +585,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 48359f2..b2e4832 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: geolocator: ^10.1.0 flutter_blue_plus: ^1.31.0 permission_handler: ^11.3.1 + flutter_polyline_points: ^2.1.0 + http: ^1.1.0 dev_dependencies: flutter_test: