first commit
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../screens/tracking_screen.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/gps_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../services/analytics_service.dart';
|
||||
import '../utils/polyline_utility.dart';
|
||||
import '../utils/geo_utils.dart';
|
||||
|
||||
/// المتحكم الرئيسي للتمرين (Main Workout Controller)
|
||||
/// يدير حالة التتبع الحية، التفاعل مع الـ GPS، وحفظ البيانات.
|
||||
class WorkoutController extends GetxController {
|
||||
final GpsService _gps = Get.find<GpsService>();
|
||||
final DatabaseService _db = Get.find<DatabaseService>();
|
||||
final SyncService _sync = Get.find<SyncService>();
|
||||
final AnalyticsService _analytics = Get.find<AnalyticsService>();
|
||||
|
||||
// حالة التتبع الحية
|
||||
final Rxn<Workout> currentWorkout = Rxn<Workout>();
|
||||
final RxList<GpsCoordinate> liveCoordinates = <GpsCoordinate>[].obs;
|
||||
final RxInt elapsedSeconds = 0.obs;
|
||||
final RxDouble liveDistance = 0.0.obs;
|
||||
final RxDouble liveSpeed = 0.0.obs;
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
// ─── التحكم في الجلسة ──────────────────────────────────────────
|
||||
|
||||
Future<void> startWorkout(WorkoutType type) async {
|
||||
bool ok = await _gps.startTracking(distanceFilter: 5);
|
||||
if (!ok) {
|
||||
Get.snackbar("خطأ", "فشل الوصول إلى الموقع (GPS). تأكد من الصلاحيات.");
|
||||
return;
|
||||
}
|
||||
|
||||
final id = const Uuid().v4();
|
||||
currentWorkout.value = Workout(id: id, workoutType: type);
|
||||
currentWorkout.value!.start();
|
||||
|
||||
liveCoordinates.clear();
|
||||
elapsedSeconds.value = 0;
|
||||
liveDistance.value = 0;
|
||||
liveSpeed.value = 0;
|
||||
|
||||
// ربط مستمع الإحداثيات
|
||||
_gps.onNewCoordinate = _handleNewCoordinate;
|
||||
|
||||
// الانتقال لشاشة التتبع
|
||||
Get.to(() => const TrackingScreen());
|
||||
|
||||
// بدء المؤقت الزمني
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (currentWorkout.value != null && !currentWorkout.value!.isPaused) {
|
||||
elapsedSeconds.value = currentWorkout.value!.activeDurationSeconds;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void pauseWorkout() {
|
||||
currentWorkout.value?.pause();
|
||||
update();
|
||||
}
|
||||
|
||||
void resumeWorkout() {
|
||||
currentWorkout.value?.resume();
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> stopWorkout() async {
|
||||
if (currentWorkout.value == null) return;
|
||||
|
||||
await _gps.stopTracking();
|
||||
_timer?.cancel();
|
||||
|
||||
final workout = currentWorkout.value!;
|
||||
workout.stop();
|
||||
|
||||
// إسناد الإحداثيات النهائية
|
||||
workout.coordinates = List.from(liveCoordinates);
|
||||
workout.distanceMeters = liveDistance.value;
|
||||
|
||||
// التحليل الذكي للمقاطع
|
||||
workout.segments =
|
||||
_analytics.analyzeSegments(workout.coordinates, workout.workoutType);
|
||||
|
||||
// تحويل المسار إلى Polyline مضغوط
|
||||
workout.routePolyline = PolylineUtility.encode(workout.coordinates);
|
||||
|
||||
if (workout.coordinates.isNotEmpty) {
|
||||
workout.startLat = workout.coordinates.first.latitude;
|
||||
workout.startLng = workout.coordinates.first.longitude;
|
||||
workout.endLat = workout.coordinates.last.latitude;
|
||||
workout.endLng = workout.coordinates.last.longitude;
|
||||
}
|
||||
|
||||
// حفظ في قاعدة البيانات المحلية
|
||||
await _db.saveWorkout(workout);
|
||||
await _db.saveSegments(workout.id, workout.segments);
|
||||
|
||||
// مسح الإحداثيات المؤقتة من جدول الـ GPS (لأنها حفظت في الـ Polyline)
|
||||
// أو يمكن تركها إذا أردت دقة عالية جداً لاحقاً
|
||||
|
||||
// إضافة إلى طابور المزامنة
|
||||
await _sync.queueWorkout(workout);
|
||||
|
||||
currentWorkout.value = null;
|
||||
Get.snackbar("تم الحفظ", "تم حفظ التمرين بنجاح وجاري المزامنة...");
|
||||
}
|
||||
|
||||
void _handleNewCoordinate(GpsCoordinate coord) {
|
||||
if (currentWorkout.value == null || currentWorkout.value!.isPaused) return;
|
||||
|
||||
liveCoordinates.add(coord);
|
||||
liveSpeed.value = coord.speed ?? 0;
|
||||
|
||||
// حفظ فوري في DB (للحماية من انهيار التطبيق)
|
||||
_db.saveCoordinate(currentWorkout.value!.id, coord);
|
||||
|
||||
// حساب المسافة التراكمية حياً
|
||||
if (liveCoordinates.length > 1) {
|
||||
// تم إضافة المسافة في الموديل أو هنا مباشرة
|
||||
// للتسهيل سنقوم بالتحديث هنا
|
||||
}
|
||||
|
||||
// تحديث المسافة الإجمالية (تبسيطاً سنعتمد على المجموع)
|
||||
_calculateLiveDistance();
|
||||
}
|
||||
|
||||
void _calculateLiveDistance() {
|
||||
if (liveCoordinates.length < 2) return;
|
||||
|
||||
double totalDist = 0;
|
||||
for (int i = 1; i < liveCoordinates.length; i++) {
|
||||
totalDist += GeoUtils.haversineDistance(
|
||||
liveCoordinates[i - 1].latitude,
|
||||
liveCoordinates[i - 1].longitude,
|
||||
liveCoordinates[i].latitude,
|
||||
liveCoordinates[i].longitude);
|
||||
}
|
||||
liveDistance.value = totalDist;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_timer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'services/gps_service.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/analytics_service.dart';
|
||||
import 'controllers/workout_controller.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 1. تهيئة الخدمات (Services Initialization)
|
||||
// بالترتيب: قاعدة البيانات -> الـ GPS -> المزامنة -> التحليل
|
||||
await Get.putAsync(
|
||||
() => DatabaseService().onInit().then((_) => DatabaseService()));
|
||||
Get.put(GpsService());
|
||||
await Get.putAsync(() => SyncService().onInit().then((_) => SyncService()));
|
||||
Get.put(AnalyticsService());
|
||||
|
||||
// 2. تهيئة المتحكمات (Controllers)
|
||||
Get.put(WorkoutController());
|
||||
|
||||
runApp(const SportPathApp());
|
||||
}
|
||||
|
||||
class SportPathApp extends StatelessWidget {
|
||||
const SportPathApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetMaterialApp(
|
||||
title: 'SportPath Tracker',
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: const Locale('ar', 'SA'), // دعم اللغة العربية افتراضياً
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
fontFamily: 'Inter', // التأكد من وجود الخط في pubspec
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFFFF5722), // اللون البرتقالي الأساسي
|
||||
primary: const Color(0xFFFF5722),
|
||||
secondary: const Color(0xFF263238),
|
||||
brightness: Brightness.dark, // وضع ليلي متميز
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// نموذج بيانات إحداثية GPS
|
||||
/// يمثل نقطة واحدة مع بيانات الارتفاع والدقة والسرعة
|
||||
class GpsCoordinate {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double? altitude;
|
||||
final double? accuracy;
|
||||
final double? speed; // m/s from GPS sensor
|
||||
final double? heading; // degrees from north
|
||||
final DateTime timestamp;
|
||||
|
||||
GpsCoordinate({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.altitude,
|
||||
this.accuracy,
|
||||
this.speed,
|
||||
this.heading,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'altitude': altitude,
|
||||
'accuracy': accuracy,
|
||||
'speed': speed,
|
||||
'heading': heading,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory GpsCoordinate.fromMap(Map<String, dynamic> map) {
|
||||
return GpsCoordinate(
|
||||
latitude: (map['latitude'] as num).toDouble(),
|
||||
longitude: (map['longitude'] as num).toDouble(),
|
||||
altitude: map['altitude'] != null
|
||||
? (map['altitude'] as num).toDouble()
|
||||
: null,
|
||||
accuracy: map['accuracy'] != null
|
||||
? (map['accuracy'] as num).toDouble()
|
||||
: null,
|
||||
speed:
|
||||
map['speed'] != null ? (map['speed'] as num).toDouble() : null,
|
||||
heading: map['heading'] != null
|
||||
? (map['heading'] as num).toDouble()
|
||||
: null,
|
||||
timestamp: DateTime.parse(map['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this coordinate has acceptable accuracy for tracking
|
||||
bool get isAccurate => accuracy != null && accuracy! <= 25.0;
|
||||
|
||||
/// Check if this is a valid GPS coordinate
|
||||
bool get isValid =>
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'GpsCoordinate(lat: $latitude, lng: $longitude, alt: $altitude, spd: $speed)';
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'gps_coordinate.dart';
|
||||
import 'workout_segment.dart';
|
||||
|
||||
/// حالات المزامنة
|
||||
enum SyncStatus { pending, synced, failed }
|
||||
|
||||
/// أنواع التمارين
|
||||
enum WorkoutType { running, walking }
|
||||
|
||||
/// نموذج بيانات التمرين الكامل
|
||||
/// يحتوي على كل المعلومات المتعلقة بجلسة تمرين واحدة
|
||||
class Workout {
|
||||
final String id;
|
||||
WorkoutType workoutType;
|
||||
DateTime? startTime;
|
||||
DateTime? endTime;
|
||||
double distanceMeters;
|
||||
int durationSeconds;
|
||||
double elevationGain;
|
||||
double elevationLoss;
|
||||
double calories;
|
||||
double avgSpeedMps;
|
||||
double maxSpeedMps;
|
||||
double avgPaceMinKm;
|
||||
String? routePolyline;
|
||||
double? startLat;
|
||||
double? startLng;
|
||||
double? endLat;
|
||||
double? endLng;
|
||||
String? weather;
|
||||
double? temperature;
|
||||
String notes;
|
||||
SyncStatus syncStatus;
|
||||
DateTime? syncedAt;
|
||||
DateTime createdAt;
|
||||
|
||||
// In-memory only (not persisted directly)
|
||||
List<GpsCoordinate> coordinates;
|
||||
List<WorkoutSegment> segments;
|
||||
|
||||
// Tracking state (in-memory only)
|
||||
bool isPaused;
|
||||
int totalPausedDurationMs;
|
||||
DateTime? _pauseStartTime;
|
||||
|
||||
Workout({
|
||||
required this.id,
|
||||
this.workoutType = WorkoutType.running,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.distanceMeters = 0,
|
||||
this.durationSeconds = 0,
|
||||
this.elevationGain = 0,
|
||||
this.elevationLoss = 0,
|
||||
this.calories = 0,
|
||||
this.avgSpeedMps = 0,
|
||||
this.maxSpeedMps = 0,
|
||||
this.avgPaceMinKm = 0,
|
||||
this.routePolyline,
|
||||
this.startLat,
|
||||
this.startLng,
|
||||
this.endLat,
|
||||
this.endLng,
|
||||
this.weather,
|
||||
this.temperature,
|
||||
this.notes = '',
|
||||
this.syncStatus = SyncStatus.pending,
|
||||
this.syncedAt,
|
||||
DateTime? createdAt,
|
||||
List<GpsCoordinate>? coordinates,
|
||||
List<WorkoutSegment>? segments,
|
||||
this.isPaused = false,
|
||||
this.totalPausedDurationMs = 0,
|
||||
}) : createdAt = createdAt ?? DateTime.now(),
|
||||
coordinates = coordinates ?? [],
|
||||
segments = segments ?? [];
|
||||
|
||||
// ─── Tracking Lifecycle ──────────────────────────────────────────
|
||||
|
||||
void start() {
|
||||
startTime = DateTime.now();
|
||||
isPaused = false;
|
||||
totalPausedDurationMs = 0;
|
||||
_pauseStartTime = null;
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (!isPaused) {
|
||||
isPaused = true;
|
||||
_pauseStartTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
void resume() {
|
||||
if (isPaused && _pauseStartTime != null) {
|
||||
totalPausedDurationMs +=
|
||||
DateTime.now().difference(_pauseStartTime!).inMilliseconds;
|
||||
isPaused = false;
|
||||
_pauseStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (isPaused) resume(); // Account for final pause
|
||||
endTime = DateTime.now();
|
||||
isPaused = false;
|
||||
_calculateFinalStats();
|
||||
}
|
||||
|
||||
/// Calculate active duration excluding pauses
|
||||
int get activeDurationSeconds {
|
||||
if (startTime == null) return 0;
|
||||
final end = endTime ?? DateTime.now();
|
||||
int total = end.difference(startTime!).inSeconds;
|
||||
int paused = totalPausedDurationMs ~/ 1000;
|
||||
return (total - paused).clamp(0, total);
|
||||
}
|
||||
|
||||
// ─── Stats Calculation ───────────────────────────────────────────
|
||||
|
||||
void _calculateFinalStats() {
|
||||
durationSeconds = activeDurationSeconds;
|
||||
|
||||
if (durationSeconds > 0 && distanceMeters > 0) {
|
||||
avgSpeedMps = distanceMeters / durationSeconds;
|
||||
|
||||
// Pace in min/km
|
||||
double kmPerHour = avgSpeedMps * 3.6;
|
||||
if (kmPerHour > 0) {
|
||||
avgPaceMinKm = 60.0 / kmPerHour;
|
||||
}
|
||||
}
|
||||
|
||||
// Calories estimation (MET-based for 70kg person)
|
||||
calories = _estimateCalories();
|
||||
|
||||
// Elevation from coordinates
|
||||
_calculateElevation();
|
||||
}
|
||||
|
||||
double _estimateCalories() {
|
||||
// MET values: Running ~10, Walking ~3.8
|
||||
double met = workoutType == WorkoutType.running ? 10.0 : 3.8;
|
||||
double weightKg = 70.0; // Default weight
|
||||
double hours = durationSeconds / 3600.0;
|
||||
return met * weightKg * hours;
|
||||
}
|
||||
|
||||
void _calculateElevation() {
|
||||
if (coordinates.length < 2) return;
|
||||
|
||||
double gain = 0;
|
||||
double loss = 0;
|
||||
|
||||
for (int i = 1; i < coordinates.length; i++) {
|
||||
final prevAlt = coordinates[i - 1].altitude;
|
||||
final currAlt = coordinates[i].altitude;
|
||||
if (prevAlt != null && currAlt != null) {
|
||||
double diff = currAlt - prevAlt;
|
||||
// Filter noise: ignore changes < 1m
|
||||
if (diff > 1.0) {
|
||||
gain += diff;
|
||||
} else if (diff < -1.0) {
|
||||
loss += diff.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elevationGain = gain;
|
||||
elevationLoss = loss;
|
||||
}
|
||||
|
||||
// ─── Serialization ──────────────────────────────────────────────
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'workout_type': workoutType.name,
|
||||
'start_time': startTime?.toIso8601String(),
|
||||
'end_time': endTime?.toIso8601String(),
|
||||
'distance_meters': distanceMeters,
|
||||
'duration_seconds': durationSeconds,
|
||||
'elevation_gain': elevationGain,
|
||||
'elevation_loss': elevationLoss,
|
||||
'calories': calories,
|
||||
'avg_speed_mps': avgSpeedMps,
|
||||
'max_speed_mps': maxSpeedMps,
|
||||
'avg_pace_min_km': avgPaceMinKm,
|
||||
'route_polyline': routePolyline,
|
||||
'start_lat': startLat,
|
||||
'start_lng': startLng,
|
||||
'end_lat': endLat,
|
||||
'end_lng': endLng,
|
||||
'weather': weather,
|
||||
'temperature': temperature,
|
||||
'notes': notes,
|
||||
'sync_status': syncStatus.name,
|
||||
'synced_at': syncedAt?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory Workout.fromMap(Map<String, dynamic> map) {
|
||||
return Workout(
|
||||
id: map['id'] as String,
|
||||
workoutType: WorkoutType.values.firstWhere(
|
||||
(e) => e.name == map['workout_type'],
|
||||
orElse: () => WorkoutType.running,
|
||||
),
|
||||
startTime: map['start_time'] != null
|
||||
? DateTime.parse(map['start_time'] as String)
|
||||
: null,
|
||||
endTime: map['end_time'] != null
|
||||
? DateTime.parse(map['end_time'] as String)
|
||||
: null,
|
||||
distanceMeters: (map['distance_meters'] as num?)?.toDouble() ?? 0,
|
||||
durationSeconds: (map['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
elevationGain: (map['elevation_gain'] as num?)?.toDouble() ?? 0,
|
||||
elevationLoss: (map['elevation_loss'] as num?)?.toDouble() ?? 0,
|
||||
calories: (map['calories'] as num?)?.toDouble() ?? 0,
|
||||
avgSpeedMps: (map['avg_speed_mps'] as num?)?.toDouble() ?? 0,
|
||||
maxSpeedMps: (map['max_speed_mps'] as num?)?.toDouble() ?? 0,
|
||||
avgPaceMinKm: (map['avg_pace_min_km'] as num?)?.toDouble() ?? 0,
|
||||
routePolyline: map['route_polyline'] as String?,
|
||||
startLat: (map['start_lat'] as num?)?.toDouble(),
|
||||
startLng: (map['start_lng'] as num?)?.toDouble(),
|
||||
endLat: (map['end_lat'] as num?)?.toDouble(),
|
||||
endLng: (map['end_lng'] as num?)?.toDouble(),
|
||||
weather: map['weather'] as String?,
|
||||
temperature: (map['temperature'] as num?)?.toDouble(),
|
||||
notes: map['notes'] as String? ?? '',
|
||||
syncStatus: SyncStatus.values.firstWhere(
|
||||
(e) => e.name == map['sync_status'],
|
||||
orElse: () => SyncStatus.pending,
|
||||
),
|
||||
syncedAt: map['synced_at'] != null
|
||||
? DateTime.parse(map['synced_at'] as String)
|
||||
: null,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to API payload for server submission
|
||||
Map<String, dynamic> toApiPayload() {
|
||||
return {
|
||||
'workout_type': workoutType.name,
|
||||
'distance_meters': distanceMeters.toInt(),
|
||||
'duration_seconds': durationSeconds,
|
||||
'elevation_gain_meters': elevationGain.toInt(),
|
||||
'elevation_loss_meters': elevationLoss.toInt(),
|
||||
'calories_burned': calories,
|
||||
'max_speed_mps': maxSpeedMps,
|
||||
'route_polyline': routePolyline ?? '',
|
||||
'start_time': startTime?.toUtc().toIso8601String() ?? '',
|
||||
'end_time': endTime?.toUtc().toIso8601String() ?? '',
|
||||
'weather_condition': weather ?? '',
|
||||
'temperature_celsius': temperature ?? 0,
|
||||
'notes': notes,
|
||||
'is_public': false,
|
||||
'segments': segments.map((s) => s.toApiMap()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/// أنواع المقاطع المكتشفة تلقائياً
|
||||
enum SegmentType {
|
||||
warmup, // إحماء - أول 5-10% من المسافة بسرعة منخفضة
|
||||
main, // المرحلة الرئيسية
|
||||
cooldown, // تبريد - آخر 5-10% بسرعة متناقصة
|
||||
stop, // نقطة توقف - سرعة < 0.3 m/s لأكثر من 10 ثوان
|
||||
sprint, // تسارع - سرعة > 120% من المتوسط
|
||||
uphill, // صعود - ارتفاع > 3% grade
|
||||
downhill, // نزول - انخفاض > 3% grade
|
||||
}
|
||||
|
||||
/// نموذج مقطع من المسار
|
||||
/// يمثل جزء من التمرين تم تقسيمه بناءً على تغيرات السرعة أو الارتفاع
|
||||
class WorkoutSegment {
|
||||
final int segmentIndex;
|
||||
final SegmentType type;
|
||||
final double distanceMeters;
|
||||
final int durationSeconds;
|
||||
final double avgSpeedMps;
|
||||
final double maxSpeedMps;
|
||||
final double elevationChange; // positive = uphill, negative = downhill
|
||||
final int startCoordIndex; // index in coordinates list
|
||||
final int endCoordIndex;
|
||||
|
||||
WorkoutSegment({
|
||||
required this.segmentIndex,
|
||||
required this.type,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
required this.avgSpeedMps,
|
||||
this.maxSpeedMps = 0,
|
||||
this.elevationChange = 0,
|
||||
required this.startCoordIndex,
|
||||
required this.endCoordIndex,
|
||||
});
|
||||
|
||||
/// Pace in min/km
|
||||
double get paceMinKm {
|
||||
if (avgSpeedMps <= 0) return 0;
|
||||
double kmh = avgSpeedMps * 3.6;
|
||||
return kmh > 0 ? 60.0 / kmh : 0;
|
||||
}
|
||||
|
||||
/// Gradient percentage (for elevation segments)
|
||||
double get gradientPercent {
|
||||
if (distanceMeters <= 0) return 0;
|
||||
return (elevationChange / distanceMeters) * 100;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'segment_index': segmentIndex,
|
||||
'segment_type': type.name,
|
||||
'distance_meters': distanceMeters,
|
||||
'duration_seconds': durationSeconds,
|
||||
'avg_speed_mps': avgSpeedMps,
|
||||
'max_speed_mps': maxSpeedMps,
|
||||
'elevation_change': elevationChange,
|
||||
'start_coord_index': startCoordIndex,
|
||||
'end_coord_index': endCoordIndex,
|
||||
};
|
||||
}
|
||||
|
||||
factory WorkoutSegment.fromMap(Map<String, dynamic> map) {
|
||||
return WorkoutSegment(
|
||||
segmentIndex: map['segment_index'] as int,
|
||||
type: SegmentType.values.firstWhere(
|
||||
(e) => e.name == map['segment_type'],
|
||||
orElse: () => SegmentType.main,
|
||||
),
|
||||
distanceMeters: (map['distance_meters'] as num).toDouble(),
|
||||
durationSeconds: map['duration_seconds'] as int,
|
||||
avgSpeedMps: (map['avg_speed_mps'] as num).toDouble(),
|
||||
maxSpeedMps: (map['max_speed_mps'] as num?)?.toDouble() ?? 0,
|
||||
elevationChange: (map['elevation_change'] as num?)?.toDouble() ?? 0,
|
||||
startCoordIndex: map['start_coord_index'] as int,
|
||||
endCoordIndex: map['end_coord_index'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to API-compatible map
|
||||
Map<String, dynamic> toApiMap() {
|
||||
return {
|
||||
'segment_order': segmentIndex,
|
||||
'duration_seconds': durationSeconds,
|
||||
'distance_meters': distanceMeters.toInt(),
|
||||
'index_in_polyline': startCoordIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../utils/formatters.dart';
|
||||
|
||||
/// شاشة سجل التمارين (Workout History Screen)
|
||||
class HistoryScreen extends StatelessWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = Get.find<DatabaseService>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("سجل التمارين", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: FutureBuilder<List<Workout>>(
|
||||
future: db.getAllWorkouts(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final workouts = snapshot.data!;
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: workouts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final workout = workouts[index];
|
||||
return _buildWorkoutCard(workout);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.history_toggle_off, size: 80, color: Colors.white.withOpacity(0.1)),
|
||||
const SizedBox(height: 20),
|
||||
const Text("لا توجد تمارين مسجلة بعد", style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWorkoutCard(Workout workout) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 15),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// الانتقال لتفاصيل التمرين (مستقبلاً)
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(15.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// أيقونة النوع
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (workout.workoutType == WorkoutType.running ? Colors.orange : Colors.green).withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
workout.workoutType == WorkoutType.running ? Icons.directions_run : Icons.directions_walk,
|
||||
color: workout.workoutType == WorkoutType.running ? Colors.orange : Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
// البيانات الأساسية
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Formatters.date(workout.createdAt),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
"${Formatters.distanceKm(workout.distanceMeters)} كم • ${Formatters.time(workout.durationSeconds)}",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// حالة المزامنة
|
||||
Icon(
|
||||
workout.syncStatus == SyncStatus.synced ? Icons.cloud_done : Icons.cloud_upload_outlined,
|
||||
size: 18,
|
||||
color: workout.syncStatus == SyncStatus.synced ? Colors.blue : Colors.orange,
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../controllers/workout_controller.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final workoutCtrl = Get.find<WorkoutController>();
|
||||
final syncService = Get.find<SyncService>();
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// 1. شريط العنوان المخصص (Premium Header)
|
||||
SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: const Text("Tracker Running",
|
||||
style: TextStyle(fontWeight: FontWeight.w800)),
|
||||
background: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFFFF5722), Color(0xFFE64A19)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(Icons.directions_run,
|
||||
size: 80, color: Colors.white.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Obx(() => IconButton(
|
||||
icon: Icon(
|
||||
syncService.isOnline
|
||||
? Icons.cloud_done
|
||||
: Icons.cloud_off,
|
||||
color:
|
||||
syncService.isOnline ? Colors.white : Colors.red),
|
||||
onPressed: () {},
|
||||
)),
|
||||
],
|
||||
),
|
||||
|
||||
// 2. حالة المزامنة السريعة
|
||||
SliverToBoxAdapter(
|
||||
child: Obx(() => syncService.pendingCount > 0
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: Colors.orange.withOpacity(0.2),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"لديك ${syncService.pendingCount} تمارين بانتظار المزامنة..",
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: Colors.orange),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink()),
|
||||
),
|
||||
|
||||
// 3. أزرار البدء السريع (Quick Actions)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("ابدأ رحلتك اليوم",
|
||||
style:
|
||||
TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 15),
|
||||
Row(
|
||||
children: [
|
||||
_buildStartCard(
|
||||
title: "ركض",
|
||||
icon: Icons.run_circle,
|
||||
color: const Color(0xFFFF5722),
|
||||
onTap: () =>
|
||||
workoutCtrl.startWorkout(WorkoutType.running),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
_buildStartCard(
|
||||
title: "مشي",
|
||||
icon: Icons.directions_walk,
|
||||
color: Colors.green,
|
||||
onTap: () =>
|
||||
workoutCtrl.startWorkout(WorkoutType.walking),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 4. عرض حالة التمرين الحالي (إذا كان نشطاً)
|
||||
SliverToBoxAdapter(
|
||||
child: Obx(() => workoutCtrl.currentWorkout.value != null
|
||||
? _buildActiveWorkoutCard(workoutCtrl)
|
||||
: const SizedBox.shrink()),
|
||||
),
|
||||
|
||||
// 5. قسم الإحصائيات أو التمرين الأخير
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("آخر تمرين",
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text("عرض الكل",
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFF5722), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 30, color: Colors.white10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildMiniStat("المسافة", "0.0 كم"),
|
||||
_buildMiniStat("الوقت", "00:00"),
|
||||
_buildMiniStat("السعرات", "0"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStartCard(
|
||||
{required String title,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required VoidCallback onTap}) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withOpacity(0.3), width: 2),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 40, color: color),
|
||||
const SizedBox(height: 10),
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold, color: color)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveWorkoutCard(WorkoutController ctrl) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF5722),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("التمرين قيد التسجيل...",
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 15),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildWhiteStat("المسافة",
|
||||
"${Formatters.distanceKm(ctrl.liveDistance.value)} كم"),
|
||||
_buildWhiteStat(
|
||||
"الوقت", Formatters.time(ctrl.elapsedSeconds.value)),
|
||||
_buildWhiteStat(
|
||||
"السرعة", Formatters.speedKmh(ctrl.liveSpeed.value)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => ctrl.stopWorkout(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.red),
|
||||
child: const Text("إنهاء"),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniStat(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
Text(value,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWhiteStat(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../controllers/workout_controller.dart';
|
||||
import '../utils/formatters.dart';
|
||||
|
||||
/// شاشة تتبع التمرين (Live Tracking Screen)
|
||||
/// تعرض الخريطة، المسار، والإحصائيات الحية.
|
||||
class TrackingScreen extends StatefulWidget {
|
||||
const TrackingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TrackingScreen> createState() => _TrackingScreenState();
|
||||
}
|
||||
|
||||
class _TrackingScreenState extends State<TrackingScreen> {
|
||||
final MapController _mapController = MapController();
|
||||
bool _followUser = true;
|
||||
late Worker _worker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final ctrl = Get.find<WorkoutController>();
|
||||
_worker = ever(ctrl.liveCoordinates, (coords) {
|
||||
if (_followUser && coords.isNotEmpty) {
|
||||
try {
|
||||
final center = LatLng(coords.last.latitude, coords.last.longitude);
|
||||
_mapController.move(center, _mapController.camera.zoom);
|
||||
} catch (_) {
|
||||
// Map not fully initialized yet
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_worker.dispose();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ctrl = Get.find<WorkoutController>();
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// 1. الخريطة (The Map)
|
||||
Obx(() {
|
||||
final coords = ctrl.liveCoordinates;
|
||||
final center = coords.isNotEmpty
|
||||
? LatLng(coords.last.latitude, coords.last.longitude)
|
||||
: const LatLng(31.9454, 35.9284); // Amman default
|
||||
|
||||
return FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: center,
|
||||
initialZoom: 16,
|
||||
onPositionChanged: (pos, hasGesture) {
|
||||
if (hasGesture) setState(() => _followUser = false);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.musadaq.running',
|
||||
),
|
||||
// رسم المسار (The Route Polyline)
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: coords.map((c) => LatLng(c.latitude, c.longitude)).toList(),
|
||||
strokeWidth: 5,
|
||||
color: const Color(0xFFFF5722),
|
||||
),
|
||||
],
|
||||
),
|
||||
// موقع المستخدم الحالي (Current Location Marker)
|
||||
if (coords.isNotEmpty)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(coords.last.latitude, coords.last.longitude),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: _buildLocationMarker(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
// 2. لوحة الإحصائيات (Stats Overlay)
|
||||
_buildTopStats(ctrl),
|
||||
|
||||
// 3. أزرار التحكم (Control Buttons)
|
||||
_buildBottomControls(ctrl),
|
||||
|
||||
// 4. زر إعادة التمركز (Recenter Button)
|
||||
if (!_followUser)
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 150,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: () => setState(() => _followUser = true),
|
||||
backgroundColor: Colors.white,
|
||||
child: const Icon(Icons.my_location, color: Colors.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopStats(WorkoutController ctrl) {
|
||||
return Positioned(
|
||||
top: 50,
|
||||
left: 20,
|
||||
right: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Obx(() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatItem("الوقت", Formatters.time(ctrl.elapsedSeconds.value)),
|
||||
_buildStatItem("المسافة", "${Formatters.distanceKm(ctrl.liveDistance.value)} كم"),
|
||||
_buildStatItem("الوتيرة", Formatters.pace(ctrl.liveSpeed.value)),
|
||||
],
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Text(value, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomControls(WorkoutController ctrl) {
|
||||
return Positioned(
|
||||
bottom: 40,
|
||||
left: 30,
|
||||
right: 30,
|
||||
child: Obx(() {
|
||||
final workout = ctrl.currentWorkout.value;
|
||||
if (workout == null) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// زر التوقف المؤقت / الاستئناف
|
||||
_buildCircleButton(
|
||||
onPressed: workout.isPaused ? ctrl.resumeWorkout : ctrl.pauseWorkout,
|
||||
icon: workout.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: Colors.orange,
|
||||
),
|
||||
// زر الإنهاء (ضغطة مطولة لتجنب الخطأ)
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
ctrl.stopWorkout();
|
||||
Get.back();
|
||||
},
|
||||
child: _buildCircleButton(
|
||||
onPressed: () {
|
||||
Get.snackbar("تنبيه", "اضغط مطولاً على زر الإيقاف للإنهاء",
|
||||
snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.white, colorText: Colors.black);
|
||||
},
|
||||
icon: Icons.stop,
|
||||
color: Colors.red,
|
||||
size: 80,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircleButton({required VoidCallback onPressed, required IconData icon, required Color color, double size = 60}) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: color.withOpacity(0.4), blurRadius: 10, spreadRadius: 2)],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: Colors.white, size: size * 0.5),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationMarker() {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF5722).withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFF5722),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:get/get.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/workout_segment.dart';
|
||||
import '../utils/geo_utils.dart';
|
||||
|
||||
/// خدمة التحليل الذكي (Smart Analytics Service)
|
||||
/// مسؤولة عن تقسيم المسار إلى أجزاء، كشف التوقفات، وتحليل أنماط الأداء.
|
||||
class AnalyticsService extends GetxService {
|
||||
|
||||
/// تحليل المسار وتقسيمه إلى أجزاء (Segments)
|
||||
/// يعتمد على تغيرات السرعة، الارتفاع، أو الاتجاه.
|
||||
List<WorkoutSegment> analyzeSegments(List<GpsCoordinate> coords, WorkoutType type) {
|
||||
if (coords.length < 5) return [];
|
||||
|
||||
List<WorkoutSegment> segments = [];
|
||||
int startIdx = 0;
|
||||
int segmentCounter = 0;
|
||||
|
||||
// 1. تحديد مرحلة الإحماء (Warm-up)
|
||||
// نعتبر أول 10% من النقاط أو أول 500 متر إحماء إذا كانت السرعة متدرجة
|
||||
int warmupEnd = (coords.length * 0.1).toInt().clamp(5, 50);
|
||||
segments.add(_createSegment(coords, 0, warmupEnd, segmentCounter++, SegmentType.warmup));
|
||||
startIdx = warmupEnd;
|
||||
|
||||
// 2. تحليل النقاط الوسطى لكشف التوقفات أو التسارعات
|
||||
for (int i = startIdx + 1; i < coords.length - 5; i++) {
|
||||
final current = coords[i];
|
||||
final prev = coords[i - 1];
|
||||
|
||||
// كشف التوقف (Stop detection)
|
||||
// إذا كانت السرعة شبه صفرية لمسافة معينة
|
||||
if (current.speed != null && current.speed! < 0.3) {
|
||||
int stopEnd = i;
|
||||
while (stopEnd < coords.length - 1 &&
|
||||
(coords[stopEnd].speed ?? 0) < 0.3 &&
|
||||
stopEnd - i < 20) { // توقف بحد أقصى 20 نقطة لتجنب المقاطع الضخمة
|
||||
stopEnd++;
|
||||
}
|
||||
|
||||
if (stopEnd - i > 3) { // توقف حقيقي لأكثر من 10 ثواني تقريباً
|
||||
// إغلاق المقطع السابق إذا كان هناك مسافة
|
||||
if (i > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, i, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
segments.add(_createSegment(coords, i, stopEnd, segmentCounter++, SegmentType.stop));
|
||||
i = stopEnd;
|
||||
startIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// كشف الميول (Elevation detection)
|
||||
// يمكن التوسع هنا لكشف uphill/downhill
|
||||
}
|
||||
|
||||
// 3. تحديد مرحلة التبريد (Cool-down)
|
||||
int cooldownStart = (coords.length * 0.9).toInt();
|
||||
if (cooldownStart > startIdx) {
|
||||
segments.add(_createSegment(coords, startIdx, cooldownStart, segmentCounter++, SegmentType.main));
|
||||
segments.add(_createSegment(coords, cooldownStart, coords.length - 1, segmentCounter++, SegmentType.cooldown));
|
||||
} else {
|
||||
segments.add(_createSegment(coords, startIdx, coords.length - 1, segmentCounter++, SegmentType.main));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
WorkoutSegment _createSegment(List<GpsCoordinate> coords, int start, int end, int index, SegmentType type) {
|
||||
double dist = 0;
|
||||
double maxSpd = 0;
|
||||
double sumSpd = 0;
|
||||
double elevChange = 0;
|
||||
|
||||
for (int i = start + 1; i <= end; i++) {
|
||||
dist += GeoUtils.haversineDistance(
|
||||
coords[i-1].latitude, coords[i-1].longitude,
|
||||
coords[i].latitude, coords[i].longitude
|
||||
);
|
||||
double s = coords[i].speed ?? 0;
|
||||
if (s > maxSpd) maxSpd = s;
|
||||
sumSpd += s;
|
||||
|
||||
if (coords[i].altitude != null && coords[i-1].altitude != null) {
|
||||
elevChange += (coords[i].altitude! - coords[i-1].altitude!);
|
||||
}
|
||||
}
|
||||
|
||||
int duration = coords[end].timestamp.difference(coords[start].timestamp).inSeconds;
|
||||
|
||||
return WorkoutSegment(
|
||||
segmentIndex: index,
|
||||
type: type,
|
||||
distanceMeters: dist,
|
||||
durationSeconds: duration,
|
||||
avgSpeedMps: duration > 0 ? dist / duration : 0,
|
||||
maxSpeedMps: maxSpd,
|
||||
elevationChange: elevChange,
|
||||
startCoordIndex: start,
|
||||
endCoordIndex: end,
|
||||
);
|
||||
}
|
||||
|
||||
/// حساب "التقارير الذكية"
|
||||
/// مثال: "سرعتك في أول 2 كم تحسنت بنسبة 12%"
|
||||
String generateInsight(List<Workout> history) {
|
||||
if (history.length < 2) return "ابدأ تمرينك الأول للحصول على تحليلات!";
|
||||
|
||||
// منطق بسيط للمقارنة بين آخر تمرينين
|
||||
final latest = history[0];
|
||||
final previous = history[1];
|
||||
|
||||
if (latest.avgSpeedMps > previous.avgSpeedMps) {
|
||||
double diff = ((latest.avgSpeedMps - previous.avgSpeedMps) / previous.avgSpeedMps) * 100;
|
||||
return "أداء رائع! سرعتك المتوسطة تحسنت بنسبة ${diff.toStringAsFixed(1)}% مقارنة بآخر مرة.";
|
||||
}
|
||||
|
||||
return "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
import '../models/workout.dart';
|
||||
import '../models/workout_segment.dart';
|
||||
|
||||
/// خدمة قاعدة البيانات المحلية
|
||||
/// تدير كل جداول SQLite: الإحداثيات، التمارين، المقاطع، طابور المزامنة
|
||||
class DatabaseService extends GetxService {
|
||||
static const String _dbName = 'sportpath_tracker.db';
|
||||
static const int _dbVersion = 2;
|
||||
|
||||
late Database _db;
|
||||
|
||||
Database get db => _db;
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
await _initDatabase();
|
||||
}
|
||||
|
||||
Future<void> _initDatabase() async {
|
||||
try {
|
||||
final dbPath = join(await getDatabasesPath(), _dbName);
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: _dbVersion,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
debugPrint('[DB] Database initialized at $dbPath');
|
||||
} catch (e) {
|
||||
debugPrint('[DB] ERROR initializing database: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
debugPrint('[DB] Creating all tables (v$version)...');
|
||||
|
||||
// GPS Coordinates table — stores raw tracking data
|
||||
await db.execute('''
|
||||
CREATE TABLE gps_coordinates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workout_id TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
altitude REAL,
|
||||
accuracy REAL,
|
||||
speed REAL,
|
||||
heading REAL,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_coords_workout ON gps_coordinates(workout_id)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_coords_timestamp ON gps_coordinates(workout_id, timestamp)',
|
||||
);
|
||||
|
||||
// Workouts table — stores completed workout summaries
|
||||
await db.execute('''
|
||||
CREATE TABLE workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
workout_type TEXT NOT NULL DEFAULT 'running',
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
distance_meters REAL DEFAULT 0,
|
||||
duration_seconds INTEGER DEFAULT 0,
|
||||
elevation_gain REAL DEFAULT 0,
|
||||
elevation_loss REAL DEFAULT 0,
|
||||
calories REAL DEFAULT 0,
|
||||
avg_speed_mps REAL DEFAULT 0,
|
||||
max_speed_mps REAL DEFAULT 0,
|
||||
avg_pace_min_km REAL DEFAULT 0,
|
||||
route_polyline TEXT,
|
||||
start_lat REAL,
|
||||
start_lng REAL,
|
||||
end_lat REAL,
|
||||
end_lng REAL,
|
||||
weather TEXT,
|
||||
temperature REAL,
|
||||
notes TEXT DEFAULT '',
|
||||
sync_status TEXT DEFAULT 'pending',
|
||||
synced_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_workouts_sync ON workouts(sync_status)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_workouts_created ON workouts(created_at DESC)',
|
||||
);
|
||||
|
||||
// Workout segments table — auto-detected route segments
|
||||
await db.execute('''
|
||||
CREATE TABLE workout_segments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workout_id TEXT NOT NULL,
|
||||
segment_index INTEGER NOT NULL,
|
||||
segment_type TEXT NOT NULL,
|
||||
distance_meters REAL DEFAULT 0,
|
||||
duration_seconds INTEGER DEFAULT 0,
|
||||
avg_speed_mps REAL DEFAULT 0,
|
||||
max_speed_mps REAL DEFAULT 0,
|
||||
elevation_change REAL DEFAULT 0,
|
||||
start_coord_index INTEGER DEFAULT 0,
|
||||
end_coord_index INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (workout_id) REFERENCES workouts(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_segments_workout ON workout_segments(workout_id)',
|
||||
);
|
||||
|
||||
// Sync queue — tracks pending uploads
|
||||
await db.execute('''
|
||||
CREATE TABLE sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workout_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
last_attempt TEXT,
|
||||
status TEXT DEFAULT 'queued',
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (workout_id) REFERENCES workouts(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_sync_status ON sync_queue(status)',
|
||||
);
|
||||
|
||||
debugPrint('[DB] All tables created successfully');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
debugPrint('[DB] Upgrading from v$oldVersion to v$newVersion');
|
||||
if (oldVersion < 2) {
|
||||
// Add speed and heading columns to existing coordinates table
|
||||
try {
|
||||
await db.execute(
|
||||
'ALTER TABLE gps_coordinates ADD COLUMN speed REAL',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE gps_coordinates ADD COLUMN heading REAL',
|
||||
);
|
||||
} catch (_) {
|
||||
// Columns may already exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GPS Coordinates CRUD ─────────────────────────────────────
|
||||
|
||||
/// Save a single coordinate during active tracking
|
||||
Future<void> saveCoordinate(String workoutId, GpsCoordinate coord) async {
|
||||
await _db.insert('gps_coordinates', {
|
||||
'workout_id': workoutId,
|
||||
'latitude': coord.latitude,
|
||||
'longitude': coord.longitude,
|
||||
'altitude': coord.altitude,
|
||||
'accuracy': coord.accuracy,
|
||||
'speed': coord.speed,
|
||||
'heading': coord.heading,
|
||||
'timestamp': coord.timestamp.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Batch save coordinates (more efficient for large imports)
|
||||
Future<void> saveCoordinatesBatch(
|
||||
String workoutId,
|
||||
List<GpsCoordinate> coords,
|
||||
) async {
|
||||
final batch = _db.batch();
|
||||
for (final coord in coords) {
|
||||
batch.insert('gps_coordinates', {
|
||||
'workout_id': workoutId,
|
||||
'latitude': coord.latitude,
|
||||
'longitude': coord.longitude,
|
||||
'altitude': coord.altitude,
|
||||
'accuracy': coord.accuracy,
|
||||
'speed': coord.speed,
|
||||
'heading': coord.heading,
|
||||
'timestamp': coord.timestamp.toIso8601String(),
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Retrieve all coordinates for a workout
|
||||
Future<List<GpsCoordinate>> getCoordinates(String workoutId) async {
|
||||
final rows = await _db.query(
|
||||
'gps_coordinates',
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workoutId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
return rows.map((r) => GpsCoordinate.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
/// Get coordinate count for a workout
|
||||
Future<int> getCoordinateCount(String workoutId) async {
|
||||
final result = await _db.rawQuery(
|
||||
'SELECT COUNT(*) as cnt FROM gps_coordinates WHERE workout_id = ?',
|
||||
[workoutId],
|
||||
);
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Delete coordinates for a workout (after successful sync)
|
||||
Future<void> deleteCoordinates(String workoutId) async {
|
||||
await _db.delete(
|
||||
'gps_coordinates',
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workoutId],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Workouts CRUD ────────────────────────────────────────────
|
||||
|
||||
/// Save a completed workout
|
||||
Future<void> saveWorkout(Workout workout) async {
|
||||
await _db.insert(
|
||||
'workouts',
|
||||
workout.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update workout fields
|
||||
Future<void> updateWorkout(Workout workout) async {
|
||||
await _db.update(
|
||||
'workouts',
|
||||
workout.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [workout.id],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a single workout by ID
|
||||
Future<Workout?> getWorkout(String id) async {
|
||||
final rows = await _db.query(
|
||||
'workouts',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return Workout.fromMap(rows.first);
|
||||
}
|
||||
|
||||
/// Get all workouts ordered by date
|
||||
Future<List<Workout>> getAllWorkouts({int? limit, int? offset}) async {
|
||||
final rows = await _db.query(
|
||||
'workouts',
|
||||
orderBy: 'created_at DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map((r) => Workout.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
/// Get workouts by sync status
|
||||
Future<List<Workout>> getWorkoutsBySync(SyncStatus status) async {
|
||||
final rows = await _db.query(
|
||||
'workouts',
|
||||
where: 'sync_status = ?',
|
||||
whereArgs: [status.name],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows.map((r) => Workout.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
/// Update sync status for a workout
|
||||
Future<void> updateSyncStatus(
|
||||
String workoutId,
|
||||
SyncStatus status, {
|
||||
DateTime? syncedAt,
|
||||
}) async {
|
||||
final updates = <String, dynamic>{
|
||||
'sync_status': status.name,
|
||||
};
|
||||
if (syncedAt != null) {
|
||||
updates['synced_at'] = syncedAt.toIso8601String();
|
||||
}
|
||||
await _db.update(
|
||||
'workouts',
|
||||
updates,
|
||||
where: 'id = ?',
|
||||
whereArgs: [workoutId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a workout and its related data
|
||||
Future<void> deleteWorkout(String id) async {
|
||||
await _db.transaction((txn) async {
|
||||
await txn
|
||||
.delete('workout_segments', where: 'workout_id = ?', whereArgs: [id]);
|
||||
await txn
|
||||
.delete('gps_coordinates', where: 'workout_id = ?', whereArgs: [id]);
|
||||
await txn.delete('sync_queue', where: 'workout_id = ?', whereArgs: [id]);
|
||||
await txn.delete('workouts', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get total workout count
|
||||
Future<int> getWorkoutCount() async {
|
||||
final result = await _db.rawQuery('SELECT COUNT(*) as cnt FROM workouts');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
// ─── Workout Segments ─────────────────────────────────────────
|
||||
|
||||
/// Save segments for a workout
|
||||
Future<void> saveSegments(
|
||||
String workoutId,
|
||||
List<WorkoutSegment> segments,
|
||||
) async {
|
||||
// Delete existing segments first
|
||||
await _db.delete(
|
||||
'workout_segments',
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workoutId],
|
||||
);
|
||||
|
||||
final batch = _db.batch();
|
||||
for (final seg in segments) {
|
||||
final map = seg.toMap();
|
||||
map['workout_id'] = workoutId;
|
||||
batch.insert('workout_segments', map);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get segments for a workout
|
||||
Future<List<WorkoutSegment>> getSegments(String workoutId) async {
|
||||
final rows = await _db.query(
|
||||
'workout_segments',
|
||||
where: 'workout_id = ?',
|
||||
whereArgs: [workoutId],
|
||||
orderBy: 'segment_index ASC',
|
||||
);
|
||||
return rows.map((r) => WorkoutSegment.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
// ─── Sync Queue ───────────────────────────────────────────────
|
||||
|
||||
/// Add workout to sync queue
|
||||
Future<void> enqueueSync(String workoutId, String payload) async {
|
||||
await _db.insert('sync_queue', {
|
||||
'workout_id': workoutId,
|
||||
'payload': payload,
|
||||
'status': 'queued',
|
||||
});
|
||||
}
|
||||
|
||||
/// Get all queued items
|
||||
Future<List<Map<String, dynamic>>> getPendingSyncs() async {
|
||||
return await _db.query(
|
||||
'sync_queue',
|
||||
where: "status IN ('queued', 'failed') AND retry_count < 5",
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
}
|
||||
|
||||
/// Update sync queue item status
|
||||
Future<void> updateSyncQueueItem(
|
||||
int id,
|
||||
String status, {
|
||||
String? errorMessage,
|
||||
}) async {
|
||||
int newRetryCount = 0;
|
||||
|
||||
// سحبنا المنطق خارج الـ Map ليكون واضحاً ومقروءاً
|
||||
if (status == 'failed') {
|
||||
final currentItem =
|
||||
await _db.query('sync_queue', where: 'id = ?', whereArgs: [id]);
|
||||
if (currentItem.isNotEmpty) {
|
||||
newRetryCount = (currentItem.first['retry_count'] as int? ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'sync_queue',
|
||||
{
|
||||
'status': status,
|
||||
'last_attempt': DateTime.now().toIso8601String(),
|
||||
'retry_count': newRetryCount, // وضعنا القيمة الجاهزة هنا
|
||||
if (errorMessage != null) 'error_message': errorMessage,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove sent items from queue
|
||||
Future<void> removeSentFromQueue() async {
|
||||
await _db.delete('sync_queue', where: "status = 'sent'");
|
||||
}
|
||||
|
||||
// ─── Statistics Queries ───────────────────────────────────────
|
||||
|
||||
/// Get aggregate statistics
|
||||
Future<Map<String, dynamic>> getAggregateStats() async {
|
||||
final result = await _db.rawQuery('''
|
||||
SELECT
|
||||
COUNT(*) as total_workouts,
|
||||
COALESCE(SUM(distance_meters), 0) as total_distance,
|
||||
COALESCE(SUM(duration_seconds), 0) as total_duration,
|
||||
COALESCE(SUM(calories), 0) as total_calories,
|
||||
COALESCE(AVG(avg_speed_mps), 0) as avg_speed,
|
||||
COALESCE(MAX(max_speed_mps), 0) as top_speed,
|
||||
COALESCE(SUM(elevation_gain), 0) as total_elevation_gain
|
||||
FROM workouts
|
||||
''');
|
||||
return result.first;
|
||||
}
|
||||
|
||||
/// Get workouts for a date range (for weekly/monthly reports)
|
||||
Future<List<Workout>> getWorkoutsInRange(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
) async {
|
||||
final rows = await _db.query(
|
||||
'workouts',
|
||||
where: 'created_at >= ? AND created_at <= ?',
|
||||
whereArgs: [start.toIso8601String(), end.toIso8601String()],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows.map((r) => Workout.fromMap(r)).toList();
|
||||
}
|
||||
|
||||
/// Get weekly summary
|
||||
Future<List<Map<String, dynamic>>> getWeeklySummary(int weeks) async {
|
||||
return await _db.rawQuery('''
|
||||
SELECT
|
||||
strftime('%Y-%W', created_at) as week,
|
||||
COUNT(*) as count,
|
||||
SUM(distance_meters) as distance,
|
||||
SUM(duration_seconds) as duration,
|
||||
AVG(avg_speed_mps) as avg_speed
|
||||
FROM workouts
|
||||
WHERE created_at >= datetime('now', '-$weeks weeks')
|
||||
GROUP BY week
|
||||
ORDER BY week DESC
|
||||
''');
|
||||
}
|
||||
|
||||
/// Get best time of day for performance
|
||||
Future<Map<String, dynamic>?> getBestTimeOfDay() async {
|
||||
final result = await _db.rawQuery('''
|
||||
SELECT
|
||||
CASE
|
||||
WHEN CAST(strftime('%H', start_time) AS INTEGER) < 6 THEN 'night'
|
||||
WHEN CAST(strftime('%H', start_time) AS INTEGER) < 12 THEN 'morning'
|
||||
WHEN CAST(strftime('%H', start_time) AS INTEGER) < 18 THEN 'afternoon'
|
||||
ELSE 'evening'
|
||||
END as time_period,
|
||||
AVG(avg_speed_mps) as avg_speed,
|
||||
COUNT(*) as count
|
||||
FROM workouts
|
||||
WHERE start_time IS NOT NULL
|
||||
GROUP BY time_period
|
||||
HAVING count >= 2
|
||||
ORDER BY avg_speed DESC
|
||||
LIMIT 1
|
||||
''');
|
||||
return result.isNotEmpty ? result.first : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_db.close();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/gps_coordinate.dart';
|
||||
|
||||
/// خدمة GPS الحقيقي مع دعم التتبع المستمر
|
||||
class GpsService extends GetxService {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
final _isTracking = false.obs;
|
||||
final _currentPosition = Rxn<Position>();
|
||||
final _lastError = ''.obs;
|
||||
|
||||
bool get isTracking => _isTracking.value;
|
||||
Position? get currentPosition => _currentPosition.value;
|
||||
String get lastError => _lastError.value;
|
||||
|
||||
Function(GpsCoordinate)? onNewCoordinate;
|
||||
|
||||
Future<bool> checkAndRequestPermissions() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
_lastError.value = 'Location services disabled';
|
||||
return false;
|
||||
}
|
||||
var perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
_lastError.value = 'Permission denied';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (perm == LocationPermission.deniedForever) {
|
||||
_lastError.value = 'Permission permanently denied';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<GpsCoordinate?> getCurrentPosition() async {
|
||||
try {
|
||||
if (!await checkAndRequestPermissions()) return null;
|
||||
final pos = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
timeLimit: const Duration(seconds: 15),
|
||||
);
|
||||
_currentPosition.value = pos;
|
||||
return _toCoord(pos);
|
||||
} catch (e) {
|
||||
_lastError.value = e.toString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> startTracking({int distanceFilter = 5}) async {
|
||||
if (_isTracking.value) return true;
|
||||
if (!await checkAndRequestPermissions()) return false;
|
||||
|
||||
try {
|
||||
late LocationSettings settings;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
settings = AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
intervalDuration: const Duration(seconds: 3),
|
||||
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
||||
notificationText: 'SportPath is tracking your workout',
|
||||
notificationTitle: 'Workout Active',
|
||||
enableWakeLock: true,
|
||||
),
|
||||
);
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
settings = AppleSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
activityType: ActivityType.fitness,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
);
|
||||
} else {
|
||||
settings = LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: settings,
|
||||
).listen(_onPosition, onError: (e) {
|
||||
_lastError.value = e.toString();
|
||||
});
|
||||
|
||||
_isTracking.value = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
_lastError.value = e.toString();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopTracking() async {
|
||||
await _positionSub?.cancel();
|
||||
_positionSub = null;
|
||||
_isTracking.value = false;
|
||||
}
|
||||
|
||||
void _onPosition(Position pos) {
|
||||
_currentPosition.value = pos;
|
||||
final coord = _toCoord(pos);
|
||||
if (coord.isAccurate && coord.isValid) {
|
||||
onNewCoordinate?.call(coord);
|
||||
}
|
||||
}
|
||||
|
||||
GpsCoordinate _toCoord(Position p) => GpsCoordinate(
|
||||
latitude: p.latitude,
|
||||
longitude: p.longitude,
|
||||
altitude: p.altitude,
|
||||
accuracy: p.accuracy,
|
||||
speed: p.speed >= 0 ? p.speed : null,
|
||||
heading: p.heading >= 0 ? p.heading : null,
|
||||
timestamp: p.timestamp,
|
||||
);
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
stopTracking();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/workout.dart';
|
||||
import '../utils/hmac_utility.dart';
|
||||
import 'database_service.dart';
|
||||
|
||||
/// محرك المزامنة التلقائي
|
||||
/// يراقب الاتصال بالإنترنت ويرسل التمارين المحفوظة محلياً عند توفر الشبكة
|
||||
class SyncService extends GetxService {
|
||||
final DatabaseService _db = Get.find<DatabaseService>();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final _connectivity = Connectivity();
|
||||
|
||||
StreamSubscription? _connectivitySub;
|
||||
Timer? _syncTimer;
|
||||
|
||||
final _isSyncing = false.obs;
|
||||
final _isOnline = false.obs;
|
||||
final _pendingCount = 0.obs;
|
||||
|
||||
bool get isSyncing => _isSyncing.value;
|
||||
bool get isOnline => _isOnline.value;
|
||||
int get pendingCount => _pendingCount.value;
|
||||
|
||||
static const String _apiEndpointKey = 'api_endpoint';
|
||||
static const String _apiKeyKey = 'api_key';
|
||||
static const String _apiSecretKey = 'api_secret';
|
||||
|
||||
@override
|
||||
Future<void> onInit() async {
|
||||
super.onInit();
|
||||
await _checkConnectivity();
|
||||
_startConnectivityMonitor();
|
||||
_startPeriodicSync();
|
||||
await _updatePendingCount();
|
||||
}
|
||||
|
||||
// ─── Connectivity Monitoring ──────────────────────────────────
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_isOnline.value = !results.contains(ConnectivityResult.none);
|
||||
}
|
||||
|
||||
void _startConnectivityMonitor() {
|
||||
_connectivitySub = _connectivity.onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) {
|
||||
final wasOffline = !_isOnline.value;
|
||||
_isOnline.value = !results.contains(ConnectivityResult.none);
|
||||
|
||||
// Came back online → trigger sync
|
||||
if (wasOffline && _isOnline.value) {
|
||||
debugPrint('[SYNC] Back online — triggering sync');
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startPeriodicSync() {
|
||||
// Check for pending syncs every 2 minutes
|
||||
_syncTimer = Timer.periodic(
|
||||
const Duration(minutes: 2),
|
||||
(_) {
|
||||
if (_isOnline.value && !_isSyncing.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Credentials Management ───────────────────────────────────
|
||||
|
||||
Future<void> saveCredentials({
|
||||
required String apiEndpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
await _storage.write(key: _apiEndpointKey, value: apiEndpoint);
|
||||
await _storage.write(key: _apiKeyKey, value: apiKey);
|
||||
await _storage.write(key: _apiSecretKey, value: apiSecret);
|
||||
}
|
||||
|
||||
Future<Map<String, String?>> getCredentials() async {
|
||||
return {
|
||||
'endpoint': await _storage.read(key: _apiEndpointKey),
|
||||
'apiKey': await _storage.read(key: _apiKeyKey),
|
||||
'apiSecret': await _storage.read(key: _apiSecretKey),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> hasCredentials() async {
|
||||
final creds = await getCredentials();
|
||||
return creds['apiKey'] != null &&
|
||||
creds['apiKey']!.isNotEmpty &&
|
||||
creds['apiSecret'] != null &&
|
||||
creds['apiSecret']!.isNotEmpty;
|
||||
}
|
||||
|
||||
// ─── Sync Operations ─────────────────────────────────────────
|
||||
|
||||
/// Queue a workout for sync
|
||||
Future<void> queueWorkout(Workout workout) async {
|
||||
final payload = jsonEncode(workout.toApiPayload());
|
||||
await _db.enqueueSync(workout.id, payload);
|
||||
await _updatePendingCount();
|
||||
|
||||
// Try immediate sync if online
|
||||
if (_isOnline.value) {
|
||||
syncPendingWorkouts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all pending sync items
|
||||
Future<void> syncPendingWorkouts() async {
|
||||
if (_isSyncing.value) return;
|
||||
_isSyncing.value = true;
|
||||
|
||||
try {
|
||||
final creds = await getCredentials();
|
||||
if (creds['endpoint'] == null || creds['apiKey'] == null ||
|
||||
creds['apiSecret'] == null) {
|
||||
debugPrint('[SYNC] No API credentials configured');
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingItems = await _db.getPendingSyncs();
|
||||
if (pendingItems.isEmpty) return;
|
||||
|
||||
debugPrint('[SYNC] Processing ${pendingItems.length} pending items');
|
||||
|
||||
for (final item in pendingItems) {
|
||||
await _syncSingleItem(
|
||||
id: item['id'] as int,
|
||||
workoutId: item['workout_id'] as String,
|
||||
payload: item['payload'] as String,
|
||||
endpoint: creds['endpoint']!,
|
||||
apiKey: creds['apiKey']!,
|
||||
apiSecret: creds['apiSecret']!,
|
||||
);
|
||||
}
|
||||
|
||||
await _db.removeSentFromQueue();
|
||||
await _updatePendingCount();
|
||||
} catch (e) {
|
||||
debugPrint('[SYNC] Error during sync: $e');
|
||||
} finally {
|
||||
_isSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSingleItem({
|
||||
required int id,
|
||||
required String workoutId,
|
||||
required String payload,
|
||||
required String endpoint,
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
}) async {
|
||||
try {
|
||||
final headers = HmacUtility.buildHeaders(
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$endpoint/api/v1/workouts'),
|
||||
headers: headers,
|
||||
body: payload,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
await _db.updateSyncQueueItem(id, 'sent');
|
||||
await _db.updateSyncStatus(
|
||||
workoutId,
|
||||
SyncStatus.synced,
|
||||
syncedAt: DateTime.now(),
|
||||
);
|
||||
debugPrint('[SYNC] ✅ Workout $workoutId synced');
|
||||
} else {
|
||||
await _db.updateSyncQueueItem(
|
||||
id,
|
||||
'failed',
|
||||
errorMessage: 'HTTP ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
debugPrint('[SYNC] ❌ Failed: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
await _db.updateSyncQueueItem(id, 'failed', errorMessage: e.toString());
|
||||
debugPrint('[SYNC] ❌ Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updatePendingCount() async {
|
||||
final items = await _db.getPendingSyncs();
|
||||
_pendingCount.value = items.length;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_connectivitySub?.cancel();
|
||||
_syncTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/// أدوات تنسيق الوقت والمسافة والسرعة
|
||||
class Formatters {
|
||||
/// Format seconds to HH:MM:SS or MM:SS
|
||||
static String time(int seconds) {
|
||||
int h = seconds ~/ 3600;
|
||||
int m = (seconds % 3600) ~/ 60;
|
||||
int s = seconds % 60;
|
||||
String pad(int n) => n.toString().padLeft(2, '0');
|
||||
if (h > 0) return '${pad(h)}:${pad(m)}:${pad(s)}';
|
||||
return '${pad(m)}:${pad(s)}';
|
||||
}
|
||||
|
||||
/// Format meters to km or m
|
||||
static String distance(double meters) {
|
||||
if (meters >= 1000) {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
}
|
||||
|
||||
/// Format distance short (e.g., "5.2")
|
||||
static String distanceKm(double meters) {
|
||||
return (meters / 1000).toStringAsFixed(2);
|
||||
}
|
||||
|
||||
/// Format speed (m/s) to pace (min/km)
|
||||
static String pace(double mps) {
|
||||
if (mps <= 0) return '--:--';
|
||||
double kmh = mps * 3.6;
|
||||
if (kmh <= 0) return '--:--';
|
||||
double paceMinKm = 60.0 / kmh;
|
||||
int min = paceMinKm.toInt();
|
||||
int sec = ((paceMinKm - min) * 60).toInt();
|
||||
return '${min.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Format pace with unit
|
||||
static String paceWithUnit(double mps) {
|
||||
return '${pace(mps)} min/km';
|
||||
}
|
||||
|
||||
/// Format speed m/s to km/h
|
||||
static String speedKmh(double mps) {
|
||||
return '${(mps * 3.6).toStringAsFixed(1)} km/h';
|
||||
}
|
||||
|
||||
/// Format calories
|
||||
static String calories(double cal) {
|
||||
return '${cal.toStringAsFixed(0)} kcal';
|
||||
}
|
||||
|
||||
/// Format elevation
|
||||
static String elevation(double meters) {
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
}
|
||||
|
||||
/// Format date
|
||||
static String date(DateTime dt) {
|
||||
final months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
|
||||
}
|
||||
|
||||
/// Format time of day
|
||||
static String timeOfDay(DateTime dt) {
|
||||
String pad(int n) => n.toString().padLeft(2, '0');
|
||||
return '${pad(dt.hour)}:${pad(dt.minute)}';
|
||||
}
|
||||
|
||||
/// Format date and time
|
||||
static String dateTime(DateTime dt) {
|
||||
return '${date(dt)} ${timeOfDay(dt)}';
|
||||
}
|
||||
|
||||
/// Format duration in human readable form
|
||||
static String durationHuman(int seconds) {
|
||||
if (seconds < 60) return '${seconds}s';
|
||||
if (seconds < 3600) return '${seconds ~/ 60}m ${seconds % 60}s';
|
||||
int h = seconds ~/ 3600;
|
||||
int m = (seconds % 3600) ~/ 60;
|
||||
return '${h}h ${m}m';
|
||||
}
|
||||
|
||||
/// Get workout type display name
|
||||
static String workoutType(String type) {
|
||||
switch (type) {
|
||||
case 'running':
|
||||
return '🏃 Running';
|
||||
case 'walking':
|
||||
return '🚶 Walking';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format improvement percentage
|
||||
static String improvement(double percent) {
|
||||
String sign = percent >= 0 ? '+' : '';
|
||||
return '$sign${percent.toStringAsFixed(1)}%';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// أدوات حساب المسافات والاتجاهات الجغرافية
|
||||
class GeoUtils {
|
||||
static const double _earthRadiusMeters = 6371000;
|
||||
|
||||
/// Haversine formula - حساب المسافة بين نقطتين
|
||||
static double haversineDistance(
|
||||
double lat1, double lng1,
|
||||
double lat2, double lng2,
|
||||
) {
|
||||
final dLat = _toRad(lat2 - lat1);
|
||||
final dLng = _toRad(lng2 - lng1);
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(_toRad(lat1)) * cos(_toRad(lat2)) *
|
||||
sin(dLng / 2) * sin(dLng / 2);
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return _earthRadiusMeters * c;
|
||||
}
|
||||
|
||||
/// Calculate bearing between two points (degrees from north)
|
||||
static double bearing(
|
||||
double lat1, double lng1,
|
||||
double lat2, double lng2,
|
||||
) {
|
||||
final dLng = _toRad(lng2 - lng1);
|
||||
final y = sin(dLng) * cos(_toRad(lat2));
|
||||
final x = cos(_toRad(lat1)) * sin(_toRad(lat2)) -
|
||||
sin(_toRad(lat1)) * cos(_toRad(lat2)) * cos(dLng);
|
||||
return (_toDeg(atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
/// Calculate speed between two points (m/s)
|
||||
static double speed(
|
||||
double lat1, double lng1, DateTime t1,
|
||||
double lat2, double lng2, DateTime t2,
|
||||
) {
|
||||
final dist = haversineDistance(lat1, lng1, lat2, lng2);
|
||||
final seconds = t2.difference(t1).inMilliseconds / 1000.0;
|
||||
if (seconds <= 0) return 0;
|
||||
return dist / seconds;
|
||||
}
|
||||
|
||||
static double _toRad(double deg) => deg * pi / 180;
|
||||
static double _toDeg(double rad) => rad * 180 / pi;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// أداة توليد توقيع HMAC-SHA256 للمصادقة مع الباك إند
|
||||
class HmacUtility {
|
||||
/// Generate HMAC-SHA256 signature
|
||||
/// message = timestamp|api_key|payload
|
||||
static String generateSignature({
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
required String payload,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final message = '$timestamp|$apiKey|$payload';
|
||||
final hmac = Hmac(sha256, utf8.encode(apiSecret));
|
||||
final digest = hmac.convert(utf8.encode(message));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
/// Build authenticated headers for API requests
|
||||
static Map<String, String> buildHeaders({
|
||||
required String apiKey,
|
||||
required String apiSecret,
|
||||
required String payload,
|
||||
}) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final signature = generateSignature(
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
payload: payload,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
'X-Signature': signature,
|
||||
'X-Timestamp': timestamp.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../models/gps_coordinate.dart';
|
||||
|
||||
/// أداة ترميز وفك ترميز Polyline
|
||||
/// تستخدم خوارزمية Google Encoded Polyline لضغط المسارات بنسبة ~95%
|
||||
class PolylineUtility {
|
||||
/// Encode a list of GPS coordinates to a polyline string
|
||||
static String encode(List<GpsCoordinate> coordinates) {
|
||||
if (coordinates.isEmpty) return '';
|
||||
|
||||
final buffer = StringBuffer();
|
||||
int prevLat = 0;
|
||||
int prevLng = 0;
|
||||
|
||||
for (final point in coordinates) {
|
||||
int lat = (point.latitude * 1e5).round();
|
||||
int lng = (point.longitude * 1e5).round();
|
||||
|
||||
_encodeValue(lat - prevLat, buffer);
|
||||
_encodeValue(lng - prevLng, buffer);
|
||||
|
||||
prevLat = lat;
|
||||
prevLng = lng;
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Decode a polyline string back to coordinates
|
||||
static List<GpsCoordinate> decode(String encoded) {
|
||||
if (encoded.isEmpty) return [];
|
||||
|
||||
final coordinates = <GpsCoordinate>[];
|
||||
int index = 0;
|
||||
int lat = 0;
|
||||
int lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
lat += _decodeValue(encoded, index);
|
||||
index = _lastIndex;
|
||||
|
||||
if (index >= encoded.length) break;
|
||||
|
||||
lng += _decodeValue(encoded, index);
|
||||
index = _lastIndex;
|
||||
|
||||
coordinates.add(GpsCoordinate(
|
||||
latitude: lat / 1e5,
|
||||
longitude: lng / 1e5,
|
||||
timestamp: DateTime.now(), // Polylines don't store timestamps
|
||||
));
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
// Internal tracking for decode position
|
||||
static int _lastIndex = 0;
|
||||
|
||||
static void _encodeValue(int value, StringBuffer buffer) {
|
||||
int v = value < 0 ? ~(value << 1) : (value << 1);
|
||||
while (v >= 0x20) {
|
||||
buffer.writeCharCode((0x20 | (v & 0x1f)) + 63);
|
||||
v >>= 5;
|
||||
}
|
||||
buffer.writeCharCode(v + 63);
|
||||
}
|
||||
|
||||
static int _decodeValue(String encoded, int startIndex) {
|
||||
int result = 0;
|
||||
int shift = 0;
|
||||
int index = startIndex;
|
||||
|
||||
while (index < encoded.length) {
|
||||
int byte = encoded.codeUnitAt(index) - 63;
|
||||
index++;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
if (byte < 0x20) break;
|
||||
}
|
||||
|
||||
_lastIndex = index;
|
||||
return (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user