Files
fitness/mobile/workout_controller.dart
2026-10-04 00:19:45 +03:00

573 lines
16 KiB
Dart

import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
/// Model Classes
class GpsCoordinate {
final double latitude;
final double longitude;
final double? altitude;
final double? accuracy;
final DateTime timestamp;
GpsCoordinate({
required this.latitude,
required this.longitude,
this.altitude,
this.accuracy,
required this.timestamp,
});
Map<String, dynamic> toMap() {
return {
'latitude': latitude,
'longitude': longitude,
'altitude': altitude,
'accuracy': accuracy,
'timestamp': timestamp.toIso8601String(),
};
}
factory GpsCoordinate.fromMap(Map<String, dynamic> map) {
return GpsCoordinate(
latitude: map['latitude'] as double,
longitude: map['longitude'] as double,
altitude: map['altitude'] as double?,
accuracy: map['accuracy'] as double?,
timestamp: DateTime.parse(map['timestamp'] as String),
);
}
}
class WorkoutSession {
final String id;
String workoutType; // 'running' or 'walking'
DateTime? startTime;
DateTime? endTime;
List<GpsCoordinate> coordinates;
bool isPaused;
int totalPausedDuration; // in milliseconds
WorkoutSession({
required this.id,
required this.workoutType,
this.startTime,
this.endTime,
List<GpsCoordinate>? coordinates,
this.isPaused = false,
this.totalPausedDuration = 0,
}) : coordinates = coordinates ?? [];
int get duration {
if (startTime == null || endTime == null) return 0;
int activeDuration = endTime!.difference(startTime!).inSeconds -
(totalPausedDuration ~/ 1000);
return activeDuration > 0 ? activeDuration : 0;
}
double getDistance() {
double totalDistance = 0.0;
for (int i = 0; i < coordinates.length - 1; i++) {
totalDistance += _haversineDistance(
coordinates[i].latitude,
coordinates[i].longitude,
coordinates[i + 1].latitude,
coordinates[i + 1].longitude,
);
}
return totalDistance;
}
static double _haversineDistance(
double lat1, double lng1, double lat2, double lng2) {
const R = 6371000; // Earth's radius in meters
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 R * c;
}
static double _toRad(double degree) => degree * pi / 180;
Map<String, dynamic> toMap() {
return {
'id': id,
'workoutType': workoutType,
'startTime': startTime?.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'coordinates': jsonEncode(coordinates.map((c) => c.toMap()).toList()),
'isPaused': isPaused ? 1 : 0,
'totalPausedDuration': totalPausedDuration,
};
}
}
/// Location Service - Handles background GPS tracking
class LocationService extends GetxService {
late Database _db;
final _dbPath = 'fitness_tracking.db';
final tableName = 'gps_coordinates';
@override
Future<void> onInit() async {
super.onInit();
await _initializeDatabase();
}
Future<void> _initializeDatabase() async {
try {
final dbPath = join(await getDatabasesPath(), _dbPath);
_db = await openDatabase(
dbPath,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $tableName (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workout_session_id TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
accuracy REAL,
timestamp TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// Create index for faster queries
await db.execute('''
CREATE INDEX idx_workout_session_id
ON $tableName(workout_session_id)
''');
},
);
} catch (e) {
debugPrint('Database initialization error: $e');
rethrow;
}
}
Future<void> saveCoordinate(
String workoutSessionId, GpsCoordinate coordinate) async {
try {
await _db.insert(
tableName,
{
'workout_session_id': workoutSessionId,
'latitude': coordinate.latitude,
'longitude': coordinate.longitude,
'altitude': coordinate.altitude,
'accuracy': coordinate.accuracy,
'timestamp': coordinate.timestamp.toIso8601String(),
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('Error saving coordinate: $e');
}
}
Future<List<GpsCoordinate>> getCoordinates(String workoutSessionId) async {
try {
final result = await _db.query(
tableName,
where: 'workout_session_id = ?',
whereArgs: [workoutSessionId],
orderBy: 'timestamp ASC',
);
return result
.map((map) => GpsCoordinate(
latitude: map['latitude'] as double,
longitude: map['longitude'] as double,
altitude: map['altitude'] as double?,
accuracy: map['accuracy'] as double?,
timestamp: DateTime.parse(map['timestamp'] as String),
))
.toList();
} catch (e) {
debugPrint('Error retrieving coordinates: $e');
return [];
}
}
Future<void> clearCoordinates(String workoutSessionId) async {
try {
await _db.delete(
tableName,
where: 'workout_session_id = ?',
whereArgs: [workoutSessionId],
);
} catch (e) {
debugPrint('Error clearing coordinates: $e');
}
}
Future<int> getCoordinateCount(String workoutSessionId) async {
try {
final result = await _db.rawQuery(
'SELECT COUNT(*) as count FROM $tableName WHERE workout_session_id = ?',
[workoutSessionId],
);
return Sqflite.firstIntValue(result) ?? 0;
} catch (e) {
debugPrint('Error getting coordinate count: $e');
return 0;
}
}
@override
void onClose() {
_db.close();
super.onClose();
}
}
/// Polyline Encoding/Decoding Utility
class PolylineUtility {
static String encodePolyline(List<GpsCoordinate> coordinates) {
String encoded = '';
List<int> previous = [0, 0];
for (var point in coordinates) {
List<int> current = [
(point.latitude * 1e5).toInt(),
(point.longitude * 1e5).toInt(),
];
for (int j = 0; j < 2; j++) {
int curr = current[j];
int prev = previous[j];
int value = curr - prev;
value = value < 0 ? ~(value << 1) : (value << 1);
while (value >= 0x20) {
int chunk = (0x20 | (value & 0x1f)) + 63;
encoded += String.fromCharCode(chunk);
value >>= 5;
}
encoded += String.fromCharCode(value + 63);
previous[j] = current[j];
}
}
return encoded;
}
}
/// HMAC Signature Generation
class HmacSignatureGenerator {
static String generateSignature(
String apiKey,
String apiSecret,
String payload,
int timestamp,
) {
final message = '$timestamp|$apiKey|$payload';
final hmac = Hmac(sha256, utf8.encode(apiSecret));
final digest = hmac.convert(utf8.encode(message));
return digest.toString();
}
}
/// Main Workout Controller
class WorkoutController extends GetxController {
final LocationService locationService = Get.put(LocationService());
late WorkoutSession _currentWorkout;
final _isTracking = false.obs;
final _isPaused = false.obs;
final _elapsedTime = 0.obs;
final _coordinates = <GpsCoordinate>[].obs;
final _distance = 0.0.obs;
final _currentSpeed = 0.0.obs;
final _maxSpeed = 0.0.obs;
final _isSubmitting = false.obs;
final _apiKey = ''.obs;
final _apiSecret = ''.obs;
// Observables
bool get isTracking => _isTracking.value;
bool get isPaused => _isPaused.value;
int get elapsedTime => _elapsedTime.value;
List<GpsCoordinate> get coordinates => _coordinates;
double get distance => _distance.value;
double get currentSpeed => _currentSpeed.value;
double get maxSpeed => _maxSpeed.value;
bool get isSubmitting => _isSubmitting.value;
WorkoutSession get currentWorkout => _currentWorkout;
Timer? _timerTracking;
Timer? _timerUpdate;
@override
void onInit() {
super.onInit();
_initializeWorkout();
}
void _initializeWorkout() {
_currentWorkout = WorkoutSession(
id: DateTime.now().millisecondsSinceEpoch.toString(),
workoutType: 'running',
);
}
void setWorkoutType(String type) {
if (type == 'running' || type == 'walking') {
_currentWorkout.workoutType = type;
}
}
void setApiCredentials(String apiKey, String apiSecret) {
_apiKey.value = apiKey;
_apiSecret.value = apiSecret;
}
Future<void> startWorkout() async {
if (_isTracking.value) return;
_currentWorkout.startTime = DateTime.now();
_currentWorkout.isPaused = false;
_currentWorkout.totalPausedDuration = 0;
_isTracking.value = true;
_isPaused.value = false;
_coordinates.clear();
_distance.value = 0.0;
_elapsedTime.value = 0;
// Start elapsed time timer (updates every second)
_timerTracking = Timer.periodic(Duration(seconds: 1), (_) {
if (!_isPaused.value && _currentWorkout.startTime != null) {
final now = DateTime.now();
int activeDuration =
now.difference(_currentWorkout.startTime!).inSeconds -
(_currentWorkout.totalPausedDuration ~/ 1000);
_elapsedTime.value = activeDuration > 0 ? activeDuration : 0;
}
});
// Start position update timer (every 5 seconds or based on distance)
_timerUpdate = Timer.periodic(Duration(seconds: 5), (_) async {
if (!_isPaused.value) {
await _updateLocation();
}
});
}
Future<void> pauseWorkout() async {
if (!_isTracking.value || _isPaused.value) return;
_isPaused.value = true;
_currentWorkout.isPaused = true;
_currentWorkout.endTime = DateTime.now(); // temporary end for pause
}
Future<void> resumeWorkout() async {
if (!_isTracking.value || !_isPaused.value) return;
_isPaused.value = false;
_currentWorkout.isPaused = false;
// Add pause duration to total
if (_currentWorkout.endTime != null) {
_currentWorkout.totalPausedDuration +=
DateTime.now().difference(_currentWorkout.endTime!).inMilliseconds;
}
}
Future<void> stopWorkout() async {
if (!_isTracking.value) return;
_isTracking.value = false;
_isPaused.value = false;
_currentWorkout.endTime = DateTime.now();
// Cancel timers
_timerTracking?.cancel();
_timerUpdate?.cancel();
// Fetch all stored coordinates from SQLite
_coordinates.value =
await locationService.getCoordinates(_currentWorkout.id);
// Update distance calculation
_distance.value = _currentWorkout.getDistance();
// Prepare for submission
}
Future<void> _updateLocation() async {
// In production, get location from location_service package
// This is a placeholder for the actual GPS tracking
try {
// Simulated coordinate update
final coordinate = GpsCoordinate(
latitude: 37.7749 + (DateTime.now().microsecond % 1000) / 100000,
longitude: -122.4194 + (DateTime.now().microsecond % 1000) / 100000,
altitude: 50.0,
accuracy: 5.0,
timestamp: DateTime.now(),
);
await locationService.saveCoordinate(_currentWorkout.id, coordinate);
_coordinates.add(coordinate);
// Update distance
if (_coordinates.length > 1) {
_distance.value = _currentWorkout.getDistance();
}
// Update current speed (simplified)
if (_coordinates.length > 1) {
final lastCoord = _coordinates[_coordinates.length - 2];
final distance = _haversineDistance(
lastCoord.latitude,
lastCoord.longitude,
coordinate.latitude,
coordinate.longitude,
);
final timeDiff =
coordinate.timestamp.difference(lastCoord.timestamp).inSeconds;
if (timeDiff > 0) {
_currentSpeed.value = distance / timeDiff;
if (_currentSpeed.value > _maxSpeed.value) {
_maxSpeed.value = _currentSpeed.value;
}
}
}
} catch (e) {
debugPrint('Error updating location: $e');
}
}
static double _haversineDistance(
double lat1, double lng1, double lat2, double lng2) {
const R = 6371000; // Earth's radius in meters
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 R * c;
}
static double _toRad(double degree) => degree * pi / 180;
Future<Map<String, dynamic>> prepareWorkoutPayload() async {
final polyline = PolylineUtility.encodePolyline(_coordinates);
final distance = _distance.value.toInt();
final duration = _elapsedTime.value;
return {
'workout_type': _currentWorkout.workoutType,
'distance_meters': distance,
'duration_seconds': duration,
'elevation_gain_meters': 0, // TODO: Calculate from coordinates
'elevation_loss_meters': 0, // TODO: Calculate from coordinates
'calories_burned':
_estimateCalories(distance, duration, _currentWorkout.workoutType),
'max_speed_mps': _maxSpeed.value,
'route_polyline': polyline,
'start_time': _currentWorkout.startTime?.toUtc().toIso8601String() ?? '',
'end_time': _currentWorkout.endTime?.toUtc().toIso8601String() ?? '',
'weather_condition': 'sunny',
'temperature_celsius': 22.0,
'notes': '',
'is_public': false,
'segments': [],
};
}
double _estimateCalories(int distance, int duration, String workoutType) {
// Simplified calorie calculation
// For a 70kg person, approximately:
// Running: 100 calories per 1km
// Walking: 50 calories per 1km
double caloriesPerKm = workoutType == 'running' ? 100 : 50;
return (distance / 1000) * caloriesPerKm;
}
Future<bool> submitWorkout(String apiEndpoint) async {
if (_isSubmitting.value) return false;
_isSubmitting.value = true;
try {
final payload = await prepareWorkoutPayload();
final payloadJson = jsonEncode(payload);
// Save workout locally to SQLite
debugPrint('Saving workout locally: $payloadJson');
// Store the completed workout in local database
// In a real app, you might create a "completed_workouts" table
await locationService.saveCoordinate(
_currentWorkout.id,
GpsCoordinate(
latitude: 0,
longitude: 0,
timestamp: DateTime.now(),
),
);
// Simulate successful storage
await Future.delayed(Duration(seconds: 1));
// Initialize new workout session
_initializeWorkout();
return true;
} catch (e) {
debugPrint('Error saving workout: $e');
return false;
} finally {
_isSubmitting.value = false;
}
}
@override
void onClose() {
_timerTracking?.cancel();
_timerUpdate?.cancel();
super.onClose();
}
}
// Formatting Utilities
String formatTime(int seconds) {
int hours = seconds ~/ 3600;
int minutes = (seconds % 3600) ~/ 60;
int secs = seconds % 60;
String twoDigits(int n) => n.toString().padLeft(2, '0');
if (hours > 0) {
return '${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(secs)}';
}
return '${twoDigits(minutes)}:${twoDigits(secs)}';
}
String formatDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
return '${meters.toStringAsFixed(0)} m';
}
String formatPace(double metersPerSecond) {
if (metersPerSecond == 0) return '--:-- min/km';
double kmPerHour = metersPerSecond * 3.6;
double pacePerKm = 60 / kmPerHour;
int minutes = pacePerKm.toInt();
int seconds = ((pacePerKm - minutes) * 60).toInt();
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')} min/km';
}