267 lines
8.1 KiB
Dart
267 lines
8.1 KiB
Dart
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 {
|
|
'client_workout_id': id,
|
|
'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(),
|
|
};
|
|
}
|
|
}
|