import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui'; import 'package:dio/dio.dart'; import 'package:floating_window_android/floating_window_android.dart'; import 'package:flutter/material.dart'; import 'package:flutter_background/flutter_background.dart'; import 'package:geolocator/geolocator.dart'; import 'package:libac_dart/nbt/NbtIo.dart'; import 'package:libac_dart/nbt/NbtUtils.dart'; import 'package:libac_dart/nbt/SnbtIo.dart'; import 'package:libac_dart/nbt/Stream.dart'; import 'package:libac_dart/nbt/impl/CompoundTag.dart'; import 'package:libac_dart/nbt/impl/DoubleTag.dart'; import 'package:libac_dart/nbt/impl/ListTag.dart'; import 'package:libac_dart/nbt/impl/StringTag.dart'; import 'package:libac_dart/utils/Converter.dart'; import 'package:libac_dart/utils/TimeUtils.dart'; import 'package:libacflutter/nbt/nbtHelpers.dart'; import 'package:timetrack/consts.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class SessionData { static DateTime StartTime = DateTime(0); static DateTime EndTime = DateTime(0); static bool IsOnTheClock = false; static List Trips = []; static Delivery? currentDelivery; static Trip? currentTrip; static List positions = []; static late StreamSubscription _listener; static Callbacks Calls = Callbacks(); static String LastSessionID = ""; static String DisplayError = ""; static double? TotalPay; static bool ContainsTripTimes = true; /// Is true if the try-catch is tripped or if not running on Android static bool isWeb = false; /// This flag is usually set when data is loaded from a saved state. Or when accessed using the Web version of the app. static bool IsReadOnly = false; static double GetTotalMiles() { double total = 0; total = _totalMilesTraveled( positions, minDistanceMeters: 5, maxDistanceMeters: 512, ); return total; } static int GetTotalDeliveries() { int total = 0; for (var trip in Trips) { total += trip.deliveries.length; } return total; } static String GetPaidHours() { return Duration2Notation(_GetPaidHours()); } static Duration _GetPaidHours() { Duration stamp = Duration(); for (var trip in Trips) { stamp += trip.EndTime.difference(trip.StartTime); } return stamp; } static String GetUnpaidHours() { // This is the inverted value of paid hours. We get total hours and subtract it from the paid hours. This gives us the unpaid hours. Time totalHoursWorked = Time.fromNotation(GetPaidHours()); Duration totalDuration = EndTime.difference(StartTime); Time totalTime = Time.fromDuration(totalDuration); Time unpaid = totalTime.copy(); unpaid.subtract(totalHoursWorked); return unpaid.toString(); } static String GetTotalMilesAsString() { double miles = GetTotalMiles(); if (miles == 0) return "0.0"; List split = miles.toString().split("."); StringBuilder out = StringBuilder(); out.append(split[0]); out.append("."); out.append(split[1].substring(0, 4)); return out.toString(); } //** Begin AI Generated code */ /// Converts meters → miles. static const _metersPerMile = 1609.344; /// Radius of the earth in meters (WGS‑84 mean radius). static const _earthRadiusM = 6371000.0; static double _haversineMeters( double lat1, double lon1, double lat2, double lon2, ) { final dLat = _deg2rad(lat2 - lat1); final dLon = _deg2rad(lon2 - lon1); final a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(_deg2rad(lat1)) * math.cos(_deg2rad(lat2)) * math.sin(dLon / 2) * math.sin(dLon / 2); return _earthRadiusM * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); } static double _deg2rad(double deg) => deg * math.pi / 180.0; /// Returns total miles traveled after basic GPS‑noise cleanup. /// /// * [minDistanceMeters] – drop segments shorter than this (jitter). /// * [maxDistanceMeters] – drop segments longer than this (impossible jump). static double _totalMilesTraveled( List positions, { double minDistanceMeters = 5, double? maxDistanceMeters, }) { if (positions.length < 2) return 0; var meters = 0.0; for (var i = 1; i < positions.length; i++) { final p1 = positions[i - 1]; final p2 = positions[i]; final d = _haversineMeters( p1.latitude, p1.longitude, p2.latitude, p2.longitude, ); if (d < minDistanceMeters) continue; // too small → jitter if (maxDistanceMeters != null && d > maxDistanceMeters) { continue; // glitch } meters += d; } return meters / _metersPerMile; } //** End AI Generated code */ static Future Login() async { final androidConfig = FlutterBackgroundAndroidConfig( notificationTitle: "Time Tracker", notificationText: "Background notification for keeping TimeTrack running in the background", notificationImportance: AndroidNotificationImportance.normal, ); bool success = await FlutterBackground.initialize( androidConfig: androidConfig, ); FlutterBackground.enableBackgroundExecution(); WakelockPlus.enable(); if (!success) { return false; } bool granted = await FloatingWindowAndroid.isPermissionGranted(); if (!granted) { success = await FloatingWindowAndroid.requestPermission(); if (!success) return false; } StartTime = DateTime.now(); IsOnTheClock = true; bool hasGPS; LocationPermission perm; hasGPS = await Geolocator.isLocationServiceEnabled(); if (!hasGPS) { IsOnTheClock = false; return await Future.error("Location services are disabled"); } perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); if (perm == LocationPermission.denied) { IsOnTheClock = false; return await Future.error("Location permissions are denied"); } } if (perm == LocationPermission.deniedForever) { IsOnTheClock = false; return await Future.error( "Location permissions are denied permanently. Login cannot proceed.", ); } _listener = Geolocator.getPositionStream( locationSettings: TTConsts.LOCATION_SETTINGS, ).listen((pos) { if (!IsOnTheClock) { _listener.cancel(); return; } positions.add(SmallPosition.fromPosition(pos)); SessionData.SaveCacheState(); SessionData.Calls.dispatch(); }); return true; } static Future Logout() async { IsOnTheClock = false; currentDelivery = null; currentTrip = null; _listener.cancel(); EndTime = DateTime.now(); FlutterBackground.disableBackgroundExecution(); WakelockPlus.disable(); var saveData = await _serializeToNBT(); ResetAppSession(); print(SnbtIo.writeToString(saveData)); Uint8List nbtData = await NbtIo.writeToStream(saveData); Trips = []; positions = []; _upload(nbtData); } static Future _upload(List nbtData) async { Dio dio = Dio(); Map payload = { "cmd": "create", "type": "nbt", "data": base64Encoder.encode(nbtData), }; try { var reply = await dio.post( TTConsts.SESSION_SERVER, data: json.encode(payload), ); if (reply.statusCode == null) { throw Exception("Fatal error while uploading"); } if (reply.statusCode! != 200) { throw Exception("Fatal error while uploading"); } Map replyJs = json.decode(reply.data as String); if (replyJs["status"] == "ok") { print("Successful upload"); LastSessionID = replyJs['session'] as String; Calls.dispatch(); } } catch (E) { // Retry in 2 seconds DisplayError = "Error: Something went wrong during upload. Retry in 5 seconds..."; Calls.dispatch(); Timer(Duration(seconds: 5), () { _upload(nbtData); }); } } static void ResetAppSession() { IsOnTheClock = false; StartTime = DateTime.fromMillisecondsSinceEpoch(0); Trips = []; currentDelivery = null; currentTrip = null; positions = []; EndTime = DateTime.fromMillisecondsSinceEpoch(0); LastSessionID = ""; DisplayError = ""; IsReadOnly = false; ContainsTripTimes = true; Calls.dispatch(); } /// This function attempts to load the saved state from cache. /// /// This will return true when a session exists; false when no session exists, or is empty. static Future LoadSavedCacheState() async { CompoundTag ct = await NBTHelper.GetNBT(name: "appstate"); // Restore various flags now. if (ct.isEmpty) { ResetAppSession(); return false; } await _deserialize(ct); return true; } /// Saves the current session based on various factors. static Future SaveCacheState() async { CompoundTag ct = IsOnTheClock ? await _serializeToNBT() : CompoundTag(); await NBTHelper.CommitNBT(data: ct, name: "appstate"); } static Future _deserialize(CompoundTag ct) async { IsOnTheClock = NbtUtils.readBoolean(ct, "inprog"); StartTime = DateTime.parse(ct.get("start")!.asString()); if (ct.containsKey("end")) { EndTime = DateTime.parse(ct.get("end")!.asString()); } else { EndTime = DateTime(0); } TotalPay = ct.get("totalPay")!.asDouble(); ListTag poses = ct.get("pos")! as ListTag; for (var pos in poses.value) { positions.add(await SmallPosition.fromNBT(pos.asCompoundTag())); } ListTag trips = ct.get("trips") as ListTag; for (var trip in trips.value) { Trips.add(await Trip.fromNBT(trip.asCompoundTag())); } if (ct.containsKey("current_trip")) { currentTrip = await Trip.fromNBT(ct.get("current_trip")!.asCompoundTag()); } if (ct.containsKey("current_delivery")) { currentDelivery = await Delivery.fromNBT( ct.get("current_delivery")!.asCompoundTag(), ); } } /// This private function will turn all the data into NBT, for both the cache state, and newer usage, for storing it on the server in a more compact format. static Future _serializeToNBT() async { CompoundTag ct = CompoundTag(); NbtUtils.writeBoolean(ct, "inprog", IsOnTheClock); // No need to write the contains trip times flag, it is set during deserialization. For inprog sessions, it will be set to true by the system. ct.put("start", StringTag.valueOf(StartTime.toIso8601String())); if (EndTime.year < 2000) { // We have a end time ct.put("end", StringTag.valueOf(EndTime.toIso8601String())); } ListTag posX = ListTag(); for (var pos in positions) { posX.add(await pos.toNBT()); } ct.put("pos", posX); ListTag myTrips = ListTag(); for (var trip in Trips) { myTrips.add(await trip.toNBT()); } ct.put("trips", myTrips); // This format supports saving current trip and current delivery. if (currentDelivery != null) { ct.put("current_delivery", await currentDelivery!.toNBT()); } if (currentTrip != null) { ct.put("current_trip", await currentTrip!.toNBT()); } if (TotalPay != null) { ct.put("totalPay", DoubleTag.valueOf(TotalPay!)); } return ct; } static Map SaveData() { Map saveData = {}; List> trips = []; for (var trip in Trips) { trips.add(trip.toJsonMap()); } List> posx = []; for (var pos in positions) { posx.add(pos.toMap()); } saveData["trips"] = trips; saveData["positions"] = posx; saveData["start"] = StartTime.toIso8601String(); saveData["end"] = EndTime.toIso8601String(); if (TotalPay != null) saveData["totalPay"] = TotalPay; return saveData; } static Future DownloadData() async { Dio dio = Dio(); Map payload = {"cmd": "get", "id": LastSessionID}; // Send the data, and get the response try { var reply = await dio.post( TTConsts.SESSION_SERVER, data: json.encode(payload), ); String cType = reply.headers.value("Content-Type") ?? "application/json"; if (cType == "application/json") { return LoadData(reply.data as Map); } else if (cType == "application/nbt") { print("Data is in NBT Format"); Uint8List lst = base64Encoder.decode(reply.data as String); // Convert this to a CompoundTag CompoundTag ct = await NbtIo.readFromStream(lst) as CompoundTag; _deserialize(ct); return true; } else return false; } catch (E, stack) { print(E); print(stack); return false; } } static bool LoadData(Map jsMap) { if (jsMap.containsKey("error")) { LastSessionID = ""; return false; } List trips = jsMap['trips'] as List; List pos = jsMap['positions'] as List; for (var trip in trips) { Trips.add(Trip.fromJsonMap(trip as Map)); } for (var position in pos) { positions.add(SmallPosition.fromMap(position as Map)); } if (jsMap.containsKey("start")) { StartTime = DateTime.parse(jsMap['start'] as String); } if (jsMap.containsKey("end")) { EndTime = DateTime.parse(jsMap["end"] as String); } if (jsMap.containsKey("totalPay")) { TotalPay = jsMap["totalPay"] as double; } else { TotalPay = null; } IsReadOnly = true; return true; } static Future GetNewLocation() async { Position pos = await Geolocator.getCurrentPosition( locationSettings: TTConsts.LOCATION_SETTINGS, ); return pos; } static Trip GetNewTrip() { if (currentTrip != null) { currentTrip!.EndTime = DateTime.now(); } currentTrip = Trip(); Trips.add(currentTrip!); return currentTrip!; } static Delivery GetNewDelivery() { if (currentTrip != null) { var dropOff = currentTrip!.startNewDelivery(); currentDelivery = dropOff; return dropOff; } else { throw Exception("A delivery cannot exist without a trip"); } } static void EndTrip() { if (currentTrip != null) { currentTrip!.EndTime = DateTime.now(); } currentDelivery = null; currentTrip = null; } /// [a] should be the Start Time, /// /// [b] is the end time static String GetTotalTimeWorked(DateTime a, DateTime b) { Duration diff = b.difference(a); return Duration2Notation(diff); } static String Duration2Notation(Duration time) { Time tm = Time.fromDuration(time); return tm.toString(); } } class Delivery { SmallPosition? endLocation; DateTime StartTime = DateTime.now(); Delivery() { StartTime = DateTime.now(); } Future MarkEndLocation() async { var pos = await SessionData.GetNewLocation(); endLocation = SmallPosition.fromPosition(pos); } Map toJsonMap() { return { "start": StartTime.toIso8601String(), "endPos": endLocation?.toMap() ?? "incomplete", }; } static Delivery fromMap(Map jsx) { Delivery delivery = Delivery(); delivery.StartTime = DateTime.parse(jsx['start'] as String); if (jsx['endPos'] is String) { delivery.endLocation = null; } else { delivery.endLocation = SmallPosition.fromMap( jsx['endPos'] as Map, ); } return delivery; } Future toNBT() async { CompoundTag ct = CompoundTag(); ct.put("start", StringTag.valueOf(StartTime.toIso8601String())); if (endLocation != null) { ct.put("endPos", await endLocation!.toNBT()); } return ct; } static Future fromNBT(CompoundTag ct) async { Delivery delivery = Delivery(); delivery.StartTime = DateTime.parse(ct.get("start")!.asString()); if (ct.containsKey("endPos")) { delivery.endLocation = await SmallPosition.fromNBT( ct.get("endPos") as CompoundTag, ); } return delivery; } } class Trip { List deliveries = []; DateTime StartTime = DateTime(0); DateTime EndTime = DateTime(0); Trip() { StartTime = DateTime.now(); } Delivery startNewDelivery() { var delivery = Delivery(); deliveries.add(delivery); return delivery; } Map toJsonMap() { Map trip = { "start": StartTime.toIso8601String(), "end": EndTime.toIso8601String(), }; List> dropOffs = []; for (var delivery in deliveries) { dropOffs.add(delivery.toJsonMap()); } trip["deliveries"] = dropOffs; return trip; } static Trip fromJsonMap(Map jsx) { Trip trip = Trip(); trip.StartTime = DateTime.parse(jsx['start'] as String); if (jsx.containsKey("end")) { trip.EndTime = DateTime.parse(jsx['end'] as String); } else { SessionData.ContainsTripTimes = false; } trip.deliveries = []; List dropOffs = jsx['deliveries'] as List; for (var dropOff in dropOffs) { trip.deliveries.add(Delivery.fromMap(dropOff as Map)); } return trip; } Future toNBT() async { CompoundTag ct = CompoundTag(); ct.put("start", StringTag.valueOf(StartTime.toIso8601String())); if (EndTime.year < 2000) { ct.put("end", StringTag.valueOf(EndTime.toIso8601String())); } ListTag drops = ListTag(); for (var drop in deliveries) { drops.add(await drop.toNBT()); } ct.put("deliveries", drops); return ct; } static Future fromNBT(CompoundTag tag) async { Trip trip = Trip(); // Deserialize the Trip trip.StartTime = DateTime.parse(tag.get("start")!.asString()); if (!tag.containsKey("end")) { SessionData.ContainsTripTimes = false; } ListTag drops = tag.get("deliveries")! as ListTag; for (var drop in drops.value) { Delivery del = await Delivery.fromNBT(drop.asCompoundTag()); trip.deliveries.add(del); } return trip; } } class Callbacks { VoidCallback? HomeCallback; VoidCallback? MapCallback; VoidCallback? UpdateSettingsCallback; VoidCallback? WorkDataCallback; void dispatch() { if (HomeCallback != null) HomeCallback!(); if (MapCallback != null) MapCallback!(); if (UpdateSettingsCallback != null) UpdateSettingsCallback!(); if (WorkDataCallback != null) WorkDataCallback!(); } } /// A simple wrapper for a position that strips away unnecessary information to create a more compact piece of save data class SmallPosition { double latitude; double longitude; SmallPosition({required this.latitude, required this.longitude}); static SmallPosition fromPosition(Position position) { return SmallPosition( latitude: position.latitude, longitude: position.longitude, ); } Map toMap() { return {"latitude": latitude, "longitude": longitude}; } static SmallPosition fromMap(Map map) { return SmallPosition( latitude: map['latitude'] as double, longitude: map['longitude'] as double, ); } Future toNBT() async { CompoundTag ct = CompoundTag(); ct.put("latitude", DoubleTag.valueOf(latitude)); ct.put("longitude", DoubleTag.valueOf(longitude)); return ct; } static Future fromNBT(CompoundTag ct) async { return SmallPosition( latitude: ct.get("latitude")?.asDouble() ?? 0, longitude: ct.get("longitude")?.asDouble() ?? 0, ); } }