WIP: Release 1.0 #2

Draft
zontreck wants to merge 40 commits from beta into main
42 changed files with 1314 additions and 291 deletions

5
Jenkinsfile vendored
View file

@ -19,12 +19,11 @@ pipeline {
#!/bin/bash #!/bin/bash
flutter build apk 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/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 cd server/php
tar -cvf ../../php.tgz . tar -cvf ../../php.tgz .
@ -36,7 +35,7 @@ pipeline {
post { post {
always { always {
archiveArtifacts artifacts: "build/app/outputs/flutter-apk/timetrack.apk" archiveArtifacts artifacts: "build/app/outputs/flutter-apk/timetrack.apk"
archiveArtifacts artifacts: "build/app/outputs/bundle/release/timetrack.aab"
archiveArtifacts artifacts: "php.tgz" archiveArtifacts artifacts: "php.tgz"

View file

@ -24,6 +24,7 @@ The app does not store data locally, due to the way android permissions function
# Implementation # Implementation
- [ ] Application Icon for mobile and web favicon
- [x] Basic UI - [x] Basic UI
- [x] Permissions - [x] Permissions
- [x] Automatic updates - [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 driving hours
- [x] Track trips - [x] Track trips
- [x] Track stops/deliveries - [x] Track stops/deliveries
- [x] Track trip base pay - [x] ~~Track trip base pay~~
- [x] Track each delivery's tips - [x] ~~Track each delivery's tips~~
- [ ] Map marker for each stop/delivery with text saying "Trip #X/DropOff #X\nBase Pay: $$$; Tip: $$$" - [x] Track total pay
- [ ] Basic version of the app in readonly mode when deployed on a web server. - [x] Map marker for each stop/delivery with text saying "Trip #X/DropOff #X ~~\nBase Pay: [amount]; Tip: [amount]~~
- [ ] Backend server - [x] Basic version of the app in readonly mode when deployed on a web server.
- [ ] PHP? - [x] Backend server
- [ ] Dart based? - [x] PHP
- [ ] ~~Dart based?~~

View file

@ -10,10 +10,15 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.REQUEST_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 <application
android:label="timetrack" android:label="Time Tracker"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@ -47,6 +52,11 @@
<provider android:name="sk.fourq.otaupdate.OtaUpdateFileProvider" android:authorities="${applicationId}.ota_update_provider" android:exported="false" android:grantUriPermissions="true"> <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" /> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" />
</provider> </provider>
<service
android:name="de.julianassmann.flutter_background.IsolateHolderService"
android:exported="false"
android:foregroundServiceType="location" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

BIN
icons/1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

BIN
icons/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
icons/144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
icons/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
icons/192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
icons/2048.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

BIN
icons/256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
icons/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
icons/4096.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

BIN
icons/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
icons/512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
icons/64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
icons/72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
icons/96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
icons/source.blend Normal file

Binary file not shown.

View file

@ -7,15 +7,15 @@ class TTConsts {
static get UPDATE_URL => static get UPDATE_URL =>
"https://git.zontreck.com/AriasCreations/TimeTracker/raw/branch/main/latest-releases.json"; "https://git.zontreck.com/AriasCreations/TimeTracker/raw/branch/main/latest-releases.json";
static get SESSION_SERVER => 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 bool UPDATE_AVAILABLE = false;
static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta; static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta;
static final LocationSettings LOCATION_SETTINGS = LocationSettings( static final LocationSettings LOCATION_SETTINGS = LocationSettings(
accuracy: LocationAccuracy.bestForNavigation, accuracy: LocationAccuracy.bestForNavigation,
distanceFilter: 15, distanceFilter: 5,
); );
static Future<void> checkUpdate() async { static Future<void> checkUpdate() async {

View file

@ -1,16 +1,32 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:floating_window_android/floating_window_android.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_background/flutter_background.dart';
import 'package:geolocator/geolocator.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/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:timetrack/consts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class SessionData { class SessionData {
static DateTime StartTime = DateTime(0); static DateTime StartTime = DateTime(0);
static DateTime EndTime = DateTime(0);
static bool IsOnTheClock = false; static bool IsOnTheClock = false;
@ -18,39 +34,33 @@ class SessionData {
static Delivery? currentDelivery; static Delivery? currentDelivery;
static Trip? currentTrip; static Trip? currentTrip;
static List<Position> positions = []; static List<SmallPosition> positions = [];
static late StreamSubscription<Position> _listener; static late StreamSubscription<Position> _listener;
static Callbacks Calls = Callbacks(); static Callbacks Calls = Callbacks();
static String LastSessionID = ""; static String LastSessionID = "";
static String DisplayError = ""; 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. /// 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 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() { static double GetTotalMiles() {
double total = 0; double total = 0;
total = _totalMilesTraveled( total = _totalMilesTraveled(
@ -61,6 +71,52 @@ class SessionData {
return total; 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() { static String GetTotalMilesAsString() {
double miles = GetTotalMiles(); double miles = GetTotalMiles();
if (miles == 0) return "0.0"; if (miles == 0) return "0.0";
@ -106,7 +162,7 @@ class SessionData {
/// * [minDistanceMeters] drop segments shorter than this (jitter). /// * [minDistanceMeters] drop segments shorter than this (jitter).
/// * [maxDistanceMeters] drop segments longer than this (impossible jump). /// * [maxDistanceMeters] drop segments longer than this (impossible jump).
static double _totalMilesTraveled( static double _totalMilesTraveled(
List<Position> positions, { List<SmallPosition> positions, {
double minDistanceMeters = 5, double minDistanceMeters = 5,
double? maxDistanceMeters, double? maxDistanceMeters,
}) { }) {
@ -126,8 +182,9 @@ class SessionData {
); );
if (d < minDistanceMeters) continue; // too small jitter if (d < minDistanceMeters) continue; // too small jitter
if (maxDistanceMeters != null && d > maxDistanceMeters) if (maxDistanceMeters != null && d > maxDistanceMeters) {
continue; // glitch continue; // glitch
}
meters += d; meters += d;
} }
@ -135,7 +192,63 @@ class SessionData {
} }
//** End AI Generated code */ //** 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(); StartTime = DateTime.now();
IsOnTheClock = true; IsOnTheClock = true;
@ -145,7 +258,7 @@ class SessionData {
hasGPS = await Geolocator.isLocationServiceEnabled(); hasGPS = await Geolocator.isLocationServiceEnabled();
if (!hasGPS) { if (!hasGPS) {
IsOnTheClock = false; IsOnTheClock = false;
return Future.error("Location services are disabled"); return await Future.error("Location services are disabled");
} }
perm = await Geolocator.checkPermission(); perm = await Geolocator.checkPermission();
@ -153,17 +266,19 @@ class SessionData {
perm = await Geolocator.requestPermission(); perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied) { if (perm == LocationPermission.denied) {
IsOnTheClock = false; IsOnTheClock = false;
return Future.error("Location permissions are denied"); return await Future.error("Location permissions are denied");
} }
} }
if (perm == LocationPermission.deniedForever) { if (perm == LocationPermission.deniedForever) {
IsOnTheClock = false; IsOnTheClock = false;
return Future.error( return await Future.error(
"Location permissions are denied permanently. Login cannot proceed.", "Location permissions are denied permanently. Login cannot proceed.",
); );
} }
await _create();
_listener = Geolocator.getPositionStream( _listener = Geolocator.getPositionStream(
locationSettings: TTConsts.LOCATION_SETTINGS, locationSettings: TTConsts.LOCATION_SETTINGS,
).listen((pos) { ).listen((pos) {
@ -171,10 +286,19 @@ class SessionData {
_listener.cancel(); _listener.cancel();
return; return;
} }
positions.add(pos); positions.add(SmallPosition.fromPosition(pos));
SessionData.SaveCacheState();
SessionData.Calls.dispatch(); SessionData.Calls.dispatch();
var curMiles = GetTotalMiles();
if (LastTotalMiles + 0.25 < curMiles) {
_performPatch();
LastTotalMiles = curMiles;
}
}); });
return true;
} }
static Future<void> Logout() async { static Future<void> Logout() async {
@ -183,79 +307,356 @@ class SessionData {
currentTrip = null; currentTrip = null;
_listener.cancel(); _listener.cancel();
var saveData = SaveData(); EndTime = DateTime.now();
print(saveData);
FlutterBackground.disableBackgroundExecution();
WakelockPlus.disable();
var saveData = await _serializeToNBT();
ResetAppSession();
print(SnbtIo.writeToString(saveData));
Uint8List nbtData = await NbtIo.writeToStream(saveData);
Trips = []; Trips = [];
positions = []; positions = [];
Dio dio = Dio(); _upload(nbtData);
Map<String, dynamic> payload = {"cmd": "create", "data": saveData}; }
var reply = await dio.post( /// v2 Create function.
TTConsts.SESSION_SERVER, ///
data: json.encode(payload), /// This function sets the Session ID globally. It will also set the Recording flag to true.
); static Future<void> _create() async {
Map<String, dynamic> replyJs = json.decode(reply.data as String); Dio dio = Dio();
if (replyJs["status"] == "ok") { Map<String, dynamic> payload = {"cmd": "createv2"};
print("Successful upload");
LastSessionID = replyJs['session'] as String; 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(); 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() { static Map<String, dynamic> SaveData() {
Map<String, dynamic> saveData = {}; Map<String, dynamic> saveData = {};
List<Map<String, dynamic>> _trips = []; List<Map<String, dynamic>> trips = [];
for (var trip in 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) { for (var pos in positions) {
_pos.add(pos.toJson()); posx.add(pos.toMap());
} }
saveData["trips"] = _trips; saveData["trips"] = trips;
saveData["positions"] = _pos; saveData["positions"] = posx;
saveData["start"] = StartTime.toIso8601String();
saveData["end"] = EndTime.toIso8601String();
if (TotalPay != null) saveData["totalPay"] = TotalPay;
return saveData; return saveData;
} }
static Future<void> DownloadData() async { static Future<bool> DownloadData() async {
Dio dio = Dio(); Dio dio = Dio();
IsSavedData = false;
Map<String, dynamic> payload = {"cmd": "get", "id": LastSessionID}; Map<String, dynamic> payload = {"cmd": "get", "id": LastSessionID};
// Send the data, and get the response // Send the data, and get the response
var reply = await dio.post( try {
TTConsts.SESSION_SERVER, var reply = await dio.post(
data: json.encode(payload), 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) { static bool LoadData(Map<String, dynamic> jsMap) {
Map<String, dynamic> _js = json.decode(js); if (jsMap.containsKey("error")) {
if (_js.containsKey("error")) {
LastSessionID = ""; LastSessionID = "";
return; return false;
} }
List<Map<String, dynamic>> _trips = List<dynamic> trips = jsMap['trips'] as List<dynamic>;
_js['trips'] as List<Map<String, dynamic>>; List<dynamic> pos = jsMap['positions'] as List<dynamic>;
List<Map<String, dynamic>> _pos =
_js['positions'] as List<Map<String, dynamic>>;
for (var trip in _trips) { for (var trip in trips) {
Trips.add(Trip.fromJsonMap(trip)); Trips.add(Trip.fromJsonMap(trip as Map<String, dynamic>));
} }
for (var position in _pos) { for (var position in pos) {
positions.add(Position.fromMap(position)); 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; IsReadOnly = true;
return true;
} }
static Future<Position> GetNewLocation() async { static Future<Position> GetNewLocation() async {
@ -266,8 +667,11 @@ class SessionData {
return pos; return pos;
} }
static Trip GetNewTrip({required double basePay}) { static Trip GetNewTrip() {
currentTrip = Trip(BasePay: basePay); if (currentTrip != null) {
currentTrip!.EndTime = DateTime.now();
}
currentTrip = Trip();
Trips.add(currentTrip!); Trips.add(currentTrip!);
return currentTrip!; return currentTrip!;
} }
@ -275,7 +679,6 @@ class SessionData {
static Delivery GetNewDelivery() { static Delivery GetNewDelivery() {
if (currentTrip != null) { if (currentTrip != null) {
var dropOff = currentTrip!.startNewDelivery(); var dropOff = currentTrip!.startNewDelivery();
;
currentDelivery = dropOff; currentDelivery = dropOff;
return dropOff; return dropOff;
} else { } else {
@ -284,14 +687,53 @@ class SessionData {
} }
static void EndTrip() { static void EndTrip() {
if (currentTrip != null) {
currentTrip!.EndTime = DateTime.now();
}
currentDelivery = null; currentDelivery = null;
currentTrip = 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 { class Delivery {
double TipAmount = 0; SmallPosition? endLocation;
Position? endLocation;
DateTime StartTime = DateTime.now(); DateTime StartTime = DateTime.now();
Delivery() { Delivery() {
@ -300,25 +742,48 @@ class Delivery {
Future<void> MarkEndLocation() async { Future<void> MarkEndLocation() async {
var pos = await SessionData.GetNewLocation(); var pos = await SessionData.GetNewLocation();
endLocation = pos; endLocation = SmallPosition.fromPosition(pos);
} }
Map<String, dynamic> toJsonMap() { Map<String, dynamic> toJsonMap() {
return { return {
"tip": TipAmount, "start": StartTime.toIso8601String(),
"start": StartTime.toString(), "endPos": endLocation?.toMap() ?? "incomplete",
"endPos": endLocation?.toJson() ?? "incomplete",
}; };
} }
static Delivery fromMap(Map<String, dynamic> jsx) { static Delivery fromMap(Map<String, dynamic> jsx) {
Delivery delivery = Delivery(); Delivery delivery = Delivery();
delivery.StartTime = DateTime.parse(jsx['start'] as String); delivery.StartTime = DateTime.parse(jsx['start'] as String);
delivery.TipAmount = jsx['tip'] as double; if (jsx['endPos'] is String) {
if (jsx['endPos'] as String == "incomplete")
delivery.endLocation = null; delivery.endLocation = null;
else } else {
delivery.endLocation = Position.fromMap(jsx['endPos']); 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; return delivery;
} }
@ -328,10 +793,9 @@ class Trip {
List<Delivery> deliveries = []; List<Delivery> deliveries = [];
DateTime StartTime = DateTime(0); DateTime StartTime = DateTime(0);
DateTime EndTime = DateTime(0);
double BasePay = 0.0; Trip() {
Trip({required this.BasePay}) {
StartTime = DateTime.now(); StartTime = DateTime.now();
} }
@ -343,27 +807,70 @@ class Trip {
} }
Map<String, dynamic> toJsonMap() { Map<String, dynamic> toJsonMap() {
Map<String, Object> _trip = {"start": StartTime.toString(), "pay": BasePay}; Map<String, Object> trip = {
List<Map<String, dynamic>> _dropOffs = []; "start": StartTime.toIso8601String(),
"end": EndTime.toIso8601String(),
};
List<Map<String, dynamic>> dropOffs = [];
for (var delivery in deliveries) { 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) { static Trip fromJsonMap(Map<String, dynamic> jsx) {
Trip trip = Trip(BasePay: 0); Trip trip = Trip();
trip.BasePay = jsx['pay'] as double;
trip.StartTime = DateTime.parse(jsx['start'] as String); 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 = []; trip.deliveries = [];
List<Map<String, dynamic>> _dropOffs = List<dynamic> dropOffs = jsx['deliveries'] as List<dynamic>;
jsx['deliveries'] as List<Map<String, dynamic>>;
for (var dropOff in _dropOffs) { for (var dropOff in dropOffs) {
trip.deliveries.add(Delivery.fromMap(dropOff)); 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; return trip;
@ -383,3 +890,44 @@ class Callbacks {
if (WorkDataCallback != null) WorkDataCallback!(); 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,
);
}
}

View file

@ -1,19 +1,43 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
import 'package:timetrack/pages/HomePage.dart';
import 'package:timetrack/pages/MainApp.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 { 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"] ?? ""; var sess = Uri.base.queryParameters["code"] ?? "";
SessionData.LastSessionID = sess; SessionData.LastSessionID = sess;
if (SessionData.LastSessionID.isNotEmpty) { if (SessionData.LastSessionID.isNotEmpty) {
await SessionData.DownloadData(); await SessionData.DownloadData();
if (SessionData.LastSessionID.isEmpty) { if (!SessionData.IsSavedData) {
// Invalid session token // Invalid session token
SessionData.DisplayError = "The URL and or session token is invalid"; 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()); runApp(MainApp());
} }

View file

@ -4,6 +4,7 @@ import 'package:libacflutter/Constants.dart';
import 'package:libacflutter/Prompt.dart'; import 'package:libacflutter/Prompt.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
import 'package:url_launcher/url_launcher.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@ -29,6 +30,30 @@ class _HomePageState extends State<HomePage> {
void call() { void call() {
setState(() {}); 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 @override
@ -91,18 +116,27 @@ class _HomePageState extends State<HomePage> {
setState(() {}); 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( ListTile(
title: Text("RESET APP SESSION"), title: Text("RESET APP SESSION"),
onTap: () async { onTap: () async {
setState(() { setState(() {
SessionData.IsOnTheClock = false; SessionData.ResetAppSession();
SessionData.StartTime = DateTime.fromMillisecondsSinceEpoch( SessionData.DirtyState = true;
0, SessionData.ActionPerformed();
);
SessionData.Trips = [];
SessionData.currentDelivery = null;
SessionData.currentTrip = null;
SessionData.positions = [];
}); });
}, },
), ),
@ -125,7 +159,10 @@ class _HomePageState extends State<HomePage> {
subtitle: Text("${SessionData.LastSessionID} - Tap to copy"), subtitle: Text("${SessionData.LastSessionID} - Tap to copy"),
onTap: () { onTap: () {
Clipboard.setData( 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( Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
setState(() { var result = await SessionData.Login();
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"), child: Text("ENGAGE"),
), ),
@ -151,10 +199,7 @@ class _HomePageState extends State<HomePage> {
Widget GetTripWidgets() { Widget GetTripWidgets() {
return Column( return Column(
children: [ children: [
Text( Text("Trip started", style: TextStyle(fontSize: 18)),
"Trip started; Base Pay: \$${SessionData.currentTrip!.BasePay}",
style: TextStyle(fontSize: 18),
),
Text( Text(
"To end both your current delivery, and the trip, tap on END TRIP", "To end both your current delivery, and the trip, tap on END TRIP",
style: TextStyle(fontSize: 18), style: TextStyle(fontSize: 18),
@ -164,25 +209,41 @@ class _HomePageState extends State<HomePage> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
var reply = await showDialog( await showDialog(
context: context, context: context,
builder: (bld) { builder: (builder) {
return InputPrompt( return AlertDialog(
title: "What was the tip?", icon: Icon(Icons.warning),
prompt: "If there was no tip, enter a 0, or just hit submit.", title: Text("Are you sure you want to end the trip?"),
type: InputPromptType.Number, 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"), child: Text("END TRIP"),
), ),
@ -195,25 +256,40 @@ class _HomePageState extends State<HomePage> {
children: [ children: [
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
var reply = await showDialog( await showDialog(
context: context, context: context,
builder: (bld) { builder: (builder) {
return InputPrompt( return AlertDialog(
title: "What was the tip?", icon: Icon(Icons.warning),
prompt: title: Text("Are you sure you want to start a new delivery?"),
"If there was no tip, enter a 0, or hit submit and leave blank", content: Text(
type: InputPromptType.Number, "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"), child: Text("Start new delivery"),
), ),
@ -226,23 +302,38 @@ class _HomePageState extends State<HomePage> {
children: [ children: [
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
var reply = await showDialog( await showDialog(
context: context, context: context,
builder: (builder) { builder: (builder) {
return InputPrompt( return AlertDialog(
title: "What is the base pay?", icon: Icon(Icons.warning),
prompt: "Enter the base pay amount below.", title: Text("Are you sure you want to start a new trip?"),
type: InputPromptType.Number, 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"), child: Text("Start New Trip"),
), ),
@ -262,8 +353,58 @@ class _HomePageState extends State<HomePage> {
}, },
); );
} else { } else {
SessionData.Logout(); await showDialog(
setState(() {}); 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"), child: Text("End work day"),
@ -276,7 +417,7 @@ class _HomePageState extends State<HomePage> {
return Column( return Column(
children: [ children: [
Text( 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), style: TextStyle(fontSize: 18),
), ),
if (SessionData.currentTrip != null) GetTripWidgets(), 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();
}
}

View file

@ -1,6 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:timetrack/data.dart';
import 'package:timetrack/pages/HomePage.dart'; import 'package:timetrack/pages/HomePage.dart';
import 'package:timetrack/pages/MapPage.dart'; import 'package:timetrack/pages/MapPage.dart';
import 'package:timetrack/pages/UpdateSettings.dart'; import 'package:timetrack/pages/UpdateSettings.dart';
@ -27,7 +26,7 @@ class MainAppState extends State<MainApp> {
return MaterialApp( return MaterialApp(
title: "Time Tracker", title: "Time Tracker",
routes: { routes: {
"/": (ctx) => Platform.isAndroid ? HomePage() : WebMain(), "/": (ctx) => !SessionData.isWeb ? HomePage() : WebMain(),
"/upd": (ctx) => UpdateSettingsPage(), "/upd": (ctx) => UpdateSettingsPage(),
"/map": (ctx) => MapPage(), "/map": (ctx) => MapPage(),
"/work": (ctx) => WorkDataPage(), "/work": (ctx) => WorkDataPage(),

View file

@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:libacflutter/Constants.dart'; import 'package:libacflutter/Constants.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
class MapPage extends StatefulWidget { class MapPage extends StatefulWidget {
MapPage({super.key}); const MapPage({super.key});
@override @override
State<StatefulWidget> createState() { State<StatefulWidget> createState() {
@ -21,6 +21,8 @@ class _MapPage extends State<MapPage> {
List<Marker> Markers = []; List<Marker> Markers = [];
bool autorefresh = true; bool autorefresh = true;
late FMTCTileProvider _tileProvider;
@override @override
void initState() { void initState() {
SessionData.Calls.MapCallback = call; SessionData.Calls.MapCallback = call;
@ -36,6 +38,11 @@ class _MapPage extends State<MapPage> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
if (!SessionData.isWeb) {
_tileProvider = FMTCTileProvider(
stores: const {'mapStore': BrowseStoreStrategy.readUpdateCreate},
);
}
PointMap = []; PointMap = [];
Markers = []; Markers = [];
@ -67,9 +74,6 @@ class _MapPage extends State<MapPage> {
title: Text( title: Text(
"Trip #${SessionData.Trips.indexOf(trip) + 1}; DropOff #${trip.deliveries.indexOf(dropOff) + 1}", "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( TileLayer(
urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
userAgentPackageName: "dev.zontreck.timetrack", userAgentPackageName: "dev.zontreck.timetrack",
tileProvider: SessionData.isWeb ? null : _tileProvider,
), ),
PolylineLayer( PolylineLayer(
polylines: [ polylines: [

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:libacflutter/Constants.dart'; import 'package:libacflutter/Constants.dart';
import 'package:ota_update/ota_update.dart'; import 'package:ota_update/ota_update.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';

View file

@ -1,10 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:libacflutter/Constants.dart'; import 'package:libacflutter/Constants.dart';
import 'package:timetrack/consts.dart'; import 'package:timetrack/consts.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
import 'package:url_launcher/url_launcher.dart';
class WebMain extends StatefulWidget { class WebMain extends StatefulWidget {
const WebMain({super.key});
@override @override
State<StatefulWidget> createState() { State<StatefulWidget> createState() {
return _WebMain(); return _WebMain();
@ -17,9 +21,31 @@ class _WebMain extends State<WebMain> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
sessionIDController.text = SessionData.LastSessionID; 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(); super.didChangeDependencies();
} }
Future<void> _callback() async {
setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -65,6 +91,35 @@ class _WebMain extends State<WebMain> {
setState(() {}); 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( Text(
"Use the top left menu to show the various pages for the data viewer.", "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( ElevatedButton(
onPressed: () async { onPressed: () async {
SessionData.IsReadOnly = false; await showDialog(
SessionData.Trips = []; context: context,
SessionData.positions = []; builder: (builder) {
SessionData.DisplayError = ""; 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"), child: Text("Close Session"),
), ),
@ -117,7 +221,23 @@ class _WebMain extends State<WebMain> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { 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(() {}); setState(() {});
}, },
child: Text("Load Session", style: TextStyle(fontSize: 18)), child: Text("Load Session", style: TextStyle(fontSize: 18)),

View file

@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:libacflutter/Constants.dart'; import 'package:libacflutter/Constants.dart';
import 'package:timetrack/data.dart'; import 'package:timetrack/data.dart';
class WorkDataPage extends StatefulWidget { class WorkDataPage extends StatefulWidget {
WorkDataPage({super.key}); const WorkDataPage({super.key});
@override @override
State<StatefulWidget> createState() { State<StatefulWidget> createState() {
@ -29,6 +28,15 @@ class _WorkData extends State<WorkDataPage> {
super.dispose(); super.dispose();
} }
Widget GetDurationWidgets() {
return Column(
children: [
Text("Paid Driving Hours: ${SessionData.GetPaidHours()}"),
Text("Unpaid Driving Hours: ${SessionData.GetUnpaidHours()}"),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -46,32 +54,76 @@ class _WorkData extends State<WorkDataPage> {
"Total saved GPS Positions: ${SessionData.positions.length}", "Total saved GPS Positions: ${SessionData.positions.length}",
style: TextStyle(fontSize: 18), style: TextStyle(fontSize: 18),
), ),
Text( if (SessionData.StartTime.year > 2000)
"Start Date & Time: ${SessionData.StartTime.toString()}", Text(
style: TextStyle(fontSize: 18), "Start Date & Time: ${SessionData.StartTime.toString()}",
), style: TextStyle(fontSize: 18),
SizedBox(height: 20), ),
Text( if (SessionData.IsReadOnly && SessionData.StartTime.year > 2000)
"Total Trips: ${SessionData.Trips.length}", Text(
style: TextStyle(fontSize: 18), "End Date & Time: ${SessionData.EndTime.toString()}",
), style: TextStyle(fontSize: 18),
Text( ),
"Total Base Pay: \$${SessionData.GetTotalBasePay()}", if (SessionData.StartTime.year > 2000)
style: TextStyle(fontSize: 18), Text(
), "Total time worked: ${SessionData.GetTotalTimeWorked(SessionData.StartTime, SessionData.EndTime)}",
Text( style: TextStyle(fontSize: 18),
"Total Tips: \$${SessionData.GetTotalTips()}", ),
style: TextStyle(fontSize: 18), if (SessionData.StartTime.year < 2000)
), ListTile(
Text( title: Text("ERROR"),
"Total Earnings: \$${SessionData.GetTotalPay()}", subtitle: Text(
style: TextStyle(fontSize: 18), "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), SizedBox(height: 20),
Text( Text(
"Total Estimated Miles: ${SessionData.GetTotalMilesAsString()}\n(Note: The miles displayed above may not be 100% accurate)", "Total Estimated Miles: ${SessionData.GetTotalMilesAsString()}\n(Note: The miles displayed above may not be 100% accurate)",
style: TextStyle(fontSize: 24), 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"),
),
], ],
), ),
), ),

View file

@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "timetrack"); gtk_window_set_title(window, "Time Tracker");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View file

@ -1,5 +1,5 @@
name: timetrack 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 # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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 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 # 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 # 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. # 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: environment:
sdk: ^3.7.2 sdk: ^3.7.2
@ -36,15 +36,21 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
libac_dart: libac_dart:
hosted: https://git.zontreck.com/api/packages/Packages/pub/ hosted: https://git.zontreck.com/api/packages/Packages/pub/
version: 1.4.20325+1215 version: ^1.4.052725+1339
libacflutter: libacflutter:
hosted: https://git.zontreck.com/api/packages/Packages/pub/ hosted: https://git.zontreck.com/api/packages/Packages/pub/
version: 1.0.31525+0222 version: ^1.0.052725+1354
dio: ^5.8.0+1 dio: ^5.8.0+1
ota_update: ^7.0.1 ota_update: ^7.0.1
geolocator: ^14.0.0 geolocator: ^14.0.0
flutter_map: ^8.1.1 flutter_map: ^8.1.1
latlong2: ^0.9.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: dev_dependencies:
flutter_test: flutter_test:
@ -67,6 +73,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- server/php/timetrack.php
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg

View file

@ -11,47 +11,138 @@ $jsx = json_decode(file_get_contents("php://input"), true);
// Get operation information // Get operation information
// DISCLAIMER: All php code below this point is AI Generated // 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']) { switch($jsx['cmd']) {
case "create": { case "create": {
// Get UUID from MySQL and insert into sessions table $session = create($DB, $jsx, false);
$result = $DB->query("SELECT UUID() AS id");
if (!$result) { echo json_encode(["status" => "ok", "session" => $session]);
http_response_code(500); break;
echo json_encode(["error" => "Failed to generate UUID"]); }
break; // Not AI Generated
} case "createv2": {
$session = create($DB, $jsx, true);
$row = $result->fetch_assoc(); echo json_encode(["status" => "created", "session" => $session]);
$sessionId = $row['id']; break;
}
// Insert into `sessions` table // Not AI Generated
$stmt = $DB->prepare("INSERT INTO `sessions` (`ID`, `timestamp`) VALUES (?, CURRENT_TIMESTAMP())"); case "get_version": {
$stmt->bind_param("s", $sessionId); $ver = get_version($DB, $jsx);
if (!$stmt->execute()) {
http_response_code(500);
echo json_encode(["error" => "Failed to insert into sessions"]); echo json_encode(["status" => "version_back", "version" => $ver]);
break;
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();
$curVer = get_version($DB, $jsx);
// Prepare data as JSON and insert into `data` table $curVer += 1; // We'll push this back to the database momentarily.
$data = json_encode($jsx['data']);
// Now, we update the data blob.
$stmt = $DB->prepare("INSERT INTO `data` (`ID`, `SessionData`) VALUES (?, ?)"); $success = pushNewData($DB, $sessionId, $data);
$null = NULL; // Required for bind_param with blob
$stmt->bind_param("sb", $sessionId, $null); // Temporarily bind $null for blob if($success) {
update_version($DB, $sessionId, $curVer);
// Send the actual blob content using send_long_data die(json_encode(["status" => "patched", "version" => $curVer]));
$stmt->send_long_data(1, $data); // Index 1 refers to the second "?" in bind_param } else {
if (!$stmt->execute()) { die(json_encode(["status" => "failed"]));
http_response_code(500);
echo json_encode(["error" => "Failed to insert into data"]);
break;
} }
$stmt->close();
echo json_encode(["status" => "ok", "session" => $sessionId]);
break; break;
} }
case "get": { case "get": {
@ -74,11 +165,18 @@ switch($jsx['cmd']) {
$stmt->close(); $stmt->close();
// Decode the JSON blob (optional — if you want raw JSON output) // Decode the JSON blob (optional — if you want raw JSON output)
header("Content-Type: application/json"); $testDecoded = json_decode($sessionData, true);
echo $sessionData; 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; break;
} }
} }
?> ?>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before After
Before After

View file

@ -18,7 +18,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <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 --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">

View file

@ -1,35 +1,35 @@
{ {
"name": "timetrack", "name": "Time Tracker",
"short_name": "timetrack", "short_name": "timetrack",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",
"theme_color": "#0175C2", "theme_color": "#0175C2",
"description": "A new Flutter project.", "description": "A simple, yet effective GPS, time, and mile tracker",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [
{ {
"src": "icons/Icon-192.png", "src": "icons/Icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-512.png", "src": "icons/Icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-maskable-192.png", "src": "icons/Icon-maskable-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "icons/Icon-maskable-512.png", "src": "icons/Icon-maskable-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
] ]
} }