Compare commits

..

6 Commits

Author SHA1 Message Date
782486d23b Atualização do mapa 2026-03-11 17:14:25 +00:00
256d34eb79 Simplificação do main.dart 2026-03-11 15:36:21 +00:00
f67ab0f40c Merge remote-tracking branch 'origin/main' 2026-03-06 11:46:43 +00:00
6b1d4b8cea Bluetooth e mapa a funcionar 2026-03-06 11:46:17 +00:00
8eeed78084 Eliminar lib/bluetooth_screen.dart 2026-03-06 11:17:31 +00:00
9e024f7db0 Eliminar lib/bluetooth_connection_screen.dart 2026-03-06 11:17:25 +00:00
11 changed files with 1143 additions and 1131 deletions

View File

@@ -1,12 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Bluetooth permissions for Android 12 (API 31) and higher -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Legacy Bluetooth permissions for Android 11 (API 30) and lower -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Location permissions (required for Bluetooth scanning on older Android versions) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="teste_projeto_turma" android:label="runvisionpro_app"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -18,10 +29,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
@@ -31,21 +38,13 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- TODO: Replace with your actual Google Maps API Key -->
<meta-data android:name="com.google.android.geo.API_KEY" <meta-data android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyCk84rxmF044cxKLABf55rEKHDqOcyoV5k"/> android:value="AIzaSyCk84rxmF044cxKLABf55rEKHDqOcyoV5k"/>
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>

View File

@@ -1,169 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:async';
class BluetoothConnectionScreen extends StatefulWidget {
const BluetoothConnectionScreen({super.key});
@override
State<BluetoothConnectionScreen> createState() => _BluetoothConnectionScreenState();
}
class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
List<ScanResult> scanResults = [];
bool isScanning = false;
StreamSubscription? scanSubscription;
StreamSubscription? isScanningSubscription;
@override
void initState() {
super.initState();
// Monitorar se o sistema está escaneando
isScanningSubscription = FlutterBluePlus.isScanning.listen((scanning) {
if (mounted) {
setState(() {
isScanning = scanning;
});
}
});
// Ouvir os resultados do scan globalmente
scanSubscription = FlutterBluePlus.scanResults.listen((results) {
if (mounted) {
setState(() {
scanResults = results;
});
}
});
}
@override
void dispose() {
scanSubscription?.cancel();
isScanningSubscription?.cancel();
super.dispose();
}
Future<void> startScan() async {
// 1. Pedir permissões (necessário no Android)
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
].request();
if (statuses[Permission.bluetoothScan]!.isGranted &&
statuses[Permission.bluetoothConnect]!.isGranted &&
statuses[Permission.location]!.isGranted) {
setState(() {
scanResults.clear();
});
// 2. Iniciar o scan
try {
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 15));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro ao buscar: $e')),
);
}
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permissões negadas')),
);
}
}
}
Future<void> connectToDevice(BluetoothDevice device) async {
try {
await device.connect();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Conectado a ${device.platformName.isEmpty ? 'Dispositivo' : device.platformName}')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Falha ao conectar: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Conexão Bluetooth'),
backgroundColor: Colors.grey[850],
foregroundColor: Colors.white,
),
backgroundColor: Colors.grey[900],
body: Column(
children: [
const SizedBox(height: 20),
Center(
child: Column(
children: [
const Icon(
Icons.bluetooth,
size: 80,
color: Colors.white,
),
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: isScanning ? null : startScan,
icon: isScanning
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
: const Icon(Icons.search),
label: Text(isScanning ? 'Buscando...' : 'Procurar Dispositivos'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
textStyle: const TextStyle(fontSize: 18),
),
),
],
),
),
const SizedBox(height: 20),
Expanded(
child: scanResults.isEmpty && !isScanning
? const Center(
child: Text(
"Nenhum dispositivo encontrado.\nClique em procurar.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
)
: ListView.builder(
itemCount: scanResults.length,
itemBuilder: (context, index) {
final device = scanResults[index].device;
final name = device.platformName.isEmpty ? 'Desconhecido' : device.platformName;
return Card(
color: Colors.white10,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(name, style: const TextStyle(color: Colors.white)),
subtitle: Text(device.remoteId.toString(), style: const TextStyle(color: Colors.white70)),
trailing: const Icon(Icons.link, color: Colors.blue),
onTap: () => connectToDevice(device),
),
);
},
),
),
],
),
);
}
}

View File

@@ -1,335 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:async';
import 'constants/app_colors.dart';
import 'constants/app_strings.dart';
class BluetoothScreen extends StatefulWidget {
const BluetoothScreen({super.key});
@override
State<BluetoothScreen> createState() => _BluetoothScreenState();
}
class _BluetoothScreenState extends State<BluetoothScreen> {
List<ScanResult> _scanResults = [];
bool _isScanning = false;
StreamSubscription? _scanResultsSubscription;
StreamSubscription? _isScanningSubscription;
@override
void initState() {
super.initState();
_scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) {
if (mounted) {
List<ScanResult> sortedResults = List.from(results);
sortedResults.sort((a, b) {
bool aHasName = _hasRealName(a);
bool bHasName = _hasRealName(b);
if (aHasName && !bHasName) return -1;
if (!aHasName && bHasName) return 1;
return 0;
});
setState(() {
_scanResults = sortedResults;
});
}
});
_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
if (mounted) {
setState(() {
_isScanning = state;
});
}
});
}
bool _hasRealName(ScanResult r) {
return r.advertisementData.advName.isNotEmpty || r.device.platformName.isNotEmpty;
}
@override
void dispose() {
_scanResultsSubscription?.cancel();
_isScanningSubscription?.cancel();
super.dispose();
}
Future<void> startScan() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.location,
].request();
if (statuses[Permission.bluetoothScan]!.isGranted &&
statuses[Permission.bluetoothConnect]!.isGranted) {
try {
_scanResults.clear();
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 15),
androidUsesFineLocation: true,
continuousUpdates: true,
);
Future.delayed(const Duration(seconds: 8), () {
if (mounted && _scanResults.isEmpty && _isScanning) {
_showSnackBar(AppStrings.noDevicesFound, AppColors.coral);
}
});
} catch (e) {
_showSnackBar("${AppStrings.scanError}$e", AppColors.error);
}
} else {
_showSnackBar(AppStrings.permissionsDenied, AppColors.coral);
}
}
Future<void> stopScan() async {
try {
await FlutterBluePlus.stopScan();
} catch (e) {
_showSnackBar("${AppStrings.stopScanError}$e", AppColors.error);
}
}
Future<void> connectToDevice(BluetoothDevice device) async {
try {
String name = device.platformName.isNotEmpty ? device.platformName : AppStrings.defaultDeviceName;
_showSnackBar("${AppStrings.connectingTo}$name...", AppColors.white.withValues(alpha: 0.7));
await device.connect();
_showSnackBar(AppStrings.connectedSuccess, AppColors.success);
} catch (e) {
_showSnackBar("${AppStrings.connectFail}$e", AppColors.error);
}
}
String _getDeviceName(ScanResult r) {
if (r.advertisementData.advName.isNotEmpty) {
return r.advertisementData.advName;
} else if (r.device.platformName.isNotEmpty) {
return r.device.platformName;
} else {
String id = r.device.remoteId.toString();
return "${AppStrings.deviceIdPrefix}${id.length > 5 ? id.substring(id.length - 5) : id}]";
}
}
void _showSnackBar(String message, Color color) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: color,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.bluetoothTitle, style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true,
backgroundColor: AppColors.transparent,
elevation: 0,
foregroundColor: AppColors.white,
),
extendBodyBehindAppBar: true,
backgroundColor: AppColors.background,
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.coral.withValues(alpha: 0.1),
AppColors.background,
],
),
),
child: Column(
children: [
const SizedBox(height: 100),
Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(25),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isScanning
? AppColors.coral.withValues(alpha: 0.1)
: AppColors.backgroundGrey.withValues(alpha: 0.3),
border: Border.all(
color: _isScanning ? AppColors.coral : AppColors.backgroundGrey,
width: 2,
),
),
child: Icon(
_isScanning ? Icons.bluetooth_searching : Icons.bluetooth,
size: 60,
color: _isScanning ? AppColors.coral : AppColors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: 30),
SizedBox(
width: 220,
height: 50,
child: ElevatedButton(
onPressed: _isScanning ? stopScan : startScan,
style: ElevatedButton.styleFrom(
backgroundColor: _isScanning ? AppColors.backgroundGrey : AppColors.white,
foregroundColor: _isScanning ? AppColors.white : AppColors.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isScanning)
const Padding(
padding: EdgeInsets.only(right: 10),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
),
),
Text(
_isScanning ? AppStrings.stopSearch : AppStrings.startSearch,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 40),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Text(
_isScanning ? AppStrings.searching : AppStrings.nearbyDevices,
style: const TextStyle(
color: AppColors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
if (_isScanning)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.coral.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Text(
AppStrings.active,
style: TextStyle(color: AppColors.coral, fontSize: 10, fontWeight: FontWeight.bold),
),
),
],
),
),
const SizedBox(height: 15),
Expanded(
child: _scanResults.isEmpty && !_isScanning
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bluetooth_disabled, size: 40, color: AppColors.white.withValues(alpha: 0.1)),
const SizedBox(height: 16),
Text(
AppStrings.startSearchInstruction,
style: TextStyle(color: AppColors.white.withValues(alpha: 0.3)),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.only(top: 0, bottom: 20),
itemCount: _scanResults.length,
itemBuilder: (context, index) {
final r = _scanResults[index];
final name = _getDeviceName(r);
final isUnknown = name.startsWith(AppStrings.deviceIdPrefix);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
decoration: BoxDecoration(
color: AppColors.backgroundGrey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(15),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
backgroundColor: AppColors.backgroundGrey.withValues(alpha: 0.3),
child: Icon(
isUnknown ? Icons.devices_other : Icons.bluetooth,
color: isUnknown ? AppColors.white.withValues(alpha: 0.4) : AppColors.white,
size: 20
),
),
title: Text(
name,
style: TextStyle(
color: isUnknown ? AppColors.white.withValues(alpha: 0.5) : AppColors.white,
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
subtitle: Text(
r.device.remoteId.toString(),
style: TextStyle(color: AppColors.white.withValues(alpha: 0.3), fontSize: 11),
),
trailing: ElevatedButton(
onPressed: () => connectToDevice(r.device),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.coral,
foregroundColor: AppColors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
child: const Text(AppStrings.connect, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -36,6 +36,10 @@ class AppStrings {
static const String connectFail = "Falha ao conectar: "; static const String connectFail = "Falha ao conectar: ";
static const String deviceIdPrefix = "Disp. ["; static const String deviceIdPrefix = "Disp. [";
static const String unknownDevice = "Dispositivo Desconhecido"; 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 // Map Screen
static const String mapTitleTracking = "TRACKING ATIVO"; static const String mapTitleTracking = "TRACKING ATIVO";
@@ -43,8 +47,10 @@ class AppStrings {
static const String mapTitleRunning = "CORRIDA"; static const String mapTitleRunning = "CORRIDA";
static const String mapPace = "RITMO"; static const String mapPace = "RITMO";
static const String mapRoute = "TRAJETO"; static const String mapRoute = "TRAJETO";
static const String mapTime = "TEMPO";
static const String kmhUnit = "KM/H"; static const String kmhUnit = "KM/H";
static const String kmUnit = "KM"; static const String kmUnit = "KM";
static const String metersUnit = "M";
static const String planningInstruction = "Toque para definir Início e Fim"; static const String planningInstruction = "Toque para definir Início e Fim";
static const String btnStop = "PARAR"; static const String btnStop = "PARAR";
static const String btnSimulate = "SIMULAR"; static const String btnSimulate = "SIMULAR";
@@ -52,4 +58,17 @@ class AppStrings {
static const String btnStopRun = "PARAR CORRIDA"; static const String btnStopRun = "PARAR CORRIDA";
static const String startPoint = "Partida"; static const String startPoint = "Partida";
static const String finishPoint = "Chegada"; 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";
} }

View File

@@ -1,517 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'constants/app_colors.dart'; import 'screens/welcome_screen.dart';
import 'constants/app_strings.dart';
import 'screens/google_map_screen.dart';
import 'screens/bluetooth_connection_screen.dart';
void main() { void main() {
// Ponto de entrada do aplicativo.
runApp(const MyApp()); runApp(const MyApp());
} }
/// Widget raiz do aplicativo que configura o MaterialApp.
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const MaterialApp( return const MaterialApp(
// Remove a bandeira de "debug" no canto superior direito.
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Define a tela inicial como RunningScreen. home: WelcomeScreen(),
home: RunningScreen(),
); );
} }
} }
/// Tela principal que exibe o progresso, estatísticas e menu.
class RunningScreen extends StatefulWidget {
const RunningScreen({super.key});
@override
State<RunningScreen> createState() => _RunningScreenState();
}
class _RunningScreenState extends State<RunningScreen>
with SingleTickerProviderStateMixin {
// Variáveis de estado para controlar os dados da corrida.
double progress = 0.35; // Progresso inicial simulado para estética.
double targetDistance = 8.0; // Distância alvo em KM.
double currentDistance = 2.8; // Distância atual percorrida simulada.
/// Constrói o indicador de progresso circular central com melhorias estéticas.
Widget _buildCircularProgressIndicator() {
return SizedBox(
width: 210,
height: 210,
child: Stack(
alignment: Alignment.center,
children: [
// Efeito de brilho (glow) sutil atrás do progresso.
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.white.withValues(alpha: 0.05),
blurRadius: 30,
spreadRadius: 10,
),
],
),
),
// TweenAnimationBuilder cria uma animação suave quando o valor do progresso muda.
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: progress),
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
builder: (context, value, _) {
return CustomPaint(
size: const Size(210, 210),
painter: CircularProgressPainter(
progress: value,
strokeWidth: 14,
progressColor: AppColors.white,
backgroundColor: AppColors.white.withValues(alpha: 0.15),
),
);
},
),
// Círculo interno que contém o texto da porcentagem.
Container(
width: 175,
height: 175,
decoration: const BoxDecoration(
color: AppColors.backgroundGrey,
shape: BoxShape.circle,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${(progress * 100).toInt()}%",
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w900,
color: AppColors.white,
letterSpacing: -1,
),
),
Text(
AppStrings.complete,
style: TextStyle(
color: AppColors.white.withValues(alpha: 0.5),
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background, // Cor de fundo escura definida nas constantes.
body: Stack(
children: [
// 1. Indicador de progresso circular posicionado no topo central.
Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 70),
child: _buildCircularProgressIndicator(),
),
),
// 2. Exibição da distância estilizada como um badge.
Positioned(
top: 300,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
decoration: BoxDecoration(
color: AppColors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: AppColors.white.withValues(alpha: 0.1)),
),
child: Text(
"${currentDistance.toStringAsFixed(1)} ${AppStrings.kmUnit} | ${targetDistance.toStringAsFixed(1)} ${AppStrings.kmUnit}",
style: const TextStyle(
color: AppColors.white,
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
),
),
),
// 3. Contêiner de estatísticas e o mapa interativo.
Positioned(
top: 360,
left: 20,
right: 20,
child: Container(
height: 210,
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: AppColors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
// Lado Esquerdo: Estatísticas.
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 15),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(Icons.directions_run_rounded, "3219", AppStrings.steps),
Divider(color: AppColors.white.withValues(alpha: 0.1), height: 1),
_buildStatItem(Icons.favorite_rounded, "98", AppStrings.bpm),
Divider(color: AppColors.white.withValues(alpha: 0.1), height: 1),
_buildStatItem(Icons.local_fire_department_rounded, "480", AppStrings.kcal),
],
),
),
),
// Lado Direito: Miniatura do Mapa Clicável.
Expanded(
flex: 6,
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const GoogleMapScreen()),
);
},
child: Container(
margin: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
children: [
Container(color: const Color(0xFF2C2C2E)),
CustomPaint(
size: Size.infinite,
painter: MapPainter(),
),
// Overlay estético indicando interatividade.
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.transparent,
AppColors.black.withValues(alpha: 0.4),
],
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.background.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: const Icon(Icons.fullscreen_rounded, color: AppColors.white, size: 20),
),
),
const Positioned(
top: 12,
left: 12,
child: Text(
AppStrings.mapPreview,
style: TextStyle(
color: AppColors.white,
fontSize: 10,
fontWeight: FontWeight.w900,
letterSpacing: 1,
),
),
),
],
),
),
),
),
),
],
),
),
),
// 4. Barra de progresso linear centralizada.
Positioned(
bottom: 170,
left: 50,
right: 50,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.white.withValues(alpha: 0.1),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.white),
),
),
),
// 5. Menu de navegação inferior.
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildMenuButton(Icons.settings_outlined, AppStrings.settings),
_buildMenuButton(Icons.group_outlined, AppStrings.groups),
_buildMenuButton(Icons.history_rounded, AppStrings.history),
_buildMenuButton(Icons.notifications_none_rounded, AppStrings.notifications, showBadge: true),
_buildMenuButton(Icons.person_outline_rounded, AppStrings.profile, isAvatar: true),
],
),
),
// 6. Ação rápida de Bluetooth.
Positioned(
top: 60,
right: 25,
child: _buildSmallActionButton(Icons.bluetooth, AppColors.error),
),
],
),
);
}
/// Item de estatística with design refinado.
Widget _buildStatItem(IconData icon, String value, String label) {
return Row(
children: [
Icon(icon, color: AppColors.coral.withValues(alpha: 0.8), size: 22),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(
color: AppColors.white,
fontSize: 19,
fontWeight: FontWeight.w900,
),
),
Text(
label,
style: TextStyle(
color: AppColors.white.withValues(alpha: 0.4),
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
],
);
}
/// Botões do menu com melhorias visuais.
Widget _buildMenuButton(IconData icon, String message, {bool showBadge = false, bool isAvatar = false}) {
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 1),
),
);
},
child: Stack(
clipBehavior: Clip.none,
children: [
isAvatar
? Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.coral, width: 2),
),
child: const CircleAvatar(
radius: 18,
backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=1'),
),
)
: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.white.withValues(alpha: 0.05)),
),
child: Icon(icon, color: AppColors.white.withValues(alpha: 0.9), size: 24),
),
if (showBadge)
Positioned(
right: -2,
top: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.coral,
shape: BoxShape.circle,
border: Border.all(color: AppColors.background, width: 2),
),
),
),
],
),
);
}
/// Botão de ação rápida (Bluetooth).
Widget _buildSmallActionButton(IconData icon, Color badgeColor) {
return GestureDetector(
onTap: () {
// Navegando para a nova tela de conexão Bluetooth
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BluetoothConnectionScreen()),
);
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
shape: BoxShape.circle,
border: Border.all(color: AppColors.white.withValues(alpha: 0.05)),
),
child: Stack(
children: [
Icon(icon, color: AppColors.white, size: 20),
Positioned(
right: 0,
top: 0,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: badgeColor,
shape: BoxShape.circle,
border: Border.all(color: AppColors.backgroundGrey, width: 1.5),
),
),
),
],
),
),
);
}
}
/// Pintor customizado para o mapa miniatura estético.
class MapPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paintPath = Paint()
..color = AppColors.coral.withValues(alpha: 0.5)
..strokeWidth = 3
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
path.moveTo(size.width * 0.1, size.height * 0.8);
path.quadraticBezierTo(size.width * 0.3, size.height * 0.9, size.width * 0.5, size.height * 0.5);
path.quadraticBezierTo(size.width * 0.7, size.height * 0.1, size.width * 0.9, size.height * 0.3);
final paintRoad = Paint()
..color = AppColors.white.withValues(alpha: 0.1)
..strokeWidth = 10
..style = PaintingStyle.stroke;
final road = Path();
road.moveTo(0, size.height * 0.5);
road.lineTo(size.width, size.height * 0.6);
canvas.drawPath(road, paintRoad);
canvas.drawPath(path, paintPath);
final markerPaint = Paint()..color = AppColors.coral;
canvas.drawCircle(Offset(size.width * 0.5, size.height * 0.5), 5, markerPaint);
canvas.drawCircle(Offset(size.width * 0.5, size.height * 0.5), 8, Paint()..color = AppColors.coral.withValues(alpha: 0.3));
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
/// Pintor customizado para o arco de progresso circular.
class CircularProgressPainter extends CustomPainter {
final double progress;
final double strokeWidth;
final Color progressColor;
final Color backgroundColor;
CircularProgressPainter({
required this.progress,
required this.strokeWidth,
required this.progressColor,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
canvas.drawCircle(center, radius, Paint()
..color = backgroundColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke);
final progressPaint = Paint()
..color = progressColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-1.5708, // -90 graus em radianos
6.2831 * progress, // 360 graus em radianos * progresso
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CircularProgressPainter oldDelegate) =>
oldDelegate.progress != progress;
}

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -16,7 +17,7 @@ class BluetoothConnectionScreen extends StatefulWidget {
class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> { class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
List<ScanResult> _scanResults = []; List<ScanResult> _scanResults = [];
bool _isScanning = false; bool _isScanning = false;
BluetoothDevice? _connectedDevice; // Track connected device BluetoothDevice? _connectedDevice;
late StreamSubscription<List<ScanResult>> _scanResultsSubscription; late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
late StreamSubscription<bool> _isScanningSubscription; late StreamSubscription<bool> _isScanningSubscription;
@@ -27,7 +28,7 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
_scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) { _scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) {
if (mounted) { if (mounted) {
setState(() { setState(() {
// FILTRO: Mantém apenas dispositivos que possuem um nome identificado // Filtra os dispositivos para mostrar apenas aqueles que possuem um nome identificado
_scanResults = results.where((r) => r.device.platformName.isNotEmpty).toList(); _scanResults = results.where((r) => r.device.platformName.isNotEmpty).toList();
}); });
} }
@@ -54,16 +55,30 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
Map<Permission, PermissionStatus> statuses = await [ Map<Permission, PermissionStatus> statuses = await [
Permission.bluetoothScan, Permission.bluetoothScan,
Permission.bluetoothConnect, Permission.bluetoothConnect,
Permission.bluetoothAdvertise,
Permission.location, Permission.location,
].request(); ].request();
if (statuses[Permission.bluetoothScan]!.isGranted && bool allGranted = true;
statuses[Permission.bluetoothConnect]!.isGranted) { if (statuses[Permission.bluetoothScan]?.isDenied ?? true) allGranted = false;
if (statuses[Permission.bluetoothConnect]?.isDenied ?? true) allGranted = false;
final bool scanPermanentlyDenied = statuses[Permission.bluetoothScan]?.isPermanentlyDenied ?? false;
final bool connectPermanentlyDenied = statuses[Permission.bluetoothConnect]?.isPermanentlyDenied ?? false;
if (scanPermanentlyDenied || connectPermanentlyDenied) {
if (mounted) {
_showSettingsDialog();
}
return;
}
if (allGranted) {
_startScan(); _startScan();
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text(AppStrings.permissionsDenied), content: Text(AppStrings.permissionsDenied),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
@@ -75,12 +90,35 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
} }
} }
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("Permissões Necessárias"),
content: const Text("As permissões de Bluetooth foram negadas permanentemente. Por favor, habilite-as nas configurações do sistema para continuar."),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("CANCELAR"),
),
TextButton(
onPressed: () {
openAppSettings();
Navigator.pop(context);
},
child: const Text("CONFIGURAÇÕES"),
),
],
),
);
}
Future<void> _startScan() async { Future<void> _startScan() async {
try { try {
if (await FlutterBluePlus.adapterState.first != BluetoothAdapterState.on) { if (await FlutterBluePlus.adapterState.first != BluetoothAdapterState.on) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text(AppStrings.turnOnBluetooth), content: Text(AppStrings.turnOnBluetooth),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
@@ -89,7 +127,9 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
return; return;
} }
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 15)); await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 15),
);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -135,7 +175,7 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
}); });
FlutterBluePlus.stopScan(); FlutterBluePlus.stopScan();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text(AppStrings.connectedSuccess), content: Text(AppStrings.connectedSuccess),
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
@@ -164,6 +204,63 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
} }
} }
Future<void> _sendSignalToDevice() async {
if (_connectedDevice == null) return;
try {
// 1. Descobrir serviços do dispositivo
List<BluetoothService> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -190,7 +287,6 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
Column( Column(
children: [ children: [
const SizedBox(height: 20), const SizedBox(height: 20),
// Header Card
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 25), padding: const EdgeInsets.symmetric(horizontal: 25),
child: Container( child: Container(
@@ -282,7 +378,35 @@ class _BluetoothConnectionScreenState extends State<BluetoothConnectionScreen> {
), ),
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
// Device List
// 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( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: _connectedDevice != null ? 1 : _scanResults.length, itemCount: _connectedDevice != null ? 1 : _scanResults.length,

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
import '../constants/app_colors.dart'; import '../constants/app_colors.dart';
import '../constants/app_strings.dart'; import '../constants/app_strings.dart';
@@ -16,27 +17,31 @@ class GoogleMapScreen extends StatefulWidget {
} }
class _GoogleMapScreenState extends State<GoogleMapScreen> { class _GoogleMapScreenState extends State<GoogleMapScreen> {
// CONFIGURAÇÃO: Insira aqui sua chave da Google Cloud com Directions API ativa
final String googleApiKey = "AIzaSyCk84rxmF044cxKLABf55rEKHDqOcyoV5k";
GoogleMapController? _mapController; GoogleMapController? _mapController;
StreamSubscription<Position>? _positionStreamSubscription; StreamSubscription<Position>? _positionStreamSubscription;
Timer? _simulationTimer; Timer? _stopwatchTimer;
// Controle de frequência de atualização para evitar sobrecarga e crashes
DateTime? _lastUpdate; DateTime? _lastUpdate;
final List<LatLng> _routePoints = []; final List<LatLng> _routePoints = [];
List<LatLng> _remainingPlannedPoints = []; // Lista para o trajeto que encurta
final Set<Polyline> _polylines = {}; final Set<Polyline> _polylines = {};
final Set<Marker> _markers = {}; final Set<Marker> _markers = {};
LatLng? _plannedStart;
LatLng? _plannedEnd; LatLng? _plannedEnd;
bool _isPlanningMode = false; bool _isPlanningMode = false;
bool _isRunning = false; bool _isRunning = false;
bool _isLoading = true;
double _currentSpeed = 0.0; double _currentSpeed = 0.0;
double _totalDistance = 0.0; double _totalDistance = 0.0;
LatLng _currentPosition = const LatLng(38.7223, -9.1393); 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? _startIcon;
BitmapDescriptor? _arrowIcon; BitmapDescriptor? _arrowIcon;
@@ -49,10 +54,10 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
} }
Future<void> _setupIconsAndTracking() async { Future<void> _setupIconsAndTracking() async {
// Marcadores premium: tamanho ideal para visibilidade e estética _startIcon = await _createPremiumMarker(Colors.greenAccent, Icons.play_arrow_rounded, 50);
_startIcon = await _createPremiumMarker(Colors.greenAccent, Icons.play_arrow_rounded, 85); _finishIcon = await _createPremiumMarker(AppColors.coral, Icons.flag_rounded, 65);
_finishIcon = await _createPremiumMarker(AppColors.coral, Icons.flag_rounded, 85); // Borda agora é proporcional ao tamanho (25).
_arrowIcon = await _createArrowMarker(Colors.black, Colors.white, 95); _arrowIcon = await _createArrowMarker(Colors.black, Colors.white, 85);
await _initTracking(); await _initTracking();
} }
@@ -81,9 +86,14 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
path.lineTo(size / 2, size * 0.7); path.lineTo(size / 2, size * 0.7);
path.lineTo(size * 0.1, size); path.lineTo(size * 0.1, size);
path.close(); path.close();
canvas.drawPath(path.shift(const Offset(0, 4)), Paint()..color = Colors.black38..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)); 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 = 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 ui.Image image = await recorder.endRecording().toImage(size.toInt(), size.toInt() + 6);
final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List()); return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List());
@@ -92,7 +102,7 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
@override @override
void dispose() { void dispose() {
_positionStreamSubscription?.cancel(); _positionStreamSubscription?.cancel();
_simulationTimer?.cancel(); _stopwatchTimer?.cancel();
_mapController?.dispose(); _mapController?.dispose();
super.dispose(); super.dispose();
} }
@@ -107,58 +117,68 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
} }
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.bestForNavigation); Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.bestForNavigation);
_currentPosition = LatLng(position.latitude, position.longitude); _currentPosition = LatLng(position.latitude, position.longitude);
// Adiciona ponto inicial para permitir rotação imediata
_routePoints.add(_currentPosition);
setState(() => _isLoading = false); setState(() => _isLoading = false);
_updateMarkers(); // Força a exibição imediata
_startLocationStream(); _startLocationStream();
} }
void _startLocationStream() { void _startLocationStream() {
_positionStreamSubscription = Geolocator.getPositionStream( _positionStreamSubscription = Geolocator.getPositionStream(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 0)
accuracy: LocationAccuracy.bestForNavigation,
distanceFilter: 0
)
).listen((Position position) { ).listen((Position position) {
if (!_isSimulating) {
final now = DateTime.now(); final now = DateTime.now();
if (_lastUpdate == null || now.difference(_lastUpdate!).inMilliseconds > 500) { if (_lastUpdate == null || now.difference(_lastUpdate!).inMilliseconds > 500) {
_lastUpdate = now; _lastUpdate = now;
_updatePosition(LatLng(position.latitude, position.longitude), position.speed); _updatePosition(LatLng(position.latitude, position.longitude), position.speed);
} }
}
}, onError: (error) {
debugPrint("${AppStrings.unknownDevice}: $error");
}); });
} }
void _updatePosition(LatLng newPoint, double speed) { void _updatePosition(LatLng newPoint, double speed) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
if (_isRunning) {
if (_routePoints.isNotEmpty) { if (_routePoints.isNotEmpty) {
_totalDistance += Geolocator.distanceBetween(_currentPosition.latitude, _currentPosition.longitude, newPoint.latitude, newPoint.longitude); _totalDistance += Geolocator.distanceBetween(_currentPosition.latitude, _currentPosition.longitude, newPoint.latitude, newPoint.longitude);
} }
_currentSpeed = speed >= 0 ? speed : 0;
_routePoints.add(newPoint); _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;
_currentPosition = newPoint; _currentPosition = newPoint;
_updateMarkers(); _updateMarkers();
if (_routePoints.length > 1) { if (_plannedEnd != null && _isRunning) {
_polylines.removeWhere((p) => p.polylineId.value == 'route' || p.polylineId.value == 'route_glow'); double distToEnd = Geolocator.distanceBetween(newPoint.latitude, newPoint.longitude, _plannedEnd!.latitude, _plannedEnd!.longitude);
_polylines.add(Polyline( if (distToEnd < 15) {
polylineId: const PolylineId('route_glow'), _finishRun();
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,
));
} }
}); });
@@ -167,6 +187,30 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
} }
} }
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<LatLng> 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() { void _updateMarkers() {
_markers.removeWhere((m) => m.markerId.value == 'follower'); _markers.removeWhere((m) => m.markerId.value == 'follower');
_markers.add(Marker( _markers.add(Marker(
@@ -176,7 +220,7 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
flat: true, flat: true,
anchor: const Offset(0.5, 0.5), anchor: const Offset(0.5, 0.5),
icon: _arrowIcon ?? BitmapDescriptor.defaultMarker, icon: _arrowIcon ?? BitmapDescriptor.defaultMarker,
zIndex: 12, zIndex: 12
)); ));
} }
@@ -187,76 +231,298 @@ class _GoogleMapScreenState extends State<GoogleMapScreen> {
return Geolocator.bearingBetween(p1.latitude, p1.longitude, p2.latitude, p2.longitude); return Geolocator.bearingBetween(p1.latitude, p1.longitude, p2.latitude, p2.longitude);
} }
void _onMapTap(LatLng point) { Future<List<LatLng>> _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<LatLng> 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; if (!_isPlanningMode) return;
setState(() { 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; _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))); _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));
} 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)));
}
}); });
List<LatLng> streetPoints = await _getRoutePolyline(_currentPosition, point);
if (streetPoints.isEmpty) {
streetPoints = [_currentPosition, point];
} }
void _toggleSimulation() {
if (_isSimulating) {
_simulationTimer?.cancel();
setState(() => _isSimulating = false);
} else {
setState(() { setState(() {
_isSimulating = true; _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; _isPlanningMode = false;
_routePoints.clear(); });
_polylines.removeWhere((p) => p.polylineId.value == 'route' || p.polylineId.value == 'route_glow');
Timer.periodic(const Duration(seconds: 1), (timer) {
if (_countdownValue == 1) {
timer.cancel();
_startRun();
} else {
setState(() => _countdownValue--);
}
});
}
void _startRun() {
setState(() {
_isCountingDown = false;
_isRunning = true;
_totalDistance = 0.0; _totalDistance = 0.0;
_currentPosition = _plannedStart ?? _currentPosition; _secondsElapsed = 0;
_routePoints.clear();
_routePoints.add(_currentPosition); _routePoints.add(_currentPosition);
_updateMarkers();
}); });
_simulationTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { _stopwatchTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
double latStep = 0.000025; setState(() => _secondsElapsed++);
double lngStep = 0.000025;
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));
}
LatLng nextPoint = LatLng(_currentPosition.latitude + latStep, _currentPosition.longitude + lngStep);
_updatePosition(nextPoint, 3.5 + Random().nextDouble() * 0.5);
}); });
} }
void _finishRun() {
_stopwatchTimer?.cancel();
setState(() {
_isRunning = false;
_remainingPlannedPoints.clear();
_polylines.removeWhere((p) => p.polylineId.value == 'planned_preview');
});
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColors.background, 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, 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))))]), body: Stack(
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), 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, 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) { 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))],
],
),
],
);
} }
} }

View File

@@ -0,0 +1,497 @@
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
import '../constants/app_strings.dart';
import 'google_map_screen.dart';
import 'bluetooth_connection_screen.dart';
/// Tela principal que exibe o progresso, estatísticas e menu.
class RunningScreen extends StatefulWidget {
const RunningScreen({super.key});
@override
State<RunningScreen> createState() => _RunningScreenState();
}
class _RunningScreenState extends State<RunningScreen>
with SingleTickerProviderStateMixin {
// Variáveis de estado para controlar os dados da corrida.
double progress = 0.35; // Progresso inicial simulado para estética.
double targetDistance = 8.0; // Distância alvo em KM.
double currentDistance = 2.8; // Distância atual percorrida simulada.
/// Constrói o indicador de progresso circular central com melhorias estéticas.
Widget _buildCircularProgressIndicator() {
return SizedBox(
width: 210,
height: 210,
child: Stack(
alignment: Alignment.center,
children: [
// Efeito de brilho (glow) sutil atrás do progresso.
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.white.withValues(alpha: 0.05),
blurRadius: 30,
spreadRadius: 10,
),
],
),
),
// TweenAnimationBuilder cria uma animação suave quando o valor do progresso muda.
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: progress),
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
builder: (context, value, _) {
return CustomPaint(
size: const Size(210, 210),
painter: CircularProgressPainter(
progress: value,
strokeWidth: 14,
progressColor: AppColors.white,
backgroundColor: AppColors.white.withValues(alpha: 0.15),
),
);
},
),
// Círculo interno que contém o texto da porcentagem.
Container(
width: 175,
height: 175,
decoration: const BoxDecoration(
color: AppColors.backgroundGrey,
shape: BoxShape.circle,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${(progress * 100).toInt()}%",
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w900,
color: AppColors.white,
letterSpacing: -1,
),
),
Text(
AppStrings.complete,
style: TextStyle(
color: AppColors.white.withValues(alpha: 0.5),
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background, // Cor de fundo escura definida nas constantes.
body: Stack(
children: [
// 1. Indicador de progresso circular posicionado no topo central.
Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 70),
child: _buildCircularProgressIndicator(),
),
),
// 2. Exibição da distância estilizada como um badge.
Positioned(
top: 300,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
decoration: BoxDecoration(
color: AppColors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: AppColors.white.withValues(alpha: 0.1)),
),
child: Text(
"${currentDistance.toStringAsFixed(1)} ${AppStrings.kmUnit} | ${targetDistance.toStringAsFixed(1)} ${AppStrings.kmUnit}",
style: const TextStyle(
color: AppColors.white,
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
),
),
),
// 3. Contêiner de estatísticas e o mapa interativo.
Positioned(
top: 360,
left: 20,
right: 20,
child: Container(
height: 210,
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: AppColors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
// Lado Esquerdo: Estatísticas.
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 15),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStatItem(Icons.directions_run_rounded, "3219", AppStrings.steps),
Divider(color: AppColors.white.withValues(alpha: 0.1), height: 1),
_buildStatItem(Icons.favorite_rounded, "98", AppStrings.bpm),
Divider(color: AppColors.white.withValues(alpha: 0.1), height: 1),
_buildStatItem(Icons.local_fire_department_rounded, "480", AppStrings.kcal),
],
),
),
),
// Lado Direito: Miniatura do Mapa Clicável.
Expanded(
flex: 6,
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const GoogleMapScreen()),
);
},
child: Container(
margin: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
children: [
Container(color: const Color(0xFF2C2C2E)),
CustomPaint(
size: Size.infinite,
painter: MapPainter(),
),
// Overlay estético indicando interatividade.
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.transparent,
AppColors.black.withValues(alpha: 0.4),
],
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColors.background.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: const Icon(Icons.fullscreen_rounded, color: AppColors.white, size: 20),
),
),
const Positioned(
top: 12,
left: 12,
child: Text(
AppStrings.mapPreview,
style: TextStyle(
color: AppColors.white,
fontSize: 10,
fontWeight: FontWeight.w900,
letterSpacing: 1,
),
),
),
],
),
),
),
),
),
],
),
),
),
// 4. Barra de progresso linear centralizada.
Positioned(
bottom: 170,
left: 50,
right: 50,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.white.withValues(alpha: 0.1),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.white),
),
),
),
// 5. Menu de navegação inferior.
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildMenuButton(Icons.settings_outlined, AppStrings.settings),
_buildMenuButton(Icons.group_outlined, AppStrings.groups),
_buildMenuButton(Icons.history_rounded, AppStrings.history),
_buildMenuButton(Icons.notifications_none_rounded, AppStrings.notifications, showBadge: true),
_buildMenuButton(Icons.person_outline_rounded, AppStrings.profile, isAvatar: true),
],
),
),
// 6. Ação rápida de Bluetooth.
Positioned(
top: 60,
right: 25,
child: _buildSmallActionButton(Icons.bluetooth, AppColors.error),
),
],
),
);
}
/// Item de estatística with design refinado.
Widget _buildStatItem(IconData icon, String value, String label) {
return Row(
children: [
Icon(icon, color: AppColors.coral.withValues(alpha: 0.8), size: 22),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(
color: AppColors.white,
fontSize: 19,
fontWeight: FontWeight.w900,
),
),
Text(
label,
style: TextStyle(
color: AppColors.white.withValues(alpha: 0.4),
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
],
);
}
/// Botões do menu com melhorias visuais.
Widget _buildMenuButton(IconData icon, String message, {bool showBadge = false, bool isAvatar = false}) {
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 1),
),
);
},
child: Stack(
clipBehavior: Clip.none,
children: [
isAvatar
? Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.coral, width: 2),
),
child: const CircleAvatar(
radius: 18,
backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=1'),
),
)
: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.white.withValues(alpha: 0.05)),
),
child: Icon(icon, color: AppColors.white.withValues(alpha: 0.9), size: 24),
),
if (showBadge)
Positioned(
right: -2,
top: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.coral,
shape: BoxShape.circle,
border: Border.all(color: AppColors.background, width: 2),
),
),
),
],
),
);
}
/// Botão de ação rápida (Bluetooth).
Widget _buildSmallActionButton(IconData icon, Color badgeColor) {
return GestureDetector(
onTap: () {
// Navegando para a nova tela de conexão Bluetooth
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BluetoothConnectionScreen()),
);
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.backgroundGrey,
shape: BoxShape.circle,
border: Border.all(color: AppColors.white.withValues(alpha: 0.05)),
),
child: Stack(
children: [
Icon(icon, color: AppColors.white, size: 20),
Positioned(
right: 0,
top: 0,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: badgeColor,
shape: BoxShape.circle,
border: Border.all(color: AppColors.backgroundGrey, width: 1.5),
),
),
),
],
),
),
);
}
}
/// Pintor customizado para o mapa miniatura estético.
class MapPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paintPath = Paint()
..color = AppColors.coral.withValues(alpha: 0.5)
..strokeWidth = 3
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
path.moveTo(size.width * 0.1, size.height * 0.8);
path.quadraticBezierTo(size.width * 0.3, size.height * 0.9, size.width * 0.5, size.height * 0.5);
path.quadraticBezierTo(size.width * 0.7, size.height * 0.1, size.width * 0.9, size.height * 0.3);
final paintRoad = Paint()
..color = AppColors.white.withValues(alpha: 0.1)
..strokeWidth = 10
..style = PaintingStyle.stroke;
final road = Path();
road.moveTo(0, size.height * 0.5);
road.lineTo(size.width, size.height * 0.6);
canvas.drawPath(road, paintRoad);
canvas.drawPath(path, paintPath);
final markerPaint = Paint()..color = AppColors.coral;
canvas.drawCircle(Offset(size.width * 0.5, size.height * 0.5), 5, markerPaint);
canvas.drawCircle(Offset(size.width * 0.5, size.height * 0.5), 8, Paint()..color = AppColors.coral.withValues(alpha: 0.3));
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
/// Pintor customizado para o arco de progresso circular.
class CircularProgressPainter extends CustomPainter {
final double progress;
final double strokeWidth;
final Color progressColor;
final Color backgroundColor;
CircularProgressPainter({
required this.progress,
required this.strokeWidth,
required this.progressColor,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
canvas.drawCircle(center, radius, Paint()
..color = backgroundColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke);
final progressPaint = Paint()
..color = progressColor
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-1.5708, // -90 graus em radianos
6.2831 * progress, // 360 graus em radianos * progresso
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CircularProgressPainter oldDelegate) =>
oldDelegate.progress != progress;
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
import 'running_screen.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.coral.withValues(alpha: 0.1),
AppColors.background,
],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Logo or Icon
Container(
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.coral.withValues(alpha: 0.1),
border: Border.all(color: AppColors.coral, width: 2),
),
child: const Icon(
Icons.directions_run_rounded,
size: 80,
color: AppColors.coral,
),
),
const SizedBox(height: 40),
const Text(
'RUN VISION PRO',
style: TextStyle(
color: AppColors.white,
fontSize: 32,
fontWeight: FontWeight.w900,
letterSpacing: 4,
),
),
const SizedBox(height: 10),
Text(
'Sua jornada começa aqui',
style: TextStyle(
color: AppColors.white.withValues(alpha: 0.6),
fontSize: 16,
fontWeight: FontWeight.w300,
),
),
const Spacer(),
// Start Button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const RunningScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.coral,
foregroundColor: AppColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 0,
),
child: const Text(
'COMEÇAR',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
),
),
const SizedBox(height: 60),
],
),
),
);
}
}

View File

@@ -190,6 +190,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.33" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -305,7 +313,7 @@ packages:
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View File

@@ -17,6 +17,8 @@ dependencies:
geolocator: ^10.1.0 geolocator: ^10.1.0
flutter_blue_plus: ^1.31.0 flutter_blue_plus: ^1.31.0
permission_handler: ^11.3.1 permission_handler: ^11.3.1
flutter_polyline_points: ^2.1.0
http: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: