diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 4fae3ca..799743d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -25,6 +25,10 @@ pipeline { mv build/app/outputs/bundle/release/app-release.aab build/app/outputs/bundle/release/timetrack.aab + + cd server/php + tar -cvf ../../php.tgz . + cd ../.. ''' } } @@ -34,6 +38,36 @@ pipeline { archiveArtifacts artifacts: "build/app/outputs/flutter-apk/timetrack.apk" archiveArtifacts artifacts: "build/app/outputs/bundle/release/timetrack.aab" + archiveArtifacts artifacts: "php.tgz" + + cleanWs() + } + } + } + + stage("Build Web App") { + agent { + label 'linux' + } + + steps { + script { + sh ''' + #!/bin/bash + + flutter build web + + cd build/web + tar -cvf ../../web.tgz . + cd ../.. + ''' + } + } + + post { + always { + archiveArtifacts artifacts: "web.tgz" + cleanWs() } } diff --git a/README.md b/README.md index 42b994c..86e9546 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,17 @@ The app does not store data locally, due to the way android permissions function # Implementation -- [ ] Basic UI +- [x] Basic UI - [x] Permissions - [x] Automatic updates -- [ ] GPS Tracking -- [ ] Formatting GPS on a viewable map -- [ ] Track driving hours - - [ ] Track trips - - [ ] Track stops/deliveries -- [ ] Track trip base pay - - [ ] Track each delivery's tips +- [x] GPS Tracking +- [x] Formatting GPS on a viewable map +- [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? diff --git a/latest-releases.json b/latest-releases.json index e7c12a6..5922860 100644 --- a/latest-releases.json +++ b/latest-releases.json @@ -1,3 +1,4 @@ { - "alpha": "1.0.0-dev.4" + "alpha": "1.0.0-dev.10", + "beta": "1.0.0-beta.3" } diff --git a/lib/consts.dart b/lib/consts.dart index 8bdd496..85c2227 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -1,13 +1,22 @@ import 'dart:convert'; import 'package:dio/dio.dart'; +import 'package:geolocator/geolocator.dart'; class TTConsts { - static const UPDATE_URL = + static get UPDATE_URL => "https://git.zontreck.com/AriasCreations/TimeTracker/raw/branch/main/latest-releases.json"; - static const VERSION = "1.0.0-dev.4"; + static get SESSION_SERVER => + "https://api.zontreck.com/timetrack/${UPDATE_CHANNEL}/timetrack.php"; + + static const VERSION = "1.0.0-beta.3"; + static bool UPDATE_AVAILABLE = false; - static UpdateChannel UPDATE_CHANNEL = UpdateChannel.alpha; + static UpdateChannel UPDATE_CHANNEL = UpdateChannel.beta; + static final LocationSettings LOCATION_SETTINGS = LocationSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 15, + ); static Future checkUpdate() async { Dio dio = Dio(); diff --git a/lib/data.dart b/lib/data.dart new file mode 100644 index 0000000..460585f --- /dev/null +++ b/lib/data.dart @@ -0,0 +1,385 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:libac_dart/nbt/Stream.dart'; +import 'package:timetrack/consts.dart'; + +class SessionData { + static DateTime StartTime = DateTime(0); + + static bool IsOnTheClock = false; + + static List Trips = []; + + static Delivery? currentDelivery; + static Trip? currentTrip; + static List positions = []; + static late StreamSubscription _listener; + static Callbacks Calls = Callbacks(); + static String LastSessionID = ""; + static String DisplayError = ""; + + /// 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( + positions, + minDistanceMeters: 5, + maxDistanceMeters: 512, + ); + return total; + } + + static String GetTotalMilesAsString() { + double miles = GetTotalMiles(); + if (miles == 0) return "0.0"; + List split = miles.toString().split("."); + StringBuilder out = StringBuilder(); + + out.append(split[0]); + out.append("."); + out.append(split[1].substring(0, 4)); + + return out.toString(); + } + + //** Begin AI Generated code */ + /// Converts meters → miles. + static const _metersPerMile = 1609.344; + + /// Radius of the earth in meters (WGS‑84 mean radius). + static const _earthRadiusM = 6371000.0; + static double _haversineMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + final dLat = _deg2rad(lat2 - lat1); + final dLon = _deg2rad(lon2 - lon1); + + final a = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_deg2rad(lat1)) * + math.cos(_deg2rad(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + + return _earthRadiusM * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _deg2rad(double deg) => deg * math.pi / 180.0; + + /// Returns total miles traveled after basic GPS‑noise cleanup. + /// + /// * [minDistanceMeters] – drop segments shorter than this (jitter). + /// * [maxDistanceMeters] – drop segments longer than this (impossible jump). + static double _totalMilesTraveled( + List positions, { + double minDistanceMeters = 5, + double? maxDistanceMeters, + }) { + if (positions.length < 2) return 0; + + var meters = 0.0; + + for (var i = 1; i < positions.length; i++) { + final p1 = positions[i - 1]; + final p2 = positions[i]; + + final d = _haversineMeters( + p1.latitude, + p1.longitude, + p2.latitude, + p2.longitude, + ); + + if (d < minDistanceMeters) continue; // too small → jitter + if (maxDistanceMeters != null && d > maxDistanceMeters) + continue; // glitch + + meters += d; + } + return meters / _metersPerMile; + } + //** End AI Generated code */ + + static Future Login() async { + StartTime = DateTime.now(); + IsOnTheClock = true; + + bool hasGPS; + + LocationPermission perm; + hasGPS = await Geolocator.isLocationServiceEnabled(); + if (!hasGPS) { + IsOnTheClock = false; + return Future.error("Location services are disabled"); + } + + perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + if (perm == LocationPermission.denied) { + IsOnTheClock = false; + return Future.error("Location permissions are denied"); + } + } + + if (perm == LocationPermission.deniedForever) { + IsOnTheClock = false; + return Future.error( + "Location permissions are denied permanently. Login cannot proceed.", + ); + } + + _listener = Geolocator.getPositionStream( + locationSettings: TTConsts.LOCATION_SETTINGS, + ).listen((pos) { + if (!IsOnTheClock) { + _listener.cancel(); + return; + } + positions.add(pos); + + SessionData.Calls.dispatch(); + }); + } + + static Future Logout() async { + IsOnTheClock = false; + currentDelivery = null; + currentTrip = null; + _listener.cancel(); + + var saveData = SaveData(); + print(saveData); + + Trips = []; + positions = []; + + Dio dio = Dio(); + Map payload = {"cmd": "create", "data": saveData}; + + var reply = await dio.post( + TTConsts.SESSION_SERVER, + data: json.encode(payload), + ); + Map replyJs = json.decode(reply.data as String); + if (replyJs["status"] == "ok") { + print("Successful upload"); + LastSessionID = replyJs['session'] as String; + Calls.dispatch(); + } + } + + static Map SaveData() { + Map saveData = {}; + + List> _trips = []; + for (var trip in Trips) { + _trips.add(trip.toJsonMap()); + } + + List> _pos = []; + for (var pos in positions) { + _pos.add(pos.toJson()); + } + + saveData["trips"] = _trips; + saveData["positions"] = _pos; + + return saveData; + } + + static Future DownloadData() async { + Dio dio = Dio(); + Map payload = {"cmd": "get", "id": LastSessionID}; + + // Send the data, and get the response + var reply = await dio.post( + TTConsts.SESSION_SERVER, + data: json.encode(payload), + ); + + LoadData(reply.data as String); + } + + static void LoadData(String js) { + Map _js = json.decode(js); + if (_js.containsKey("error")) { + LastSessionID = ""; + return; + } + List> _trips = + _js['trips'] as List>; + List> _pos = + _js['positions'] as List>; + + for (var trip in _trips) { + Trips.add(Trip.fromJsonMap(trip)); + } + + for (var position in _pos) { + positions.add(Position.fromMap(position)); + } + + IsReadOnly = true; + } + + static Future GetNewLocation() async { + Position pos = await Geolocator.getCurrentPosition( + locationSettings: TTConsts.LOCATION_SETTINGS, + ); + + return pos; + } + + static Trip GetNewTrip({required double basePay}) { + currentTrip = Trip(BasePay: basePay); + Trips.add(currentTrip!); + return currentTrip!; + } + + static Delivery GetNewDelivery() { + if (currentTrip != null) { + var dropOff = currentTrip!.startNewDelivery(); + ; + currentDelivery = dropOff; + return dropOff; + } else { + throw Exception("A delivery cannot exist without a trip"); + } + } + + static void EndTrip() { + currentDelivery = null; + currentTrip = null; + } +} + +class Delivery { + double TipAmount = 0; + Position? endLocation; + DateTime StartTime = DateTime.now(); + + Delivery() { + StartTime = DateTime.now(); + } + + Future MarkEndLocation() async { + var pos = await SessionData.GetNewLocation(); + endLocation = pos; + } + + Map toJsonMap() { + return { + "tip": TipAmount, + "start": StartTime.toString(), + "endPos": endLocation?.toJson() ?? "incomplete", + }; + } + + static Delivery fromMap(Map jsx) { + Delivery delivery = Delivery(); + delivery.StartTime = DateTime.parse(jsx['start'] as String); + delivery.TipAmount = jsx['tip'] as double; + if (jsx['endPos'] as String == "incomplete") + delivery.endLocation = null; + else + delivery.endLocation = Position.fromMap(jsx['endPos']); + + return delivery; + } +} + +class Trip { + List deliveries = []; + + DateTime StartTime = DateTime(0); + + double BasePay = 0.0; + + Trip({required this.BasePay}) { + StartTime = DateTime.now(); + } + + Delivery startNewDelivery() { + var delivery = Delivery(); + deliveries.add(delivery); + + return delivery; + } + + Map toJsonMap() { + Map _trip = {"start": StartTime.toString(), "pay": BasePay}; + List> _dropOffs = []; + for (var delivery in deliveries) { + _dropOffs.add(delivery.toJsonMap()); + } + + _trip["deliveries"] = _dropOffs; + + return _trip; + } + + static Trip fromJsonMap(Map jsx) { + Trip trip = Trip(BasePay: 0); + trip.BasePay = jsx['pay'] as double; + trip.StartTime = DateTime.parse(jsx['start'] as String); + trip.deliveries = []; + List> _dropOffs = + jsx['deliveries'] as List>; + + for (var dropOff in _dropOffs) { + trip.deliveries.add(Delivery.fromMap(dropOff)); + } + + return trip; + } +} + +class Callbacks { + VoidCallback? HomeCallback; + VoidCallback? MapCallback; + VoidCallback? UpdateSettingsCallback; + VoidCallback? WorkDataCallback; + + void dispatch() { + if (HomeCallback != null) HomeCallback!(); + if (MapCallback != null) MapCallback!(); + if (UpdateSettingsCallback != null) UpdateSettingsCallback!(); + if (WorkDataCallback != null) WorkDataCallback!(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3c1c510..fef79bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,19 @@ import 'package:flutter/material.dart'; import 'package:timetrack/consts.dart'; +import 'package:timetrack/data.dart'; import 'package:timetrack/pages/MainApp.dart'; Future main() async { await TTConsts.checkUpdate(); + var sess = Uri.base.queryParameters["code"] ?? ""; + SessionData.LastSessionID = sess; + if (SessionData.LastSessionID.isNotEmpty) { + await SessionData.DownloadData(); + if (SessionData.LastSessionID.isEmpty) { + // Invalid session token + SessionData.DisplayError = "The URL and or session token is invalid"; + } + } runApp(MainApp()); } diff --git a/lib/pages/HomePage.dart b/lib/pages/HomePage.dart index 70455ad..9d9b7b9 100644 --- a/lib/pages/HomePage.dart +++ b/lib/pages/HomePage.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:libacflutter/Constants.dart'; +import 'package:libacflutter/Prompt.dart'; import 'package:timetrack/consts.dart'; +import 'package:timetrack/data.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -18,6 +21,22 @@ class _HomePageState extends State { super.didChangeDependencies(); } + @override + void initState() { + SessionData.Calls.HomeCallback = call; + super.initState(); + } + + void call() { + setState(() {}); + } + + @override + void dispose() { + SessionData.Calls.HomeCallback = null; + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -51,10 +70,219 @@ class _HomePageState extends State { }, leading: Icon(Icons.update), ), + ListTile( + title: Text("Trip Map"), + leading: Icon(Icons.map), + subtitle: Text( + "View a map of the route\n(NOTE: This is not live, and reflects the current state as of the time the map is opened.)", + ), + onTap: () async { + await Navigator.pushNamed(context, "/map"); + }, + ), + ListTile( + title: Text("Work Data"), + subtitle: Text("View and edit work data"), + leading: Icon(Icons.work_history), + onTap: () async { + // Open up the work data viewer and editor. + // Edit will be disabled for web or read only mode. + await Navigator.pushNamed(context, "/work"); + setState(() {}); + }, + ), + 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 = []; + }); + }, + ), ], ), ), ), + body: SingleChildScrollView( + child: Column( + children: [ + if (!SessionData.IsOnTheClock) + Text( + "Hit engage when you are ready to go online and start tracking location data, and trips.", + style: TextStyle(fontSize: 18), + ), + + if (SessionData.LastSessionID.isNotEmpty) + ListTile( + title: Text("Session ID"), + subtitle: Text("${SessionData.LastSessionID} - Tap to copy"), + onTap: () { + Clipboard.setData( + ClipboardData(text: SessionData.LastSessionID), + ); + }, + ), + if (!SessionData.IsOnTheClock) + Center( + child: ElevatedButton( + onPressed: () async { + setState(() { + SessionData.Login(); + }); + }, + child: Text("ENGAGE"), + ), + ), + + if (SessionData.IsOnTheClock) GetLoggedInWidgets(), + ], + ), + ), + ); + } + + Widget GetTripWidgets() { + return Column( + children: [ + Text( + "Trip started; Base Pay: \$${SessionData.currentTrip!.BasePay}", + style: TextStyle(fontSize: 18), + ), + Text( + "To end both your current delivery, and the trip, tap on END TRIP", + style: TextStyle(fontSize: 18), + ), + Text( + "You are currently on Delivery #${SessionData.currentTrip!.deliveries.length}", + ), + ElevatedButton( + onPressed: () async { + var reply = 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, + ); + }, + ); + + 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"), + ), + ], + ); + } + + Widget GetDeliveryWidgets() { + return Column( + children: [ + ElevatedButton( + onPressed: () async { + var reply = 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, + ); + }, + ); + + 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"), + ), + ], + ); + } + + Widget GetNonTripWidgets() { + return Column( + children: [ + ElevatedButton( + onPressed: () async { + var reply = await showDialog( + context: context, + builder: (builder) { + return InputPrompt( + title: "What is the base pay?", + prompt: "Enter the base pay amount below.", + type: InputPromptType.Number, + ); + }, + ); + 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"), + ), + ElevatedButton( + onPressed: () async { + if (SessionData.currentTrip != null || + SessionData.currentDelivery != null) { + showDialog( + context: context, + builder: (build) { + return AlertDialog( + title: Text("Cannot end work day"), + content: Text( + "You must end the trip and any delivery before you can fully end the work day.", + ), + ); + }, + ); + } else { + SessionData.Logout(); + setState(() {}); + } + }, + child: Text("End work day"), + ), + ], + ); + } + + Widget GetLoggedInWidgets() { + 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", + style: TextStyle(fontSize: 18), + ), + if (SessionData.currentTrip != null) GetTripWidgets(), + if (SessionData.currentDelivery != null) GetDeliveryWidgets(), + if (SessionData.currentTrip == null) GetNonTripWidgets(), + ], ); } } diff --git a/lib/pages/MainApp.dart b/lib/pages/MainApp.dart index 6aa2ec4..087fd6f 100644 --- a/lib/pages/MainApp.dart +++ b/lib/pages/MainApp.dart @@ -1,6 +1,11 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:timetrack/pages/HomePage.dart'; +import 'package:timetrack/pages/MapPage.dart'; import 'package:timetrack/pages/UpdateSettings.dart'; +import 'package:timetrack/pages/WebMainPage.dart'; +import 'package:timetrack/pages/WorkData.dart'; class MainApp extends StatefulWidget { const MainApp({super.key}); @@ -21,7 +26,12 @@ class MainAppState extends State { Widget build(BuildContext context) { return MaterialApp( title: "Time Tracker", - routes: {"/": (ctx) => HomePage(), "/upd": (ctx) => UpdateSettingsPage()}, + routes: { + "/": (ctx) => Platform.isAndroid ? HomePage() : WebMain(), + "/upd": (ctx) => UpdateSettingsPage(), + "/map": (ctx) => MapPage(), + "/work": (ctx) => WorkDataPage(), + }, theme: ThemeData.dark(), ); } diff --git a/lib/pages/MapPage.dart b/lib/pages/MapPage.dart new file mode 100644 index 0000000..fc05285 --- /dev/null +++ b/lib/pages/MapPage.dart @@ -0,0 +1,166 @@ +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'; + +class MapPage extends StatefulWidget { + MapPage({super.key}); + + @override + State createState() { + return _MapPage(); + } +} + +class _MapPage extends State { + MapController controller = MapController(); + LatLng initialPosition = LatLng(0, 0); + List PointMap = []; + List Markers = []; + bool autorefresh = true; + + @override + void initState() { + SessionData.Calls.MapCallback = call; + super.initState(); + } + + void call() { + if (autorefresh) { + didChangeDependencies(); + } + setState(() {}); + } + + @override + void didChangeDependencies() { + PointMap = []; + Markers = []; + + var firstPos = SessionData.positions[0]; + initialPosition = LatLng(firstPos.latitude, firstPos.longitude); + + for (var position in SessionData.positions) { + PointMap.add(LatLng(position.latitude, position.longitude)); + } + + print("Total trips: ${SessionData.Trips.length}"); + + for (var trip in SessionData.Trips) { + for (var dropOff in trip.deliveries) { + Markers.add( + Marker( + point: LatLng( + dropOff.endLocation!.latitude, + dropOff.endLocation!.longitude, + ), + child: Stack( + children: [ + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (builder) { + return AlertDialog( + title: Text( + "Trip #${SessionData.Trips.indexOf(trip) + 1}; DropOff #${trip.deliveries.indexOf(dropOff) + 1}", + ), + content: Text( + "Pay: \$${trip.BasePay}\nTip: \$${dropOff.TipAmount}", + ), + ); + }, + ); + }, + icon: Icon( + Icons.location_on_rounded, + size: 24, + color: Colors.red, + ), + ), + ], + ), + ), + ); + print("Marker added"); + } + } + + setState(() {}); + super.didChangeDependencies(); + } + + @override + void dispose() { + SessionData.Calls.MapCallback = null; + + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Time Tracker - Map View"), + backgroundColor: LibACFlutterConstants.TITLEBAR_COLOR, + actions: [ + IconButton( + onPressed: () async { + didChangeDependencies(); + }, + icon: Icon(Icons.refresh), + ), + IconButton( + onPressed: () { + autorefresh = !autorefresh; + }, + icon: + autorefresh + ? Icon(Icons.play_disabled) + : Icon(Icons.play_circle), + ), + ], + ), + body: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: SafeArea( + child: FlutterMap( + mapController: controller, + options: MapOptions( + minZoom: 1, + maxZoom: 30, + initialZoom: 15, + initialCenter: initialPosition, + keepAlive: false, + ), + children: [ + TileLayer( + urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + userAgentPackageName: "dev.zontreck.timetrack", + ), + PolylineLayer( + polylines: [ + Polyline( + points: PointMap, + color: Colors.blue, + borderStrokeWidth: 8, + borderColor: Colors.blue, + ), + ], + ), + MarkerLayer(markers: Markers), + RichAttributionWidget( + attributions: [ + TextSourceAttribution('OpenStreetMap contributors'), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/UpdateSettings.dart b/lib/pages/UpdateSettings.dart index 5a0acaa..2922068 100644 --- a/lib/pages/UpdateSettings.dart +++ b/lib/pages/UpdateSettings.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:libacflutter/Constants.dart'; import 'package:ota_update/ota_update.dart'; import 'package:timetrack/consts.dart'; +import 'package:timetrack/data.dart'; class UpdateSettingsPage extends StatefulWidget { const UpdateSettingsPage({super.key}); @@ -14,6 +15,22 @@ class UpdateSettingsPage extends StatefulWidget { } class _UpdSet extends State { + @override + void initState() { + SessionData.Calls.UpdateSettingsCallback = call; + super.initState(); + } + + @override + void dispose() { + SessionData.Calls.UpdateSettingsCallback = null; + super.dispose(); + } + + void call() { + setState(() {}); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -76,7 +93,7 @@ class _UpdSet extends State { onTap: () { OtaUpdate() .execute( - "https://ci.zontreck.com/job/Projects/job/Dart/job/Time%20Tracker/job/main/lastSuccessfulBuild/artifact/build/app/outputs/flutter-apk/timetrack.apk", + "https://ci.zontreck.com/job/Projects/job/Dart/job/Time%20Tracker/job/${TTConsts.UPDATE_CHANNEL == UpdateChannel.stable ? "main" : TTConsts.UPDATE_CHANNEL}/lastSuccessfulBuild/artifact/build/app/outputs/flutter-apk/timetrack.apk", destinationFilename: "timetrack.apk", ) .listen((OtaEvent event) { diff --git a/lib/pages/WebMainPage.dart b/lib/pages/WebMainPage.dart new file mode 100644 index 0000000..41db49f --- /dev/null +++ b/lib/pages/WebMainPage.dart @@ -0,0 +1,128 @@ +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'; + +class WebMain extends StatefulWidget { + @override + State createState() { + return _WebMain(); + } +} + +class _WebMain extends State { + TextEditingController sessionIDController = TextEditingController(); + + @override + void didChangeDependencies() { + sessionIDController.text = SessionData.LastSessionID; + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Time Tracker"), + backgroundColor: LibACFlutterConstants.TITLEBAR_COLOR, + ), + drawer: Drawer( + elevation: 8, + child: SingleChildScrollView( + child: Column( + children: [ + DrawerHeader( + child: Column( + children: [ + Text("Time Tracker"), + Text("Created by Tara Piccari"), + Text("Copyright 2025 - Present"), + Text("Version: ${TTConsts.VERSION}"), + ], + ), + ), + if (SessionData.IsReadOnly) + ListTile( + title: Text("Trip Map"), + leading: Icon(Icons.map), + subtitle: Text( + "View a map of the route\n(NOTE: This is not live, and reflects the current state as of the time the map is opened.)", + ), + onTap: () async { + await Navigator.pushNamed(context, "/map"); + }, + ), + if (SessionData.IsReadOnly) + ListTile( + title: Text("Work Data"), + subtitle: Text("View work data"), + leading: Icon(Icons.work_history), + onTap: () async { + // Open up the work data viewer and editor. + // Edit will be disabled for web or read only mode. + await Navigator.pushNamed(context, "/work"); + setState(() {}); + }, + ), + ], + ), + ), + ), + body: Padding( + padding: EdgeInsets.all(8), + child: SingleChildScrollView( + child: Column( + children: [ + // Start doing magic! + if (SessionData.DisplayError.isNotEmpty) + Text(SessionData.DisplayError, style: TextStyle(fontSize: 18)), + // Check what widgets need to be displayed. + if (SessionData.IsReadOnly) GetReadOnlyWidgets(), + if (!SessionData.IsReadOnly) GetLoadWidgets(), + ], + ), + ), + ), + ); + } + + Widget GetReadOnlyWidgets() { + return Column( + children: [ + Text( + "Use the top left menu to show the various pages for the data viewer.", + ), + ElevatedButton( + onPressed: () async { + SessionData.IsReadOnly = false; + SessionData.Trips = []; + SessionData.positions = []; + SessionData.DisplayError = ""; + }, + child: Text("Close Session"), + ), + ], + ); + } + + Widget GetLoadWidgets() { + return Column( + children: [ + // Present a text box for the session ID, and a button for loading. + ListTile(title: Text("Session ID")), + TextField( + controller: sessionIDController, + decoration: InputDecoration(border: OutlineInputBorder()), + ), + ElevatedButton( + onPressed: () async { + await SessionData.DownloadData(); + setState(() {}); + }, + child: Text("Load Session", style: TextStyle(fontSize: 18)), + ), + ], + ); + } +} diff --git a/lib/pages/WorkData.dart b/lib/pages/WorkData.dart new file mode 100644 index 0000000..f99e551 --- /dev/null +++ b/lib/pages/WorkData.dart @@ -0,0 +1,81 @@ +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}); + + @override + State createState() { + return _WorkData(); + } +} + +class _WorkData extends State { + void call() { + setState(() {}); + } + + @override + void initState() { + SessionData.Calls.WorkDataCallback = call; + super.initState(); + } + + @override + void dispose() { + SessionData.Calls.WorkDataCallback = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Time Tracker - Work Data"), + backgroundColor: LibACFlutterConstants.TITLEBAR_COLOR, + ), + body: Padding( + padding: EdgeInsets.all(8), + child: SingleChildScrollView( + child: Column( + children: [ + // This is where we'll display all the work data, like total earnings, and present a editor + Text( + "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), + ), + SizedBox(height: 20), + Text( + "Total Estimated Miles: ${SessionData.GetTotalMilesAsString()}\n(Note: The miles displayed above may not be 100% accurate)", + style: TextStyle(fontSize: 24), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 02c44b6..dd244a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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-dev.4 +version: 1.0.0-beta.3 environment: sdk: ^3.7.2 @@ -42,6 +42,9 @@ dependencies: version: 1.0.31525+0222 dio: ^5.8.0+1 ota_update: ^7.0.1 + geolocator: ^14.0.0 + flutter_map: ^8.1.1 + latlong2: ^0.9.1 dev_dependencies: flutter_test: diff --git a/server/add session.sql b/server/add session.sql new file mode 100644 index 0000000..5c66790 --- /dev/null +++ b/server/add session.sql @@ -0,0 +1 @@ +INSERT INTO `sessions` (`ID`, `timestamp`) VALUES (uuid(), current_timestamp()); \ No newline at end of file diff --git a/server/php/timetrack.php b/server/php/timetrack.php new file mode 100644 index 0000000..f29d28c --- /dev/null +++ b/server/php/timetrack.php @@ -0,0 +1,84 @@ +query("SELECT UUID() AS id"); + if (!$result) { + http_response_code(500); + echo json_encode(["error" => "Failed to generate UUID"]); + break; + } + + $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; + } + $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; + } + + $stmt->close(); + + echo json_encode(["status" => "ok", "session" => $sessionId]); + break; + } + case "get": { + $sessionId = $jsx['id']; + + $stmt = $DB->prepare("SELECT `SessionData` FROM `data` WHERE `ID` = ?"); + $stmt->bind_param("s", $sessionId); + $stmt->execute(); + $stmt->store_result(); + + if ($stmt->num_rows === 0) { + http_response_code(404); + echo json_encode(["error" => "Session not found"]); + $stmt->close(); + break; + } + + $stmt->bind_result($sessionData); + $stmt->fetch(); + $stmt->close(); + + // Decode the JSON blob (optional — if you want raw JSON output) + header("Content-Type: application/json"); + echo $sessionData; + break; + } + + +} +?> \ No newline at end of file