TimeTracker/lib/data.dart

933 lines
25 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:io';
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 String DisplayMessage = "";
static bool DirtyState = false;
static double? TotalPay;
static bool ContainsTripTimes = true;
static bool IsSavedData = false;
static String SaveDataType = "";
/// This indicates whether the app is in a live session.
static bool Recording = false;
/// This is the version number of the recording as specified by the server.
static int RecordingVersion = 0;
/// This is used for the patch method detection. Do not edit this value, it will be overwritten.
static double LastTotalMiles = 0.0;
/// 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 */
/// A simple function that performs some immediate actions when a action has been performed. This does not replace the dirty state handler, which can perform additional tasks, such as upload of the patch data.
static Future<void> ActionPerformed() async {
await SaveCacheState();
DirtyState = true;
}
/// This replaces _upload. This method will serialize the current data as is with no alterations. It will then upload the data to the server in a patch.
static Future<void> _performPatch() async {
var data = await _serializeToNBT();
Dio dio = Dio();
// This is where we send the data to the server. In the server reply to a patch will be the new version number. This is not currently needed.
var packet = json.encode({
"cmd": "patch",
"id": LastSessionID,
"data": base64.encode(await NbtIo.writeToStream(data)),
});
var reply = await dio.post(TTConsts.SESSION_SERVER, data: packet);
var replyData = reply.data as String;
var replyJs = json.decode(replyData);
if (replyJs['status'] == "patched") {
RecordingVersion = replyJs['version'] as int;
return;
} else {
DisplayError = "Error: Patch upload failed. Retrying...";
Timer(Duration(seconds: 5), () async {
_performPatch();
});
}
}
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.",
);
}
await _create();
_listener = Geolocator.getPositionStream(
locationSettings: TTConsts.LOCATION_SETTINGS,
).listen((pos) {
if (!IsOnTheClock) {
_listener.cancel();
return;
}
positions.add(SmallPosition.fromPosition(pos));
SessionData.SaveCacheState();
SessionData.Calls.dispatch();
var curMiles = GetTotalMiles();
if (LastTotalMiles + 0.25 < curMiles) {
_performPatch();
LastTotalMiles = curMiles;
}
});
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);
}
/// v2 Create function.
///
/// This function sets the Session ID globally. It will also set the Recording flag to true.
static Future<void> _create() async {
Dio dio = Dio();
Map<String, dynamic> payload = {"cmd": "createv2"};
try {
var reply = await dio.post(
TTConsts.SESSION_SERVER,
data: json.encode(payload),
);
if (reply.statusCode == null) {
throw Exception("Fatal error while creating");
}
if (reply.statusCode! != 200) {
throw Exception("Fatal error while creating");
}
Map<String, dynamic> replyJs = json.decode(reply.data as String);
if (replyJs["status"] == "created") {
print("Successful creation");
LastSessionID = replyJs['session'] as String;
Recording = true;
RecordingVersion = 0;
Calls.dispatch();
}
} catch (E) {
// Retry in 2 seconds
DisplayError =
"Error: Something went wrong during session creation. Retry in 5 seconds...";
Calls.dispatch();
Timer(Duration(seconds: 5), () {
_create();
});
}
}
/// Deprecated: This function uploads using the v1 API, and is intended to be called at the conclusion of data recording.
@Deprecated(
"This function utilizes the Protocol v1 Create method, which has been replaced by createv2 and patch. This function will be removed.",
)
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;
Recording = false;
if (FlutterBackground.isBackgroundExecutionEnabled) {
FlutterBackground.disableBackgroundExecution();
}
WakelockPlus.disable();
NBTHelper.CommitNBT(data: CompoundTag(), name: "appstate");
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");
if (DirtyState) {
DirtyState = false;
DisplayMessage = "Saved cache state";
}
}
static Future<void> _deserialize(CompoundTag ct) async {
IsOnTheClock = NbtUtils.readBoolean(ct, "inprog");
if (ct.containsKey("record"))
Recording = NbtUtils.readBoolean(ct, "record");
else
Recording = false;
if (Recording && isWeb) {
IsOnTheClock = false;
IsReadOnly = true;
}
if (IsOnTheClock) {
await Login();
}
if (!ct.containsKey("start")) {
ResetAppSession();
return;
}
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(),
);
}
print("Deserialized data: ${SnbtIo.writeToString(ct)}");
}
/// 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);
NbtUtils.writeBoolean(ct, "record", Recording);
// 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();
}
static Future<int> FetchVersion() async {
Dio dio = Dio();
var packet = json.encode({"cmd": "get_version", "id": LastSessionID});
var response = await dio.post(TTConsts.SESSION_SERVER, data: packet);
if (response.statusCode == null) {
DisplayError = "Error: Version retrieval failed";
return -1;
}
if (response.statusCode! != 200) {
DisplayError = "Error: Session server HTTP Response code";
return -2;
}
var reply = json.decode(response.data as String);
if (reply["status"] == "version_back") {
return reply["version"] as int;
} else {
DisplayError = "Error: Unknown get_version reply";
return -3;
}
}
}
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,
);
}
}