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