WIP: Release 1.0 #2
5
Jenkinsfile
vendored
|
@ -19,12 +19,11 @@ pipeline {
|
|||
#!/bin/bash
|
||||
|
||||
flutter build apk
|
||||
flutter build appbundle
|
||||
|
||||
|
||||
mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/timetrack.apk
|
||||
|
||||
|
||||
mv build/app/outputs/bundle/release/app-release.aab build/app/outputs/bundle/release/timetrack.aab
|
||||
|
||||
cd server/php
|
||||
tar -cvf ../../php.tgz .
|
||||
|
@ -36,7 +35,7 @@ pipeline {
|
|||
post {
|
||||
always {
|
||||
archiveArtifacts artifacts: "build/app/outputs/flutter-apk/timetrack.apk"
|
||||
archiveArtifacts artifacts: "build/app/outputs/bundle/release/timetrack.aab"
|
||||
|
||||
|
||||
archiveArtifacts artifacts: "php.tgz"
|
||||
|
||||
|
|
16
README.md
|
@ -24,6 +24,7 @@ The app does not store data locally, due to the way android permissions function
|
|||
|
||||
# Implementation
|
||||
|
||||
- [ ] Application Icon for mobile and web favicon
|
||||
- [x] Basic UI
|
||||
- [x] Permissions
|
||||
- [x] Automatic updates
|
||||
|
@ -32,10 +33,11 @@ The app does not store data locally, due to the way android permissions function
|
|||
- [x] Track driving hours
|
||||
- [x] Track trips
|
||||
- [x] Track stops/deliveries
|
||||
- [x] Track trip base pay
|
||||
- [x] Track each delivery's tips
|
||||
- [ ] Map marker for each stop/delivery with text saying "Trip #X/DropOff #X\nBase Pay: $$$; Tip: $$$"
|
||||
- [ ] Basic version of the app in readonly mode when deployed on a web server.
|
||||
- [ ] Backend server
|
||||
- [ ] PHP?
|
||||
- [ ] Dart based?
|
||||
- [x] ~~Track trip base pay~~
|
||||
- [x] ~~Track each delivery's tips~~
|
||||
- [x] Track total pay
|
||||
- [x] Map marker for each stop/delivery with text saying "Trip #X/DropOff #X ~~\nBase Pay: [amount]; Tip: [amount]~~
|
||||
- [x] Basic version of the app in readonly mode when deployed on a web server.
|
||||
- [x] Backend server
|
||||
- [x] PHP
|
||||
- [ ] ~~Dart based?~~
|
||||
|
|
|
@ -10,10 +10,15 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application
|
||||
android:label="timetrack"
|
||||
android:label="Time Tracker"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
@ -47,6 +52,11 @@
|
|||
<provider android:name="sk.fourq.otaupdate.OtaUpdateFileProvider" android:authorities="${applicationId}.ota_update_provider" android:exported="false" android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="de.julianassmann.flutter_background.IsolateHolderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 33 KiB |
BIN
icons/1024.png
Normal file
After Width: | Height: | Size: 566 KiB |
BIN
icons/128.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
icons/144.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
icons/16.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/192.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
icons/2048.png
Normal file
After Width: | Height: | Size: 2 MiB |
BIN
icons/256.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
icons/32.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
icons/4096.png
Normal file
After Width: | Height: | Size: 7.5 MiB |
BIN
icons/48.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
icons/512.png
Normal file
After Width: | Height: | Size: 166 KiB |
BIN
icons/64.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
icons/72.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
icons/96.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
icons/source.blend
Normal file
|
@ -7,15 +7,15 @@ class TTConsts {
|
|||
static get UPDATE_URL =>
|
||||
"https://git.zontreck.com/AriasCreations/TimeTracker/raw/branch/main/latest-releases.json";
|
||||
static get SESSION_SERVER =>
|
||||
"https://api.zontreck.com/timetrack/${UPDATE_CHANNEL}/timetrack.php";
|
||||
"https://api.zontreck.com/timetrack/$UPDATE_CHANNEL/timetrack.php";
|
||||
|
||||
static const VERSION = "1.0.0-beta.3";
|
||||
static const VERSION = "1.0.0-beta.33";
|
||||
|
||||
static bool UPDATE_AVAILABLE = false;
|
||||
static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta;
|
||||
static final LocationSettings LOCATION_SETTINGS = LocationSettings(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
distanceFilter: 15,
|
||||
distanceFilter: 5,
|
||||
);
|
||||
|
||||
static Future<void> checkUpdate() async {
|
||||
|
|
738
lib/data.dart
|
@ -1,16 +1,32 @@
|
|||
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;
|
||||
|
||||
|
@ -18,39 +34,33 @@ class SessionData {
|
|||
|
||||
static Delivery? currentDelivery;
|
||||
static Trip? currentTrip;
|
||||
static List<Position> positions = [];
|
||||
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 GetTotalBasePay() {
|
||||
double total = 0;
|
||||
for (var trip in Trips) {
|
||||
total += trip.BasePay;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
static double GetTotalTips() {
|
||||
double total = 0;
|
||||
for (var trip in Trips) {
|
||||
for (var drop in trip.deliveries) {
|
||||
total += drop.TipAmount;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
static double GetTotalPay() {
|
||||
return GetTotalBasePay() + GetTotalTips();
|
||||
}
|
||||
|
||||
static double GetTotalMiles() {
|
||||
double total = 0;
|
||||
total = _totalMilesTraveled(
|
||||
|
@ -61,6 +71,52 @@ class SessionData {
|
|||
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";
|
||||
|
@ -106,7 +162,7 @@ class SessionData {
|
|||
/// * [minDistanceMeters] – drop segments shorter than this (jitter).
|
||||
/// * [maxDistanceMeters] – drop segments longer than this (impossible jump).
|
||||
static double _totalMilesTraveled(
|
||||
List<Position> positions, {
|
||||
List<SmallPosition> positions, {
|
||||
double minDistanceMeters = 5,
|
||||
double? maxDistanceMeters,
|
||||
}) {
|
||||
|
@ -126,8 +182,9 @@ class SessionData {
|
|||
);
|
||||
|
||||
if (d < minDistanceMeters) continue; // too small → jitter
|
||||
if (maxDistanceMeters != null && d > maxDistanceMeters)
|
||||
if (maxDistanceMeters != null && d > maxDistanceMeters) {
|
||||
continue; // glitch
|
||||
}
|
||||
|
||||
meters += d;
|
||||
}
|
||||
|
@ -135,7 +192,63 @@ class SessionData {
|
|||
}
|
||||
//** End AI Generated code */
|
||||
|
||||
static Future<void> Login() async {
|
||||
/// 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;
|
||||
|
||||
|
@ -145,7 +258,7 @@ class SessionData {
|
|||
hasGPS = await Geolocator.isLocationServiceEnabled();
|
||||
if (!hasGPS) {
|
||||
IsOnTheClock = false;
|
||||
return Future.error("Location services are disabled");
|
||||
return await Future.error("Location services are disabled");
|
||||
}
|
||||
|
||||
perm = await Geolocator.checkPermission();
|
||||
|
@ -153,17 +266,19 @@ class SessionData {
|
|||
perm = await Geolocator.requestPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
IsOnTheClock = false;
|
||||
return Future.error("Location permissions are denied");
|
||||
return await Future.error("Location permissions are denied");
|
||||
}
|
||||
}
|
||||
|
||||
if (perm == LocationPermission.deniedForever) {
|
||||
IsOnTheClock = false;
|
||||
return Future.error(
|
||||
return await Future.error(
|
||||
"Location permissions are denied permanently. Login cannot proceed.",
|
||||
);
|
||||
}
|
||||
|
||||
await _create();
|
||||
|
||||
_listener = Geolocator.getPositionStream(
|
||||
locationSettings: TTConsts.LOCATION_SETTINGS,
|
||||
).listen((pos) {
|
||||
|
@ -171,10 +286,19 @@ class SessionData {
|
|||
_listener.cancel();
|
||||
return;
|
||||
}
|
||||
positions.add(pos);
|
||||
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 {
|
||||
|
@ -183,79 +307,356 @@ class SessionData {
|
|||
currentTrip = null;
|
||||
_listener.cancel();
|
||||
|
||||
var saveData = SaveData();
|
||||
print(saveData);
|
||||
EndTime = DateTime.now();
|
||||
|
||||
FlutterBackground.disableBackgroundExecution();
|
||||
WakelockPlus.disable();
|
||||
|
||||
var saveData = await _serializeToNBT();
|
||||
ResetAppSession();
|
||||
print(SnbtIo.writeToString(saveData));
|
||||
Uint8List nbtData = await NbtIo.writeToStream(saveData);
|
||||
|
||||
Trips = [];
|
||||
positions = [];
|
||||
|
||||
Dio dio = Dio();
|
||||
Map<String, dynamic> payload = {"cmd": "create", "data": saveData};
|
||||
_upload(nbtData);
|
||||
}
|
||||
|
||||
var reply = await dio.post(
|
||||
TTConsts.SESSION_SERVER,
|
||||
data: json.encode(payload),
|
||||
);
|
||||
Map<String, dynamic> replyJs = json.decode(reply.data as String);
|
||||
if (replyJs["status"] == "ok") {
|
||||
print("Successful upload");
|
||||
LastSessionID = replyJs['session'] as String;
|
||||
/// 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 = [];
|
||||
List<Map<String, dynamic>> trips = [];
|
||||
for (var trip in Trips) {
|
||||
_trips.add(trip.toJsonMap());
|
||||
trips.add(trip.toJsonMap());
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _pos = [];
|
||||
List<Map<String, dynamic>> posx = [];
|
||||
for (var pos in positions) {
|
||||
_pos.add(pos.toJson());
|
||||
posx.add(pos.toMap());
|
||||
}
|
||||
|
||||
saveData["trips"] = _trips;
|
||||
saveData["positions"] = _pos;
|
||||
saveData["trips"] = trips;
|
||||
saveData["positions"] = posx;
|
||||
saveData["start"] = StartTime.toIso8601String();
|
||||
saveData["end"] = EndTime.toIso8601String();
|
||||
if (TotalPay != null) saveData["totalPay"] = TotalPay;
|
||||
|
||||
return saveData;
|
||||
}
|
||||
|
||||
static Future<void> DownloadData() async {
|
||||
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
|
||||
var reply = await dio.post(
|
||||
TTConsts.SESSION_SERVER,
|
||||
data: json.encode(payload),
|
||||
);
|
||||
try {
|
||||
var reply = await dio.post(
|
||||
TTConsts.SESSION_SERVER,
|
||||
data: json.encode(payload),
|
||||
);
|
||||
|
||||
LoadData(reply.data as String);
|
||||
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 void LoadData(String js) {
|
||||
Map<String, dynamic> _js = json.decode(js);
|
||||
if (_js.containsKey("error")) {
|
||||
static bool LoadData(Map<String, dynamic> jsMap) {
|
||||
if (jsMap.containsKey("error")) {
|
||||
LastSessionID = "";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
List<Map<String, dynamic>> _trips =
|
||||
_js['trips'] as List<Map<String, dynamic>>;
|
||||
List<Map<String, dynamic>> _pos =
|
||||
_js['positions'] as List<Map<String, dynamic>>;
|
||||
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));
|
||||
for (var trip in trips) {
|
||||
Trips.add(Trip.fromJsonMap(trip as Map<String, dynamic>));
|
||||
}
|
||||
|
||||
for (var position in _pos) {
|
||||
positions.add(Position.fromMap(position));
|
||||
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 {
|
||||
|
@ -266,8 +667,11 @@ class SessionData {
|
|||
return pos;
|
||||
}
|
||||
|
||||
static Trip GetNewTrip({required double basePay}) {
|
||||
currentTrip = Trip(BasePay: basePay);
|
||||
static Trip GetNewTrip() {
|
||||
if (currentTrip != null) {
|
||||
currentTrip!.EndTime = DateTime.now();
|
||||
}
|
||||
currentTrip = Trip();
|
||||
Trips.add(currentTrip!);
|
||||
return currentTrip!;
|
||||
}
|
||||
|
@ -275,7 +679,6 @@ class SessionData {
|
|||
static Delivery GetNewDelivery() {
|
||||
if (currentTrip != null) {
|
||||
var dropOff = currentTrip!.startNewDelivery();
|
||||
;
|
||||
currentDelivery = dropOff;
|
||||
return dropOff;
|
||||
} else {
|
||||
|
@ -284,14 +687,53 @@ class SessionData {
|
|||
}
|
||||
|
||||
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 {
|
||||
double TipAmount = 0;
|
||||
Position? endLocation;
|
||||
SmallPosition? endLocation;
|
||||
DateTime StartTime = DateTime.now();
|
||||
|
||||
Delivery() {
|
||||
|
@ -300,25 +742,48 @@ class Delivery {
|
|||
|
||||
Future<void> MarkEndLocation() async {
|
||||
var pos = await SessionData.GetNewLocation();
|
||||
endLocation = pos;
|
||||
endLocation = SmallPosition.fromPosition(pos);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJsonMap() {
|
||||
return {
|
||||
"tip": TipAmount,
|
||||
"start": StartTime.toString(),
|
||||
"endPos": endLocation?.toJson() ?? "incomplete",
|
||||
"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);
|
||||
delivery.TipAmount = jsx['tip'] as double;
|
||||
if (jsx['endPos'] as String == "incomplete")
|
||||
if (jsx['endPos'] is String) {
|
||||
delivery.endLocation = null;
|
||||
else
|
||||
delivery.endLocation = Position.fromMap(jsx['endPos']);
|
||||
} 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;
|
||||
}
|
||||
|
@ -328,10 +793,9 @@ class Trip {
|
|||
List<Delivery> deliveries = [];
|
||||
|
||||
DateTime StartTime = DateTime(0);
|
||||
DateTime EndTime = DateTime(0);
|
||||
|
||||
double BasePay = 0.0;
|
||||
|
||||
Trip({required this.BasePay}) {
|
||||
Trip() {
|
||||
StartTime = DateTime.now();
|
||||
}
|
||||
|
||||
|
@ -343,27 +807,70 @@ class Trip {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toJsonMap() {
|
||||
Map<String, Object> _trip = {"start": StartTime.toString(), "pay": BasePay};
|
||||
List<Map<String, dynamic>> _dropOffs = [];
|
||||
Map<String, Object> trip = {
|
||||
"start": StartTime.toIso8601String(),
|
||||
"end": EndTime.toIso8601String(),
|
||||
};
|
||||
List<Map<String, dynamic>> dropOffs = [];
|
||||
for (var delivery in deliveries) {
|
||||
_dropOffs.add(delivery.toJsonMap());
|
||||
dropOffs.add(delivery.toJsonMap());
|
||||
}
|
||||
|
||||
_trip["deliveries"] = _dropOffs;
|
||||
trip["deliveries"] = dropOffs;
|
||||
|
||||
return _trip;
|
||||
return trip;
|
||||
}
|
||||
|
||||
static Trip fromJsonMap(Map<String, dynamic> jsx) {
|
||||
Trip trip = Trip(BasePay: 0);
|
||||
trip.BasePay = jsx['pay'] as double;
|
||||
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<Map<String, dynamic>> _dropOffs =
|
||||
jsx['deliveries'] as List<Map<String, dynamic>>;
|
||||
List<dynamic> dropOffs = jsx['deliveries'] as List<dynamic>;
|
||||
|
||||
for (var dropOff in _dropOffs) {
|
||||
trip.deliveries.add(Delivery.fromMap(dropOff));
|
||||
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;
|
||||
|
@ -383,3 +890,44 @@ class Callbacks {
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,43 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||
import 'package:timetrack/consts.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
import 'package:timetrack/pages/HomePage.dart';
|
||||
import 'package:timetrack/pages/MainApp.dart';
|
||||
|
||||
@pragma("vm:entry-point")
|
||||
void serviceEntry() {
|
||||
// Run the floater app here.
|
||||
runApp(MaterialApp(debugShowCheckedModeBanner: false, home: OverlayWidget()));
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
await TTConsts.checkUpdate();
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
SessionData.LoadSavedCacheState();
|
||||
|
||||
SessionData.isWeb = true;
|
||||
try {
|
||||
SessionData.isWeb = !Platform.isAndroid;
|
||||
} catch (E) {}
|
||||
|
||||
if (!SessionData.isWeb) await TTConsts.checkUpdate();
|
||||
var sess = Uri.base.queryParameters["code"] ?? "";
|
||||
|
||||
SessionData.LastSessionID = sess;
|
||||
if (SessionData.LastSessionID.isNotEmpty) {
|
||||
await SessionData.DownloadData();
|
||||
if (SessionData.LastSessionID.isEmpty) {
|
||||
if (!SessionData.IsSavedData) {
|
||||
// Invalid session token
|
||||
SessionData.DisplayError = "The URL and or session token is invalid";
|
||||
}
|
||||
}
|
||||
|
||||
if (!SessionData.isWeb) await FMTCObjectBoxBackend().initialise();
|
||||
if (!SessionData.isWeb) await FMTCStore('mapStore').manage.create();
|
||||
|
||||
runApp(MainApp());
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:libacflutter/Constants.dart';
|
|||
import 'package:libacflutter/Prompt.dart';
|
||||
import 'package:timetrack/consts.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
@ -29,6 +30,30 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
void call() {
|
||||
setState(() {});
|
||||
|
||||
if (SessionData.DisplayError.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(SessionData.DisplayError),
|
||||
elevation: 8,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
SessionData.DisplayError = "";
|
||||
}
|
||||
|
||||
if (SessionData.DisplayMessage.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(SessionData.DisplayMessage),
|
||||
elevation: 8,
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
SessionData.DisplayMessage = "";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -91,18 +116,27 @@ class _HomePageState extends State<HomePage> {
|
|||
setState(() {});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Source Code"),
|
||||
subtitle: Text("Licensed under the GPLv3"),
|
||||
leading: Icon(Icons.code_rounded),
|
||||
onTap: () async {
|
||||
final Uri url = Uri.parse(
|
||||
'https://git.zontreck.com/AriasCreations/TimeTracker',
|
||||
);
|
||||
if (!await launchUrl(url)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text("RESET APP SESSION"),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
SessionData.IsOnTheClock = false;
|
||||
SessionData.StartTime = DateTime.fromMillisecondsSinceEpoch(
|
||||
0,
|
||||
);
|
||||
SessionData.Trips = [];
|
||||
SessionData.currentDelivery = null;
|
||||
SessionData.currentTrip = null;
|
||||
SessionData.positions = [];
|
||||
SessionData.ResetAppSession();
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
});
|
||||
},
|
||||
),
|
||||
|
@ -125,7 +159,10 @@ class _HomePageState extends State<HomePage> {
|
|||
subtitle: Text("${SessionData.LastSessionID} - Tap to copy"),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: SessionData.LastSessionID),
|
||||
ClipboardData(
|
||||
text:
|
||||
"https://timetrack.zontreck.com/?code=${SessionData.LastSessionID}",
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -133,9 +170,20 @@ class _HomePageState extends State<HomePage> {
|
|||
Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
SessionData.Login();
|
||||
});
|
||||
var result = await SessionData.Login();
|
||||
if (!result) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Fatal Error: Could not establish the background service for keeping the app alive.",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
},
|
||||
child: Text("ENGAGE"),
|
||||
),
|
||||
|
@ -151,10 +199,7 @@ class _HomePageState extends State<HomePage> {
|
|||
Widget GetTripWidgets() {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"Trip started; Base Pay: \$${SessionData.currentTrip!.BasePay}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
Text("Trip started", style: TextStyle(fontSize: 18)),
|
||||
Text(
|
||||
"To end both your current delivery, and the trip, tap on END TRIP",
|
||||
style: TextStyle(fontSize: 18),
|
||||
|
@ -164,25 +209,41 @@ class _HomePageState extends State<HomePage> {
|
|||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
var reply = await showDialog(
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (bld) {
|
||||
return InputPrompt(
|
||||
title: "What was the tip?",
|
||||
prompt: "If there was no tip, enter a 0, or just hit submit.",
|
||||
type: InputPromptType.Number,
|
||||
builder: (builder) {
|
||||
return AlertDialog(
|
||||
icon: Icon(Icons.warning),
|
||||
title: Text("Are you sure you want to end the trip?"),
|
||||
content: Text(
|
||||
"Once ended, a marker for your current delivery will be dropped at your current location",
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
SessionData.currentDelivery!.MarkEndLocation();
|
||||
|
||||
SessionData.EndTrip();
|
||||
|
||||
setState(() {});
|
||||
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
},
|
||||
child: Text("Yes"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (reply == null || reply == "") reply = "0";
|
||||
double tip = double.parse(reply as String);
|
||||
SessionData.currentDelivery!.TipAmount = tip;
|
||||
SessionData.currentDelivery!.MarkEndLocation();
|
||||
|
||||
SessionData.EndTrip();
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: Text("END TRIP"),
|
||||
),
|
||||
|
@ -195,25 +256,40 @@ class _HomePageState extends State<HomePage> {
|
|||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
var reply = await showDialog(
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (bld) {
|
||||
return InputPrompt(
|
||||
title: "What was the tip?",
|
||||
prompt:
|
||||
"If there was no tip, enter a 0, or hit submit and leave blank",
|
||||
type: InputPromptType.Number,
|
||||
builder: (builder) {
|
||||
return AlertDialog(
|
||||
icon: Icon(Icons.warning),
|
||||
title: Text("Are you sure you want to start a new delivery?"),
|
||||
content: Text(
|
||||
"Once ended, a marker for your current delivery will be dropped at your current location",
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
SessionData.currentDelivery!.MarkEndLocation();
|
||||
SessionData.GetNewDelivery();
|
||||
|
||||
setState(() {});
|
||||
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
},
|
||||
child: Text("Yes"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (reply == null || reply == "") reply = "0";
|
||||
double tip = double.parse(reply as String);
|
||||
SessionData.currentDelivery!.TipAmount = tip;
|
||||
SessionData.currentDelivery!.MarkEndLocation();
|
||||
SessionData.GetNewDelivery();
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: Text("Start new delivery"),
|
||||
),
|
||||
|
@ -226,23 +302,38 @@ class _HomePageState extends State<HomePage> {
|
|||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
var reply = await showDialog(
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (builder) {
|
||||
return InputPrompt(
|
||||
title: "What is the base pay?",
|
||||
prompt: "Enter the base pay amount below.",
|
||||
type: InputPromptType.Number,
|
||||
return AlertDialog(
|
||||
icon: Icon(Icons.warning),
|
||||
title: Text("Are you sure you want to start a new trip?"),
|
||||
content: Text("Starting a trip will also start a delivery."),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
SessionData.GetNewTrip();
|
||||
SessionData.GetNewDelivery();
|
||||
|
||||
setState(() {});
|
||||
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
},
|
||||
child: Text("Yes"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (reply == null || reply == "") reply = "0";
|
||||
|
||||
double basePay = double.parse(reply as String);
|
||||
SessionData.GetNewTrip(basePay: basePay);
|
||||
SessionData.GetNewDelivery();
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: Text("Start New Trip"),
|
||||
),
|
||||
|
@ -262,8 +353,58 @@ class _HomePageState extends State<HomePage> {
|
|||
},
|
||||
);
|
||||
} else {
|
||||
SessionData.Logout();
|
||||
setState(() {});
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (builder) {
|
||||
return AlertDialog(
|
||||
icon: Icon(Icons.warning),
|
||||
title: Text("Are you sure you want to end the work day?"),
|
||||
content: Text(
|
||||
"Ending the work day will finalize your session. A code will pop up. To copy a URL to this session to your clipboard, tap and hold on the session code. You will be asked to enter the total pay if you tap yes.\n\nEnding the work day cannot be undone.",
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
|
||||
// Prompt for the total pay
|
||||
var reply = await showDialog(
|
||||
context: context,
|
||||
builder: (bldx) {
|
||||
return InputPrompt(
|
||||
title: "What was the total pay?",
|
||||
prompt:
|
||||
"Enter the total pay here. If you do not want to, simply leave the field blank and a 0 will be used instead.",
|
||||
type: InputPromptType.Number,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (reply == null || reply as String == "") {
|
||||
reply = 0.0;
|
||||
}
|
||||
|
||||
if (reply is String) reply = double.parse(reply);
|
||||
|
||||
SessionData.TotalPay = reply as double;
|
||||
|
||||
await SessionData.Logout();
|
||||
setState(() {});
|
||||
|
||||
SessionData.DirtyState = true;
|
||||
SessionData.ActionPerformed();
|
||||
},
|
||||
child: Text("Yes"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text("End work day"),
|
||||
|
@ -276,7 +417,7 @@ class _HomePageState extends State<HomePage> {
|
|||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
"You are now on the clock\nYour location is being tracked for record keeping purposes.\n\nYou started at ${SessionData.StartTime.toLocal()}\n\n",
|
||||
"Your location is being tracked for record keeping purposes.\n\nYou started ${SessionData.GetTotalTimeWorked(SessionData.StartTime, DateTime.now())} ago\n\n",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
if (SessionData.currentTrip != null) GetTripWidgets(),
|
||||
|
@ -286,3 +427,19 @@ class _HomePageState extends State<HomePage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayWidget extends StatefulWidget {
|
||||
const OverlayWidget({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _Overlay();
|
||||
}
|
||||
}
|
||||
|
||||
class _Overlay extends State<OverlayWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
import 'package:timetrack/pages/HomePage.dart';
|
||||
import 'package:timetrack/pages/MapPage.dart';
|
||||
import 'package:timetrack/pages/UpdateSettings.dart';
|
||||
|
@ -27,7 +26,7 @@ class MainAppState extends State<MainApp> {
|
|||
return MaterialApp(
|
||||
title: "Time Tracker",
|
||||
routes: {
|
||||
"/": (ctx) => Platform.isAndroid ? HomePage() : WebMain(),
|
||||
"/": (ctx) => !SessionData.isWeb ? HomePage() : WebMain(),
|
||||
"/upd": (ctx) => UpdateSettingsPage(),
|
||||
"/map": (ctx) => MapPage(),
|
||||
"/work": (ctx) => WorkDataPage(),
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:libacflutter/Constants.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||
|
||||
class MapPage extends StatefulWidget {
|
||||
MapPage({super.key});
|
||||
const MapPage({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
|
@ -21,6 +21,8 @@ class _MapPage extends State<MapPage> {
|
|||
List<Marker> Markers = [];
|
||||
bool autorefresh = true;
|
||||
|
||||
late FMTCTileProvider _tileProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
SessionData.Calls.MapCallback = call;
|
||||
|
@ -36,6 +38,11 @@ class _MapPage extends State<MapPage> {
|
|||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (!SessionData.isWeb) {
|
||||
_tileProvider = FMTCTileProvider(
|
||||
stores: const {'mapStore': BrowseStoreStrategy.readUpdateCreate},
|
||||
);
|
||||
}
|
||||
PointMap = [];
|
||||
Markers = [];
|
||||
|
||||
|
@ -67,9 +74,6 @@ class _MapPage extends State<MapPage> {
|
|||
title: Text(
|
||||
"Trip #${SessionData.Trips.indexOf(trip) + 1}; DropOff #${trip.deliveries.indexOf(dropOff) + 1}",
|
||||
),
|
||||
content: Text(
|
||||
"Pay: \$${trip.BasePay}\nTip: \$${dropOff.TipAmount}",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -140,6 +144,8 @@ class _MapPage extends State<MapPage> {
|
|||
TileLayer(
|
||||
urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
userAgentPackageName: "dev.zontreck.timetrack",
|
||||
|
||||
tileProvider: SessionData.isWeb ? null : _tileProvider,
|
||||
),
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:libacflutter/Constants.dart';
|
||||
import 'package:ota_update/ota_update.dart';
|
||||
import 'package:timetrack/consts.dart';
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:libacflutter/Constants.dart';
|
||||
import 'package:timetrack/consts.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class WebMain extends StatefulWidget {
|
||||
const WebMain({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _WebMain();
|
||||
|
@ -17,9 +21,31 @@ class _WebMain extends State<WebMain> {
|
|||
@override
|
||||
void didChangeDependencies() {
|
||||
sessionIDController.text = SessionData.LastSessionID;
|
||||
|
||||
// Check if FirstRun
|
||||
if (SessionData.Calls.HomeCallback == null) {
|
||||
SessionData.Calls.HomeCallback = _callback;
|
||||
// After doing this, we also want to schedule the timer
|
||||
Timer.periodic(Duration(seconds: 5), (timer) async {
|
||||
if (!SessionData.Recording) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the latest version number, compare, then redownload the data.
|
||||
int ver = await SessionData.FetchVersion();
|
||||
if (ver != SessionData.RecordingVersion) {
|
||||
await SessionData.DownloadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
Future<void> _callback() async {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
@ -65,6 +91,35 @@ class _WebMain extends State<WebMain> {
|
|||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
ListTile(
|
||||
title: Text("Source Code"),
|
||||
subtitle: Text("Licensed under the GPLv3"),
|
||||
leading: Icon(Icons.code_rounded),
|
||||
onTap: () async {
|
||||
final Uri url = Uri.parse(
|
||||
'https://git.zontreck.com/AriasCreations/TimeTracker',
|
||||
);
|
||||
if (!await launchUrl(url)) {
|
||||
throw Exception('Could not launch $url');
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Download Mobile App"),
|
||||
subtitle: Text(
|
||||
"Click to go to the download page, from there you will want the APK file.",
|
||||
),
|
||||
leading: Icon(Icons.download),
|
||||
onTap: () async {
|
||||
final Uri url = Uri.parse(
|
||||
"https://ci.zontreck.com/job/Projects/job/Dart/job/Time%20Tracker/",
|
||||
);
|
||||
if (!await launchUrl(url)) {
|
||||
throw Exception("Could not launch $url");
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -93,12 +148,61 @@ class _WebMain extends State<WebMain> {
|
|||
Text(
|
||||
"Use the top left menu to show the various pages for the data viewer.",
|
||||
),
|
||||
if (SessionData.IsSavedData)
|
||||
ListTile(
|
||||
title: Text("You are viewing saved data"),
|
||||
subtitle: Text(
|
||||
"This data was saved in the ${SessionData.SaveDataType} format.\n\nThis data is read-only. You will not be able to edit it.",
|
||||
),
|
||||
tileColor: const Color.fromARGB(255, 7, 123, 255),
|
||||
),
|
||||
if (SessionData.Recording)
|
||||
ListTile(
|
||||
title: Text("LIVE SESSION"),
|
||||
subtitle: Text(
|
||||
"This session is live! Recording is still in progress. Over time this live view will automatically refresh until the recording is ended.\n\nSession Version: ${SessionData.RecordingVersion}",
|
||||
),
|
||||
tileColor: LibACFlutterConstants.TITLEBAR_COLOR,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
SessionData.IsReadOnly = false;
|
||||
SessionData.Trips = [];
|
||||
SessionData.positions = [];
|
||||
SessionData.DisplayError = "";
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (builder) {
|
||||
return AlertDialog(
|
||||
icon: Icon(Icons.warning_amber),
|
||||
title: Text("Are you sure?"),
|
||||
content: Text(
|
||||
"If you close the session, you will need to re-enter the session code if you wish to load it again.",
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
SessionData.IsReadOnly = false;
|
||||
SessionData.Trips = [];
|
||||
SessionData.positions = [];
|
||||
SessionData.DisplayError = "";
|
||||
SessionData.StartTime = DateTime(0);
|
||||
SessionData.EndTime = DateTime(0);
|
||||
SessionData.LastSessionID = "";
|
||||
sessionIDController.text = "";
|
||||
|
||||
setState(() {});
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Yes"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text("Close Session"),
|
||||
),
|
||||
|
@ -117,7 +221,23 @@ class _WebMain extends State<WebMain> {
|
|||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await SessionData.DownloadData();
|
||||
SessionData.LastSessionID = sessionIDController.text;
|
||||
bool success = await SessionData.DownloadData();
|
||||
|
||||
if (!success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"ERROR: The provided session code was not found on the server",
|
||||
style: TextStyle(fontSize: 18, color: Colors.white),
|
||||
),
|
||||
backgroundColor: LibACFlutterConstants.TITLEBAR_COLOR,
|
||||
),
|
||||
snackBarAnimationStyle: AnimationStyle(
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
child: Text("Load Session", style: TextStyle(fontSize: 18)),
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:libacflutter/Constants.dart';
|
||||
import 'package:timetrack/data.dart';
|
||||
|
||||
class WorkDataPage extends StatefulWidget {
|
||||
WorkDataPage({super.key});
|
||||
const WorkDataPage({super.key});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
|
@ -29,6 +28,15 @@ class _WorkData extends State<WorkDataPage> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Widget GetDurationWidgets() {
|
||||
return Column(
|
||||
children: [
|
||||
Text("Paid Driving Hours: ${SessionData.GetPaidHours()}"),
|
||||
Text("Unpaid Driving Hours: ${SessionData.GetUnpaidHours()}"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
@ -46,32 +54,76 @@ class _WorkData extends State<WorkDataPage> {
|
|||
"Total saved GPS Positions: ${SessionData.positions.length}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
"Start Date & Time: ${SessionData.StartTime.toString()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
"Total Trips: ${SessionData.Trips.length}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
"Total Base Pay: \$${SessionData.GetTotalBasePay()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
"Total Tips: \$${SessionData.GetTotalTips()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
"Total Earnings: \$${SessionData.GetTotalPay()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
if (SessionData.StartTime.year > 2000)
|
||||
Text(
|
||||
"Start Date & Time: ${SessionData.StartTime.toString()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
if (SessionData.IsReadOnly && SessionData.StartTime.year > 2000)
|
||||
Text(
|
||||
"End Date & Time: ${SessionData.EndTime.toString()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
if (SessionData.StartTime.year > 2000)
|
||||
Text(
|
||||
"Total time worked: ${SessionData.GetTotalTimeWorked(SessionData.StartTime, SessionData.EndTime)}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
if (SessionData.StartTime.year < 2000)
|
||||
ListTile(
|
||||
title: Text("ERROR"),
|
||||
subtitle: Text(
|
||||
"This TTX session file appears to have been saved in an early alpha version. It does not contain the Start time or End timestamp information.",
|
||||
),
|
||||
tileColor: LibACFlutterConstants.TITLEBAR_COLOR,
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
if (SessionData.TotalPay == null)
|
||||
ListTile(
|
||||
title: Text("ERROR"),
|
||||
subtitle: Text(
|
||||
"This TTX session file appears to have been saved in a early beta build. It does not contain the total pay.",
|
||||
),
|
||||
tileColor: LibACFlutterConstants.TITLEBAR_COLOR,
|
||||
),
|
||||
if (SessionData.ContainsTripTimes) GetDurationWidgets(),
|
||||
if (!SessionData.ContainsTripTimes)
|
||||
ListTile(
|
||||
title: Text("ERROR"),
|
||||
subtitle: Text(
|
||||
"This TTX session file is older than Beta 13. It does not contain trip end times. For that reason, the app cannot display the paid driving hours and unpaid driving hours.",
|
||||
),
|
||||
tileColor: LibACFlutterConstants.TITLEBAR_COLOR,
|
||||
),
|
||||
if (SessionData.TotalPay != null)
|
||||
Text(
|
||||
"Total Pay: \$${SessionData.TotalPay!}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
|
||||
Text(
|
||||
"Total Estimated Miles: ${SessionData.GetTotalMilesAsString()}\n(Note: The miles displayed above may not be 100% accurate)",
|
||||
style: TextStyle(fontSize: 24),
|
||||
),
|
||||
|
||||
SizedBox(height: 40),
|
||||
Text(
|
||||
"Total Number of Trips: ${SessionData.Trips.length}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
|
||||
Text(
|
||||
"Total Deliveries: ${SessionData.GetTotalDeliveries()}",
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Process data export to GPX format.
|
||||
},
|
||||
child: Text("Export as GPX"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
|||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "timetrack");
|
||||
gtk_header_bar_set_title(header_bar, "Time Tracker");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "timetrack");
|
||||
gtk_window_set_title(window, "Time Tracker");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
|
17
pubspec.yaml
|
@ -1,5 +1,5 @@
|
|||
name: timetrack
|
||||
description: "A new Flutter project."
|
||||
description: "Time Track - A simple, yet effective GPS, time, and mile tracker"
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0-beta.3
|
||||
version: 1.0.0-beta.33
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
|
@ -36,15 +36,21 @@ dependencies:
|
|||
cupertino_icons: ^1.0.8
|
||||
libac_dart:
|
||||
hosted: https://git.zontreck.com/api/packages/Packages/pub/
|
||||
version: 1.4.20325+1215
|
||||
version: ^1.4.052725+1339
|
||||
libacflutter:
|
||||
hosted: https://git.zontreck.com/api/packages/Packages/pub/
|
||||
version: 1.0.31525+0222
|
||||
version: ^1.0.052725+1354
|
||||
dio: ^5.8.0+1
|
||||
ota_update: ^7.0.1
|
||||
geolocator: ^14.0.0
|
||||
flutter_map: ^8.1.1
|
||||
latlong2: ^0.9.1
|
||||
flutter_map_tile_caching: ^10.1.1
|
||||
url_launcher: ^6.3.1
|
||||
flutter_background: ^1.3.0+1
|
||||
floating_window_android: ^1.0.0
|
||||
shared_preferences: ^2.5.3
|
||||
wakelock_plus: ^1.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
@ -67,6 +73,9 @@ flutter:
|
|||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- server/php/timetrack.php
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
|
|
@ -11,47 +11,138 @@ $jsx = json_decode(file_get_contents("php://input"), true);
|
|||
|
||||
// Get operation information
|
||||
// DISCLAIMER: All php code below this point is AI Generated
|
||||
function create($DB, $jsx, $blank) {
|
||||
// Get UUID from MySQL and insert into sessions table
|
||||
$result = $DB->query("SELECT UUID() AS id");
|
||||
if (!$result) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to generate UUID"]);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$sessionId = $row['id'];
|
||||
|
||||
// Insert into `sessions` table
|
||||
$stmt = $DB->prepare("INSERT INTO `sessions` (`ID`, `timestamp`, `version`) VALUES (?, CURRENT_TIMESTAMP(), 0)");
|
||||
$stmt->bind_param("s", $sessionId);
|
||||
if (!$stmt->execute()) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to insert into sessions"]);
|
||||
return NULL;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
// Prepare data as JSON and insert into `data` table
|
||||
if(!$blank) {
|
||||
if($jsx['type'] == "json") {
|
||||
$data = json_encode($jsx['data']);
|
||||
}else if($jsx ['type'] == "nbt") {
|
||||
$data = base64_decode($jsx['data']);
|
||||
}
|
||||
} else {
|
||||
$data = "";
|
||||
}
|
||||
|
||||
$stmt = $DB->prepare("INSERT INTO `data` (`ID`, `SessionData`) VALUES (?, ?)");
|
||||
$null = NULL; // Required for bind_param with blob
|
||||
$stmt->bind_param("sb", $sessionId, $null); // Temporarily bind $null for blob
|
||||
|
||||
// Send the actual blob content using send_long_data
|
||||
$stmt->send_long_data(1, $data); // Index 1 refers to the second "?" in bind_param
|
||||
if (!$stmt->execute()) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to insert into data"]);
|
||||
return $sessionId;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
return $sessionId;
|
||||
}
|
||||
|
||||
function get_version($DB, $jsx) {
|
||||
|
||||
$sessionId = $jsx['id'];
|
||||
$stmt = $DB->prepare("SELECT `version` FROM `sessions` WHERE `ID` = ?");
|
||||
$stmt->bind_param("s", $sessionId);
|
||||
$stmt->execute();
|
||||
$stmt->store_result();
|
||||
|
||||
if($stmt->num_rows === 0) {
|
||||
|
||||
$stmt->close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
$stmt->bind_result($version);
|
||||
$stmt->fetch();
|
||||
$stmt->close();
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
function update_version($DB, $sesisonId, $newVer) {
|
||||
$stmt = $DB->prepare("UPDATE `sessions` SET `version` = ? WHERE `ID` = ?");
|
||||
$stmt->bind_param("is", $newVer, $sesisonId);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
function pushNewData($DB, $sessionId, $data) {
|
||||
$stmt = $DB->prepare("UPDATE `data` SET `SessionData` = ? WHERE `ID` = ?");
|
||||
$stmt->bind_param("bs", NULL, $sessionId);
|
||||
$stmt->send_long_data(0, $data);
|
||||
if(!$stmt->execute()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch($jsx['cmd']) {
|
||||
case "create": {
|
||||
// Get UUID from MySQL and insert into sessions table
|
||||
$result = $DB->query("SELECT UUID() AS id");
|
||||
if (!$result) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to generate UUID"]);
|
||||
break;
|
||||
}
|
||||
$session = create($DB, $jsx, false);
|
||||
|
||||
echo json_encode(["status" => "ok", "session" => $session]);
|
||||
break;
|
||||
}
|
||||
// Not AI Generated
|
||||
case "createv2": {
|
||||
$session = create($DB, $jsx, true);
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$sessionId = $row['id'];
|
||||
|
||||
// Insert into `sessions` table
|
||||
$stmt = $DB->prepare("INSERT INTO `sessions` (`ID`, `timestamp`) VALUES (?, CURRENT_TIMESTAMP())");
|
||||
$stmt->bind_param("s", $sessionId);
|
||||
if (!$stmt->execute()) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to insert into sessions"]);
|
||||
break;
|
||||
echo json_encode(["status" => "created", "session" => $session]);
|
||||
break;
|
||||
}
|
||||
// Not AI Generated
|
||||
case "get_version": {
|
||||
$ver = get_version($DB, $jsx);
|
||||
|
||||
|
||||
echo json_encode(["status" => "version_back", "version" => $ver]);
|
||||
|
||||
break;
|
||||
}
|
||||
// Not AI Generated
|
||||
case "patch": {
|
||||
$sessionId = $jsx['id'];
|
||||
|
||||
if($jsx['type'] == "json") {
|
||||
$data = json_encode($jsx['data']);
|
||||
}else if($jsx ['type'] == "nbt") {
|
||||
$data = base64_decode($jsx['data']);
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
// Prepare data as JSON and insert into `data` table
|
||||
$data = json_encode($jsx['data']);
|
||||
|
||||
$stmt = $DB->prepare("INSERT INTO `data` (`ID`, `SessionData`) VALUES (?, ?)");
|
||||
$null = NULL; // Required for bind_param with blob
|
||||
$stmt->bind_param("sb", $sessionId, $null); // Temporarily bind $null for blob
|
||||
|
||||
// Send the actual blob content using send_long_data
|
||||
$stmt->send_long_data(1, $data); // Index 1 refers to the second "?" in bind_param
|
||||
if (!$stmt->execute()) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Failed to insert into data"]);
|
||||
break;
|
||||
|
||||
$curVer = get_version($DB, $jsx);
|
||||
$curVer += 1; // We'll push this back to the database momentarily.
|
||||
|
||||
// Now, we update the data blob.
|
||||
$success = pushNewData($DB, $sessionId, $data);
|
||||
|
||||
if($success) {
|
||||
update_version($DB, $sessionId, $curVer);
|
||||
die(json_encode(["status" => "patched", "version" => $curVer]));
|
||||
} else {
|
||||
die(json_encode(["status" => "failed"]));
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
echo json_encode(["status" => "ok", "session" => $sessionId]);
|
||||
break;
|
||||
}
|
||||
case "get": {
|
||||
|
@ -74,11 +165,18 @@ switch($jsx['cmd']) {
|
|||
$stmt->close();
|
||||
|
||||
// Decode the JSON blob (optional — if you want raw JSON output)
|
||||
header("Content-Type: application/json");
|
||||
echo $sessionData;
|
||||
$testDecoded = json_decode($sessionData, true);
|
||||
if(json_last_error() == JSON_ERROR_NONE) {
|
||||
|
||||
header("Content-Type: application/json");
|
||||
die($sessionData);
|
||||
|
||||
}else {
|
||||
header("Content-Type: application/nbt");
|
||||
die(base64_encode($sessionData));
|
||||
//die($sessionData);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
?>
|
BIN
web/favicon.png
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 166 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 166 KiB |
|
@ -18,7 +18,7 @@
|
|||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta name="description" content="A simple, yet effective GPS, time, and mile tracker">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
{
|
||||
"name": "timetrack",
|
||||
"short_name": "timetrack",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
"name": "Time Tracker",
|
||||
"short_name": "timetrack",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A simple, yet effective GPS, time, and mile tracker",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|