first commit

This commit is contained in:
Hamza-Ayed
2026-10-04 00:19:45 +03:00
commit 05f1c9ec6b
93 changed files with 10782 additions and 0 deletions
@@ -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();
}
}
+56
View File
@@ -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(),
);
}
}
+66
View File
@@ -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)';
}
+265
View File
@@ -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(),
};
}
}
+90
View File
@@ -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,
};
}
}
+113
View File
@@ -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),
],
),
),
),
);
}
}
+250
View File
@@ -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)),
],
);
}
}
+235
View File
@@ -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)),
),
),
],
);
}
}
+119
View File
@@ -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 "استمر في التدريب! الاستمرارية هي مفتاح التطور.";
}
}
+482
View File
@@ -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();
}
}
+130
View File
@@ -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();
}
}
+213
View File
@@ -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();
}
}
+103
View File
@@ -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)}%';
}
}
+46
View File
@@ -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;
}
+41
View File
@@ -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(),
};
}
}
+84
View File
@@ -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);
}
}