573 lines
16 KiB
Dart
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';
|
|
}
|