first commit
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user