TimeTracker/lib/data.dart

787 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Trip> Trips = [];
static Delivery? currentDelivery;
static Trip? currentTrip;
static List<SmallPosition> positions = [];
static late StreamSubscription<Position> _listener;
static Callbacks Calls = Callbacks();
static String LastSessionID = "";
static String DisplayError = "";
static double? TotalPay;
static bool ContainsTripTimes = true;
static bool IsSavedData = false;
static String SaveDataType = "";
/// 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() {
Time stamp = Time(days: 0, hours: 0, minutes: 0, seconds: 0);
print("Total trips: ${Trips.length}");
for (var trip in Trips) {
var diff = trip.EndTime.difference(trip.StartTime);
var diffTime = Time(
days: 0,
hours: 0,
minutes: 0,
seconds: diff.inSeconds,
);
stamp.add(diffTime);
print("Add timestamp; ${diff}; ${diffTime}");
}
print("Final time: ${stamp}");
return stamp.toDuration();
}
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<String> 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 (WGS84 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 GPSnoise cleanup.
///
/// * [minDistanceMeters] drop segments shorter than this (jitter).
/// * [maxDistanceMeters] drop segments longer than this (impossible jump).
static double _totalMilesTraveled(
List<SmallPosition> 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<bool> 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<void> 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<void> _upload(List<int> nbtData) async {
Dio dio = Dio();
Map<String, dynamic> 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<String, dynamic> 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<bool> LoadSavedCacheState() async {
CompoundTag ct = await NBTHelper.GetNBT(name: "appstate");
// Restore various flags now.
if (ct.isEmpty) {
ResetAppSession();
return false;
}
await _deserialize(ct);
if (IsOnTheClock) ContainsTripTimes = true;
Calls.dispatch();
return true;
}
/// Saves the current session based on various factors.
static Future<void> SaveCacheState() async {
CompoundTag ct = IsOnTheClock ? await _serializeToNBT() : CompoundTag();
await NBTHelper.CommitNBT(data: ct, name: "appstate");
}
static Future<void> _deserialize(CompoundTag ct) async {
IsOnTheClock = NbtUtils.readBoolean(ct, "inprog");
if (IsOnTheClock) {
await Login();
}
StartTime = DateTime.parse(ct.get("start")!.asString());
if (ct.containsKey("end")) {
EndTime = DateTime.parse(ct.get("end")!.asString());
} else {
EndTime = DateTime(0);
}
if (ct.containsKey("totalPay")) {
TotalPay = ct.get("totalPay")!.asDouble();
}
ListTag poses = ct.get("pos")! as ListTag;
positions.clear();
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<CompoundTag> _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();
int len = positions.length;
int i = 0;
for (i = 0; i < len; i++) {
var pos = positions[i];
posX.add(await pos.toNBT());
}
ct.put("pos", posX);
ListTag myTrips = ListTag();
len = Trips.length;
i = 0;
for (i = 0; i < len; i++) {
var trip = Trips[i];
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<String, dynamic> SaveData() {
Map<String, dynamic> saveData = {};
List<Map<String, dynamic>> trips = [];
for (var trip in Trips) {
trips.add(trip.toJsonMap());
}
List<Map<String, dynamic>> 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<bool> DownloadData() async {
Dio dio = Dio();
IsSavedData = false;
Map<String, dynamic> 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") {
IsSavedData = true;
SaveDataType = "JSON";
return LoadData(reply.data as Map<String, dynamic>);
} 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);
IsReadOnly = true;
isWeb = true;
IsSavedData = true;
SaveDataType = "NBT";
return true;
} else
return false;
} catch (E, stack) {
print(E);
print(stack);
return false;
}
}
static bool LoadData(Map<String, dynamic> jsMap) {
if (jsMap.containsKey("error")) {
LastSessionID = "";
return false;
}
List<dynamic> trips = jsMap['trips'] as List<dynamic>;
List<dynamic> pos = jsMap['positions'] as List<dynamic>;
for (var trip in trips) {
Trips.add(Trip.fromJsonMap(trip as Map<String, dynamic>));
}
for (var position in pos) {
positions.add(SmallPosition.fromMap(position as Map<String, dynamic>));
}
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<Position> 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<void> MarkEndLocation() async {
var pos = await SessionData.GetNewLocation();
endLocation = SmallPosition.fromPosition(pos);
}
Map<String, dynamic> toJsonMap() {
return {
"start": StartTime.toIso8601String(),
"endPos": endLocation?.toMap() ?? "incomplete",
};
}
static Delivery fromMap(Map<String, dynamic> 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<String, dynamic>,
);
}
return delivery;
}
Future<CompoundTag> 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<Delivery> 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<Delivery> deliveries = [];
DateTime StartTime = DateTime(0);
DateTime EndTime = DateTime(0);
Trip() {
StartTime = DateTime.now();
}
Delivery startNewDelivery() {
var delivery = Delivery();
deliveries.add(delivery);
return delivery;
}
Map<String, dynamic> toJsonMap() {
Map<String, Object> trip = {
"start": StartTime.toIso8601String(),
"end": EndTime.toIso8601String(),
};
List<Map<String, dynamic>> dropOffs = [];
for (var delivery in deliveries) {
dropOffs.add(delivery.toJsonMap());
}
trip["deliveries"] = dropOffs;
return trip;
}
static Trip fromJsonMap(Map<String, dynamic> 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<dynamic> dropOffs = jsx['deliveries'] as List<dynamic>;
for (var dropOff in dropOffs) {
trip.deliveries.add(Delivery.fromMap(dropOff as Map<String, dynamic>));
}
return trip;
}
Future<CompoundTag> 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<Trip> 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;
} else {
trip.EndTime = DateTime.parse(tag.get("end")!.asString());
}
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<String, dynamic> toMap() {
return {"latitude": latitude, "longitude": longitude};
}
static SmallPosition fromMap(Map<String, dynamic> map) {
return SmallPosition(
latitude: map['latitude'] as double,
longitude: map['longitude'] as double,
);
}
Future<CompoundTag> toNBT() async {
CompoundTag ct = CompoundTag();
ct.put("latitude", DoubleTag.valueOf(latitude));
ct.put("longitude", DoubleTag.valueOf(longitude));
return ct;
}
static Future<SmallPosition> fromNBT(CompoundTag ct) async {
return SmallPosition(
latitude: ct.get("latitude")?.asDouble() ?? 0,
longitude: ct.get("longitude")?.asDouble() ?? 0,
);
}
}