mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Add GPS run foundation: TCX codec, DB, deps
Pure-Dart TCX codec pinned to the PC's RunnerUp parser by a shared golden fixture (Dart byte-compare + pytest through the real verifier), GPS filters and moving-time maths mirroring the PC walk rule, runs / run_points tables (schema v9), PositionSource seam over geolocator, location permissions and foreground service type, map/cache deps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
1020421371
commit
c2544c4a9c
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
@ -0,0 +1,190 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ns3="http://www.garmin.com/xmlschemas/ActivityExtension/v2" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-08-12T15:42:11Z</Id>
|
||||
<Lap StartTime="2026-08-12T15:42:11Z">
|
||||
<TotalTimeSeconds>624</TotalTimeSeconds>
|
||||
<DistanceMeters>983.6306355595589</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:12Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858438076</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294584871</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>110.06611511572041</AltitudeMeters>
|
||||
<DistanceMeters>2.142966628074646</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>54</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:13Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858442867</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294574705</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>109.65041623678366</AltitudeMeters>
|
||||
<DistanceMeters>3.0187163949012756</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>53</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858578090</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294260282</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>102.71014718222068</AltitudeMeters>
|
||||
<DistanceMeters>31.116633534431458</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>65</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:48Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858744365</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294188828</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>101.26072111921606</AltitudeMeters>
|
||||
<DistanceMeters>50.64840912818909</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>61</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:56Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858792955</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294100028</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.58585072961351</AltitudeMeters>
|
||||
<DistanceMeters>58.97427588701248</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:43:42Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858859857</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.292925949</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.60869064520372</AltitudeMeters>
|
||||
<DistanceMeters>140.32738709449768</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
|
||||
<Lap StartTime="2026-08-12T15:52:36Z">
|
||||
<TotalTimeSeconds>626</TotalTimeSeconds>
|
||||
<DistanceMeters>980.7304027080536</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:36Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859968549</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282023519</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.76934097304894</AltitudeMeters>
|
||||
<DistanceMeters>1003.5486820391379</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>59</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859990492</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282025531</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.06562741186055</AltitudeMeters>
|
||||
<DistanceMeters>1005.9941445044242</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:28Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861645306</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.281974721</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.66943152941295</AltitudeMeters>
|
||||
<DistanceMeters>1190.9687945595942</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>55</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:46Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861872213</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282046494</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.35286804810477</AltitudeMeters>
|
||||
<DistanceMeters>1217.0878333947621</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Notes></Notes>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>RunnerUp Pixel 6a with barometer</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
<BuildMajor>0</BuildMajor>
|
||||
<BuildMinor>0</BuildMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>org.runnerup - 2.11.0.1 - 23000372</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1071
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
1071
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
File diff suppressed because it is too large
Load Diff
747
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
747
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
@ -0,0 +1,747 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-11-09T16:30:00Z</Id>
|
||||
<Lap StartTime="2026-11-09T16:30:00Z">
|
||||
<TotalTimeSeconds>1560</TotalTimeSeconds>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8584000</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>0.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8587720</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2949745</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>54.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8591285</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2954757</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.3</AltitudeMeters>
|
||||
<DistanceMeters>107.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8594688</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2960023</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>161.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8597926</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2965524</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>215.997</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8600999</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2971240</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.1</AltitudeMeters>
|
||||
<DistanceMeters>269.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8603909</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2977150</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>324.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8606663</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2983230</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.6</AltitudeMeters>
|
||||
<DistanceMeters>378.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8609269</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2989459</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>432.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8611740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2995813</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>485.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8614092</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3002272</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.2</AltitudeMeters>
|
||||
<DistanceMeters>540.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8616342</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3008814</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.4</AltitudeMeters>
|
||||
<DistanceMeters>594.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8618509</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3015420</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>648.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8620615</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3022071</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>702.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8622683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3028751</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>756.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8624735</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3035441</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>810.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8626796</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3042125</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>864.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8628890</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3048786</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>918.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8631038</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3055406</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>972.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8633263</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3061968</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1026.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8635585</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3068452</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1080.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8638022</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3074837</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>1134.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8640589</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3081103</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>1188.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8643301</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3087227</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>1242.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8646168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3093186</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.6</AltitudeMeters>
|
||||
<DistanceMeters>1296.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8649196</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3098958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>1350.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8652388</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3104521</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.3</AltitudeMeters>
|
||||
<DistanceMeters>1404.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8655746</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3109853</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.1</AltitudeMeters>
|
||||
<DistanceMeters>1457.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8659267</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3114938</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.9</AltitudeMeters>
|
||||
<DistanceMeters>1512.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8662946</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3119758</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.7</AltitudeMeters>
|
||||
<DistanceMeters>1566.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8666773</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3124302</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>1620.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8670740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3128561</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.2</AltitudeMeters>
|
||||
<DistanceMeters>1674.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8674834</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3132533</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>1728.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8679042</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3136218</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.7</AltitudeMeters>
|
||||
<DistanceMeters>1782.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8683351</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3139623</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.4</AltitudeMeters>
|
||||
<DistanceMeters>1836.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8687747</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3142760</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>1890.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8692217</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3145646</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.9</AltitudeMeters>
|
||||
<DistanceMeters>1944.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8696748</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3148301</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.6</AltitudeMeters>
|
||||
<DistanceMeters>1998.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8701329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3150752</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.3</AltitudeMeters>
|
||||
<DistanceMeters>2052.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8705949</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3153027</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.1</AltitudeMeters>
|
||||
<DistanceMeters>2106.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8710599</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3155157</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.8</AltitudeMeters>
|
||||
<DistanceMeters>2160.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8715270</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3157179</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.6</AltitudeMeters>
|
||||
<DistanceMeters>2214.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8719954</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3159128</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.3</AltitudeMeters>
|
||||
<DistanceMeters>2268.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8724644</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3161042</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.1</AltitudeMeters>
|
||||
<DistanceMeters>2322.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8729334</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3162960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.9</AltitudeMeters>
|
||||
<DistanceMeters>2376.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8734016</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3164919</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.7</AltitudeMeters>
|
||||
<DistanceMeters>2430.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8738683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3166958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>2484.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8743329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3169112</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.4</AltitudeMeters>
|
||||
<DistanceMeters>2538.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8747943</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3171416</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>2592.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8752516</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3173901</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>2646.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8757037</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3176595</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2700.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8761495</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3179525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2754.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8765876</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3182709</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2808.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8770168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3186164</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2862.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8774357</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3189900</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2916.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8778429</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3193923</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2970.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8782372</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3198234</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>3024.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8786174</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3202829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>3078.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8789825</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3207698</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>3132.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8793318</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3212829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.5</AltitudeMeters>
|
||||
<DistanceMeters>3186.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8796647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3218205</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>3240.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8799810</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3223808</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.8</AltitudeMeters>
|
||||
<DistanceMeters>3294.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8802809</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3229617</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.0</AltitudeMeters>
|
||||
<DistanceMeters>3348.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8805647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3235609</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.2</AltitudeMeters>
|
||||
<DistanceMeters>3402.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8808333</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3241761</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.4</AltitudeMeters>
|
||||
<DistanceMeters>3456.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8810877</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3248052</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.7</AltitudeMeters>
|
||||
<DistanceMeters>3510.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8813292</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3254458</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.9</AltitudeMeters>
|
||||
<DistanceMeters>3564.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8815595</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3260960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.2</AltitudeMeters>
|
||||
<DistanceMeters>3618.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8817805</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3267536</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.4</AltitudeMeters>
|
||||
<DistanceMeters>3672.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8819942</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3274167</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.7</AltitudeMeters>
|
||||
<DistanceMeters>3726.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8822028</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3280836</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>3780.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8824086</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3287525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>3834.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8826139</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3294217</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>3888.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8828212</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3300896</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>3942.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8830327</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3307543</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>3996.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8832508</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3314142</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>4050.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8834774</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3320674</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>4104.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8837145</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3327118</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>4158.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:56:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8839640</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3333455</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
169
screen_locker/tests/test_run_tcx_golden.py
Normal file
169
screen_locker/tests/test_run_tcx_golden.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Cross-language golden test: the app's TCX is accepted by the real verifier.
|
||||
|
||||
The workout app (Dart, ``lib/services/tcx_codec.dart``) writes the TCX files
|
||||
the PC credits. Each language's own round trip passes happily while the other
|
||||
side's parser disagrees, so the Dart twin
|
||||
(``stronglift_replacement/workout_app/test/services/tcx_golden_fixture_test.dart``)
|
||||
encodes ``fixtures/run_tcx/golden_tracks.json`` and compares the result byte
|
||||
for byte with the ``.tcx`` files checked in next to it. This test feeds those
|
||||
SAME files to the real ``RunnerUpVerificationMixin`` -- date glob, parser,
|
||||
run validation and walk summing -- and asserts the km / minutes the JSON
|
||||
promises. Everything runs on a temporary drop directory: nothing here reads
|
||||
or writes ``~/data/cloud/RunnerUp``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "run_tcx"
|
||||
SAMPLE = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
|
||||
# ``_TCX_SPORT_TO_INT`` code for "Running" in screen_locker/_runnerup_tcx.py.
|
||||
SPORT_RUNNING = 0
|
||||
|
||||
|
||||
def _golden() -> dict[str, dict[str, object]]:
|
||||
return json.loads((FIXTURES / "golden_tracks.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class Verifier(RunnerUpVerificationMixin):
|
||||
"""The real mixin with no phone: only the WebDAV drop directory is read."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.adb = MagicMock(return_value=(False, ""))
|
||||
|
||||
def _has_adb_device(self) -> bool:
|
||||
return False
|
||||
|
||||
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
|
||||
return self.adb(args)
|
||||
|
||||
|
||||
@pytest.fixture(name="inbox")
|
||||
def inbox_fixture(tmp_path: Path) -> Path:
|
||||
"""A temporary WebDAV inbox standing in for ``~/data/cloud/RunnerUp``."""
|
||||
inbox = tmp_path / "cloud" / "RunnerUp"
|
||||
inbox.mkdir(parents=True)
|
||||
return inbox
|
||||
|
||||
|
||||
def _verify(inbox: Path, today: str) -> tuple[str, str] | None:
|
||||
"""Run the real file-based verification on ``inbox`` as if it were ``today``."""
|
||||
with (
|
||||
patch("screen_locker._runnerup_verification.RUNNERUP_WEBDAV_DIRS", (inbox,)),
|
||||
patch("screen_locker._runnerup_verification.today_str", return_value=today),
|
||||
):
|
||||
return Verifier()._verify_runnerup_via_files()
|
||||
|
||||
|
||||
def _drop(inbox: Path, name: str) -> None:
|
||||
shutil.copyfile(FIXTURES / name, inbox / name)
|
||||
|
||||
|
||||
class TestGoldenRun:
|
||||
"""The app's run file passes the PC's run validation unchanged."""
|
||||
|
||||
def test_run_is_verified_with_the_promised_km_and_minutes(
|
||||
self, inbox: Path
|
||||
) -> None:
|
||||
"""Date glob, parser and validation accept the app's run file."""
|
||||
spec = _golden()["running"]
|
||||
_drop(inbox, str(spec["file"]))
|
||||
expect = spec["expect"]
|
||||
assert isinstance(expect, dict)
|
||||
result = _verify(inbox, "2026-11-09")
|
||||
assert result == (
|
||||
expect["status"],
|
||||
f"Running: {expect['distance_km']} km in {expect['minutes']} min",
|
||||
)
|
||||
|
||||
def test_parsed_fields_match_the_json(self, inbox: Path) -> None:
|
||||
"""The parser reads sport, duration and distance as the app wrote them."""
|
||||
spec = _golden()["running"]
|
||||
_drop(inbox, str(spec["file"]))
|
||||
data = Verifier()._pull_and_parse_tcx(str(inbox / str(spec["file"])))
|
||||
assert data is not None
|
||||
assert data["sport"] == SPORT_RUNNING
|
||||
assert data["walking"] is False
|
||||
assert data["duration_seconds"] == spec["total_seconds"]
|
||||
assert data["distance_m"] == pytest.approx(float(str(spec["distance_m"])))
|
||||
assert data["activity_id"] == str(spec["started_at"])
|
||||
|
||||
def test_the_local_date_in_the_name_is_what_is_globbed(self, inbox: Path) -> None:
|
||||
"""Another day does not see the file: the stamp is local, the glob is too."""
|
||||
spec = _golden()["running"]
|
||||
_drop(inbox, str(spec["file"]))
|
||||
assert _verify(inbox, "2026-11-10") is None
|
||||
|
||||
|
||||
class TestGoldenWalk:
|
||||
"""The app's walk file is a walk, and its moving time is what the PC sums."""
|
||||
|
||||
def test_walk_is_verified_with_the_promised_moving_minutes(
|
||||
self, inbox: Path
|
||||
) -> None:
|
||||
"""A walk is summed per day: legs, km and moving minutes as promised."""
|
||||
spec = _golden()["walking"]
|
||||
_drop(inbox, str(spec["file"]))
|
||||
expect = spec["expect"]
|
||||
assert isinstance(expect, dict)
|
||||
result = _verify(inbox, "2026-11-09")
|
||||
assert result == (
|
||||
expect["status"],
|
||||
(
|
||||
f"Walking: {expect['legs']} walk(s), {expect['distance_km']} km, "
|
||||
f"{expect['moving_minutes']} min moving"
|
||||
),
|
||||
)
|
||||
|
||||
def test_pc_moving_time_equals_the_apps_total_seconds(self, inbox: Path) -> None:
|
||||
"""Dart's moving-time rule and the PC's ``track_summary`` agree exactly."""
|
||||
spec = _golden()["walking"]
|
||||
_drop(inbox, str(spec["file"]))
|
||||
data = Verifier()._pull_and_parse_tcx(str(inbox / str(spec["file"])))
|
||||
assert data is not None
|
||||
assert data["walking"] is True
|
||||
assert data["moving_seconds"] == pytest.approx(
|
||||
float(str(spec["total_seconds"]))
|
||||
)
|
||||
assert data["activity_id"] == str(spec["started_at"])
|
||||
|
||||
def test_walk_is_flagged_by_name_even_if_sport_is_not_walking(
|
||||
self, inbox: Path
|
||||
) -> None:
|
||||
"""The app also writes ``Sport="Walking"``; either signal alone suffices."""
|
||||
spec = _golden()["walking"]
|
||||
name = str(spec["file"])
|
||||
body = (FIXTURES / name).read_text(encoding="utf-8")
|
||||
assert 'Sport="Walking"' in body
|
||||
renamed = inbox / "activity.tcx"
|
||||
renamed.write_text(body.replace('Sport="Walking"', 'Sport="Other"'))
|
||||
data = Verifier()._pull_and_parse_tcx(str(renamed))
|
||||
assert data is not None
|
||||
assert data["walking"] is False
|
||||
(inbox / name).write_text(body.replace('Sport="Walking"', 'Sport="Other"'))
|
||||
by_name = Verifier()._pull_and_parse_tcx(str(inbox / name))
|
||||
assert by_name is not None
|
||||
assert by_name["walking"] is True
|
||||
|
||||
|
||||
class TestRunnerUpSample:
|
||||
"""The trimmed real RunnerUp export parses to the numbers Dart's decoder sees."""
|
||||
|
||||
def test_parser_sums_both_laps(self, inbox: Path) -> None:
|
||||
"""Same literals as the Dart decoder test: 624 + 626 s, two laps' metres."""
|
||||
_drop(inbox, SAMPLE)
|
||||
data = Verifier()._pull_and_parse_tcx(str(inbox / SAMPLE))
|
||||
assert data is not None
|
||||
assert data["sport"] == SPORT_RUNNING
|
||||
assert data["walking"] is False
|
||||
assert data["duration_seconds"] == 1250
|
||||
assert data["distance_m"] == pytest.approx(983.6306 + 980.7304, abs=1e-3)
|
||||
assert data["activity_id"] == "2026-08-12T15:42:11Z"
|
||||
@ -14,3 +14,18 @@ Implementing design_v2.md improvements.
|
||||
workout and shows the status-bar notification; notification presses go
|
||||
through a durable prefs queue, never straight into sqflite (the CRDT
|
||||
sync layer owns that database's ordering)
|
||||
|
||||
## GPS runs (foundation, 2026-11-10)
|
||||
- The app records runs/walks itself and uploads a TCX the PC credits unchanged.
|
||||
Contract = the PC parser, pinned by `screen_locker/tests/fixtures/run_tcx/`
|
||||
(Dart `test/services/tcx_golden_fixture_test.dart` <-> pytest
|
||||
`screen_locker/tests/test_run_tcx_golden.py`). Regenerate after a deliberate
|
||||
codec change: `UPDATE_GOLDEN=1 flutter test test/services/tcx_golden_fixture_test.dart`.
|
||||
- File name `RunnerUp_YYYY-MM-DD-HH-MM-SS_<Running|Walking>.tcx`, stamp in
|
||||
LOCAL time; `<Id>`/`<Time>` UTC `Z`; source tag `kuhy workout_app` in
|
||||
`Author/Name` + `Creator/Name`.
|
||||
- Trackpoints must be <= 30 s apart with cumulative `DistanceMeters`, or the PC
|
||||
credits no walking time (`lib/services/run_math.dart` mirrors that rule).
|
||||
- Thresholds that decide credit (3.5 km / 25 min) live on the PC only.
|
||||
- GPS sits behind `PositionSource` (`position_source*.dart`); imported
|
||||
RunnerUp runs are stored with `upload_state='never'` (table CHECK).
|
||||
|
||||
@ -22,6 +22,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- GPS run/walk tracking. FINE is the GPS fix; COARSE must be declared
|
||||
alongside it or Android 12+ cannot offer the approximate choice. The
|
||||
FOREGROUND_SERVICE_LOCATION permission is what lets the same
|
||||
foreground service keep receiving fixes with the screen off (the
|
||||
service type below must name `location` on Android 14+). The runtime
|
||||
grant is requested when a recording starts, never at launch. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<!-- Required by ForegroundTaskOptions(allowWakeLock: true), which takes a
|
||||
PARTIAL_WAKE_LOCK so the deadline fires with the screen off. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
@ -66,10 +76,16 @@
|
||||
additionally demands a runtime ACTIVITY_RECOGNITION/BODY_SENSORS
|
||||
grant on Android 14+, and specialUse needs a Play-review
|
||||
justification property. Neither buys anything for a sideloaded
|
||||
personal app, and dataSync's 6h/24h budget dwarfs a workout. -->
|
||||
personal app, and dataSync's 6h/24h budget dwarfs a workout.
|
||||
|
||||
"location" is added for GPS run tracking: a service whose type
|
||||
list lacks it is not allowed to read the location in the
|
||||
background on Android 14+. The Dart side must pass
|
||||
ForegroundServiceTypes.location (with dataSync) when it starts
|
||||
the service for a recording. -->
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:foregroundServiceType="dataSync|location"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
<!-- adb-only pairing for the phone->PC workout poke. Exported because
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
/// A stored run: the track plus its upload bookkeeping.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
|
||||
/// Whether a run's TCX has reached the PC's dufs inbox.
|
||||
enum RunUploadState {
|
||||
/// Recorded here; not uploaded yet (or the last attempt failed).
|
||||
pending('pending'),
|
||||
|
||||
/// The PC's dufs accepted the file.
|
||||
uploaded('uploaded'),
|
||||
|
||||
/// Must never be uploaded. Every imported RunnerUp run is this: re-sending
|
||||
/// history would credit old runs as new ones.
|
||||
never('never');
|
||||
|
||||
const RunUploadState(this.storageKey);
|
||||
|
||||
/// The value in the `runs.upload_state` column.
|
||||
final String storageKey;
|
||||
|
||||
/// The state stored as [key]; throws on an unknown key.
|
||||
static RunUploadState fromStorageKey(String key) =>
|
||||
values.firstWhere((s) => s.storageKey == key);
|
||||
}
|
||||
|
||||
/// One row of the `runs` table with its points (empty when listed).
|
||||
@immutable
|
||||
class RunRecord {
|
||||
/// Creates a record.
|
||||
const RunRecord({
|
||||
required this.track,
|
||||
required this.uploadState,
|
||||
required this.tcxFilename,
|
||||
required this.tcxSha256,
|
||||
});
|
||||
|
||||
/// The run; `track.points` is empty in a list result.
|
||||
final RunTrack track;
|
||||
|
||||
/// Upload bookkeeping.
|
||||
final RunUploadState uploadState;
|
||||
|
||||
/// The export file name (see `tcxFilename`).
|
||||
final String tcxFilename;
|
||||
|
||||
/// Hex SHA-256 of the encoded TCX: the dedupe key.
|
||||
final String tcxSha256;
|
||||
}
|
||||
155
stronglift_replacement/workout_app/lib/models/run_track.dart
Normal file
155
stronglift_replacement/workout_app/lib/models/run_track.dart
Normal file
@ -0,0 +1,155 @@
|
||||
/// A recorded run or walk: the one shape the recorder, the TCX codec, the
|
||||
/// database and the map all share.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// The sports the app records. The name doubles as the TCX `Sport` attribute
|
||||
/// and the file-name suffix the PC keys on (`..._Walking.tcx`).
|
||||
enum RunSport {
|
||||
/// A run; credited on distance OR duration by the PC.
|
||||
running('Running'),
|
||||
|
||||
/// A walk; the PC sums a day's walks and credits their moving time.
|
||||
walking('Walking');
|
||||
|
||||
const RunSport(this.tcxName);
|
||||
|
||||
/// `Sport="..."` on the TCX `Activity` and `_<name>.tcx` in the file name.
|
||||
final String tcxName;
|
||||
|
||||
/// The sport named [tcxName], or null for anything the app does not record.
|
||||
static RunSport? fromTcxName(String? tcxName) {
|
||||
for (final sport in values) {
|
||||
if (sport.tcxName == tcxName) return sport;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a track came from; stored as `runs.source`.
|
||||
enum RunSource {
|
||||
/// Recorded by this app.
|
||||
app('app'),
|
||||
|
||||
/// Parsed from a RunnerUp export (history import).
|
||||
runnerupImport('runnerup_import');
|
||||
|
||||
const RunSource(this.storageKey);
|
||||
|
||||
/// The value in the `runs.source` column.
|
||||
final String storageKey;
|
||||
|
||||
/// The source stored as [key]; throws on an unknown key.
|
||||
static RunSource fromStorageKey(String key) =>
|
||||
values.firstWhere((s) => s.storageKey == key);
|
||||
}
|
||||
|
||||
/// One accepted GPS fix.
|
||||
@immutable
|
||||
class RunPoint {
|
||||
/// Creates a point; [time] is normalised to UTC.
|
||||
RunPoint({
|
||||
required DateTime time,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.distanceM,
|
||||
this.altitudeM,
|
||||
}) : time = time.toUtc();
|
||||
|
||||
/// When the fix was taken (UTC).
|
||||
final DateTime time;
|
||||
|
||||
/// Degrees north.
|
||||
final double latitude;
|
||||
|
||||
/// Degrees east.
|
||||
final double longitude;
|
||||
|
||||
/// Metres above sea level, when the device reports it.
|
||||
final double? altitudeM;
|
||||
|
||||
/// Cumulative credited distance from the start, in metres.
|
||||
///
|
||||
/// The PC derives walking speed from the difference between two points'
|
||||
/// values, so this is the field the recorder must keep honest.
|
||||
final double distanceM;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is RunPoint &&
|
||||
other.time == time &&
|
||||
other.latitude == latitude &&
|
||||
other.longitude == longitude &&
|
||||
other.altitudeM == altitudeM &&
|
||||
other.distanceM == distanceM;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(time, latitude, longitude, altitudeM, distanceM);
|
||||
}
|
||||
|
||||
/// A finished (or in-progress) track.
|
||||
@immutable
|
||||
class RunTrack {
|
||||
/// Creates a track; [startedAt] is normalised to UTC.
|
||||
RunTrack({
|
||||
required this.id,
|
||||
required this.sport,
|
||||
required DateTime startedAt,
|
||||
required this.points,
|
||||
required this.totalSeconds,
|
||||
required this.distanceM,
|
||||
this.source = RunSource.app,
|
||||
}) : startedAt = startedAt.toUtc();
|
||||
|
||||
/// A track whose [id] is derived from [startedAt] (see [idFor]).
|
||||
factory RunTrack.started({
|
||||
required RunSport sport,
|
||||
required DateTime startedAt,
|
||||
required List<RunPoint> points,
|
||||
required int totalSeconds,
|
||||
required double distanceM,
|
||||
RunSource source = RunSource.app,
|
||||
}) => RunTrack(
|
||||
id: idFor(startedAt),
|
||||
sport: sport,
|
||||
startedAt: startedAt,
|
||||
points: points,
|
||||
totalSeconds: totalSeconds,
|
||||
distanceM: distanceM,
|
||||
source: source,
|
||||
);
|
||||
|
||||
/// The TCX `<Id>`: the start instant, UTC, second precision.
|
||||
///
|
||||
/// The PC deduplicates a run's copies (inbox and `processed/`) by this
|
||||
/// string, so two different runs must never share one.
|
||||
static String idFor(DateTime startedAt) {
|
||||
final u = startedAt.toUtc();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${u.year.toString().padLeft(4, '0')}-${two(u.month)}-${two(u.day)}'
|
||||
'T${two(u.hour)}:${two(u.minute)}:${two(u.second)}Z';
|
||||
}
|
||||
|
||||
/// The TCX `<Id>` and the database key.
|
||||
final String id;
|
||||
|
||||
/// Running or walking.
|
||||
final RunSport sport;
|
||||
|
||||
/// When recording started (UTC).
|
||||
final DateTime startedAt;
|
||||
|
||||
/// Accepted fixes in time order.
|
||||
final List<RunPoint> points;
|
||||
|
||||
/// Moving time in seconds (not elapsed): what the PC reads as the duration.
|
||||
final int totalSeconds;
|
||||
|
||||
/// Credited distance in metres.
|
||||
final double distanceM;
|
||||
|
||||
/// Recorded here or imported.
|
||||
final RunSource source;
|
||||
}
|
||||
135
stronglift_replacement/workout_app/lib/services/gps_filter.dart
Normal file
135
stronglift_replacement/workout_app/lib/services/gps_filter.dart
Normal file
@ -0,0 +1,135 @@
|
||||
/// Turns raw GPS fixes into the accepted points of a track.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/run_math.dart';
|
||||
|
||||
/// Worst horizontal accuracy (metres, 68 % radius) a fix may report.
|
||||
const double kMaxFixAccuracyM = 25;
|
||||
|
||||
/// Consecutive "too fast" fixes after which the filter concludes its anchor
|
||||
/// was the bad one and re-anchors on the newest fix.
|
||||
const int kMaxConsecutiveJumps = 5;
|
||||
|
||||
/// One reading from the platform's location provider.
|
||||
@immutable
|
||||
class GpsFix {
|
||||
/// Creates a fix.
|
||||
const GpsFix({
|
||||
required this.time,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.accuracyM,
|
||||
this.altitudeM,
|
||||
this.isMocked = false,
|
||||
});
|
||||
|
||||
/// When the provider took the fix.
|
||||
final DateTime time;
|
||||
|
||||
/// Degrees north.
|
||||
final double latitude;
|
||||
|
||||
/// Degrees east.
|
||||
final double longitude;
|
||||
|
||||
/// Metres above sea level, when known.
|
||||
final double? altitudeM;
|
||||
|
||||
/// Horizontal accuracy radius in metres.
|
||||
final double accuracyM;
|
||||
|
||||
/// Whether Android flagged the fix as coming from a mock provider.
|
||||
final bool isMocked;
|
||||
}
|
||||
|
||||
/// Why [GpsFilter.add] kept or dropped a fix.
|
||||
enum FixVerdict {
|
||||
/// Kept as a new point.
|
||||
accepted,
|
||||
|
||||
/// Dropped: a mock-location fix (faking a run must not credit it).
|
||||
mocked,
|
||||
|
||||
/// Dropped: accuracy worse than [kMaxFixAccuracyM], or not a number.
|
||||
inaccurate,
|
||||
|
||||
/// Dropped: not strictly later (whole seconds) than the last point.
|
||||
outOfOrder,
|
||||
|
||||
/// Dropped: implies a speed above the sport's cap, i.e. a GPS jump.
|
||||
tooFast,
|
||||
}
|
||||
|
||||
/// Accumulates accepted points and their cumulative credited distance.
|
||||
///
|
||||
/// A step between two accepted points adds distance only when it is a moving
|
||||
/// step (see [isMovingStep]): a dropout longer than [kMaxCreditGapSeconds]
|
||||
/// and standing-still jitter credit nothing, same as the PC reads a walk.
|
||||
class GpsFilter {
|
||||
/// Creates a filter for [sport] (which picks the speed cap).
|
||||
GpsFilter({required this.sport});
|
||||
|
||||
/// The sport being recorded.
|
||||
final RunSport sport;
|
||||
|
||||
final List<RunPoint> _points = [];
|
||||
int _jumps = 0;
|
||||
|
||||
/// Accepted points so far, in time order.
|
||||
List<RunPoint> get points => List.unmodifiable(_points);
|
||||
|
||||
/// Cumulative credited distance in metres.
|
||||
double get distanceM => _points.isEmpty ? 0 : _points.last.distanceM;
|
||||
|
||||
/// Judges [fix]; an accepted fix is appended to [points].
|
||||
FixVerdict add(GpsFix fix) {
|
||||
if (fix.isMocked) return FixVerdict.mocked;
|
||||
if (!(fix.accuracyM >= 0 && fix.accuracyM <= kMaxFixAccuracyM)) {
|
||||
return FixVerdict.inaccurate;
|
||||
}
|
||||
// Whole seconds: that is all a TCX trackpoint can say.
|
||||
final time = DateTime.fromMillisecondsSinceEpoch(
|
||||
fix.time.millisecondsSinceEpoch ~/ 1000 * 1000,
|
||||
isUtc: true,
|
||||
);
|
||||
if (_points.isEmpty) {
|
||||
_accept(fix, time, 0);
|
||||
return FixVerdict.accepted;
|
||||
}
|
||||
final last = _points.last;
|
||||
final gap = time.difference(last.time).inSeconds;
|
||||
if (gap <= 0) return FixVerdict.outOfOrder;
|
||||
final step = haversineM(
|
||||
last.latitude,
|
||||
last.longitude,
|
||||
fix.latitude,
|
||||
fix.longitude,
|
||||
);
|
||||
if (step / gap > maxSpeedMps(sport)) {
|
||||
_jumps++;
|
||||
if (_jumps < kMaxConsecutiveJumps) return FixVerdict.tooFast;
|
||||
// The anchor, not the fixes, was wrong: continue from here, no credit.
|
||||
_accept(fix, time, 0);
|
||||
return FixVerdict.accepted;
|
||||
}
|
||||
final moving =
|
||||
gap <= kMaxCreditGapSeconds && step / gap >= kMinMovingSpeedMps;
|
||||
_accept(fix, time, moving ? step : 0);
|
||||
return FixVerdict.accepted;
|
||||
}
|
||||
|
||||
void _accept(GpsFix fix, DateTime time, double credited) {
|
||||
_jumps = 0;
|
||||
_points.add(
|
||||
RunPoint(
|
||||
time: time,
|
||||
latitude: fix.latitude,
|
||||
longitude: fix.longitude,
|
||||
altitudeM: fix.altitudeM,
|
||||
distanceM: distanceM + credited,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
/// The seam between the recorder and the platform's location provider.
|
||||
///
|
||||
/// Everything above this interface (recorder, map, sandbox replay) is pure
|
||||
/// Dart and runs on the Linux test host and the desktop target; only
|
||||
/// `GeolocatorPositionSource` touches the geolocator plugin.
|
||||
library;
|
||||
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
|
||||
/// Whether the app may read the location right now.
|
||||
enum LocationAccess {
|
||||
/// Permission granted and the location service is on.
|
||||
granted,
|
||||
|
||||
/// Permission refused this time; asking again may work.
|
||||
denied,
|
||||
|
||||
/// Permission refused for good; only the system settings can change it.
|
||||
deniedForever,
|
||||
|
||||
/// Permission is fine but the device's location switch is off.
|
||||
serviceOff;
|
||||
|
||||
/// Whether fixes can be read right now.
|
||||
bool get isGranted => this == LocationAccess.granted;
|
||||
}
|
||||
|
||||
/// A stream of GPS fixes plus the permission handshake that guards it.
|
||||
abstract class PositionSource {
|
||||
/// Asks for (or re-checks) location permission; never throws for a refusal.
|
||||
Future<LocationAccess> requestAccess();
|
||||
|
||||
/// Fixes roughly every [interval], best accuracy, until cancelled.
|
||||
///
|
||||
/// [interval] must stay well under the PC's 30 s credit gap
|
||||
/// (`kMaxCreditGapSeconds`): a quieter stream earns nothing.
|
||||
Stream<GpsFix> fixes({Duration interval = const Duration(seconds: 2)});
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
/// The real [PositionSource], backed by the geolocator plugin.
|
||||
library;
|
||||
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
import 'package:workout_app/services/position_source.dart';
|
||||
|
||||
/// Reads fixes through [GeolocatorPlatform.instance], so a test can swap the
|
||||
/// platform and drive this class without a device.
|
||||
class GeolocatorPositionSource implements PositionSource {
|
||||
/// Creates the source; [forceLocationManager] skips Google Play services.
|
||||
GeolocatorPositionSource({this.forceLocationManager = false});
|
||||
|
||||
/// Android: use the legacy LocationManager instead of the fused provider.
|
||||
final bool forceLocationManager;
|
||||
|
||||
GeolocatorPlatform get _platform => GeolocatorPlatform.instance;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> requestAccess() async {
|
||||
if (!await _platform.isLocationServiceEnabled()) {
|
||||
return LocationAccess.serviceOff;
|
||||
}
|
||||
var permission = await _platform.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await _platform.requestPermission();
|
||||
}
|
||||
return switch (permission) {
|
||||
LocationPermission.always ||
|
||||
LocationPermission.whileInUse => LocationAccess.granted,
|
||||
LocationPermission.deniedForever => LocationAccess.deniedForever,
|
||||
LocationPermission.denied ||
|
||||
LocationPermission.unableToDetermine => LocationAccess.denied,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<GpsFix> fixes({Duration interval = const Duration(seconds: 2)}) =>
|
||||
_platform
|
||||
.getPositionStream(
|
||||
locationSettings: AndroidSettings(
|
||||
accuracy: LocationAccuracy.bestForNavigation,
|
||||
intervalDuration: interval,
|
||||
forceLocationManager: forceLocationManager,
|
||||
),
|
||||
)
|
||||
.map(
|
||||
(p) => GpsFix(
|
||||
time: p.timestamp,
|
||||
latitude: p.latitude,
|
||||
longitude: p.longitude,
|
||||
altitudeM: p.altitude,
|
||||
accuracyM: p.accuracy,
|
||||
isMocked: p.isMocked,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/// A [PositionSource] that replays a fixed list of fixes.
|
||||
///
|
||||
/// For widget and recorder tests and for the sandbox flavor's replayed run:
|
||||
/// no plugin, no permission prompt, deterministic.
|
||||
library;
|
||||
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
import 'package:workout_app/services/position_source.dart';
|
||||
|
||||
/// Emits [script] once, in order, then closes the stream.
|
||||
class ScriptedPositionSource implements PositionSource {
|
||||
/// Creates a source that answers [access] and replays [script].
|
||||
///
|
||||
/// With [fixDelay] set, each fix is emitted that long after the previous
|
||||
/// one (a real-time replay); otherwise they arrive back to back.
|
||||
ScriptedPositionSource(
|
||||
this.script, {
|
||||
this.access = LocationAccess.granted,
|
||||
this.fixDelay,
|
||||
});
|
||||
|
||||
/// The fixes to replay.
|
||||
final List<GpsFix> script;
|
||||
|
||||
/// What [requestAccess] answers.
|
||||
final LocationAccess access;
|
||||
|
||||
/// Pause before each fix, or null for none.
|
||||
final Duration? fixDelay;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> requestAccess() async => access;
|
||||
|
||||
@override
|
||||
Stream<GpsFix> fixes({
|
||||
Duration interval = const Duration(seconds: 2),
|
||||
}) async* {
|
||||
for (final fix in script) {
|
||||
if (fixDelay != null) await Future<void>.delayed(fixDelay!);
|
||||
yield fix;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
/// Distance and moving-time maths for recorded tracks (pure, no I/O).
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
|
||||
/// Mean Earth radius in metres (IUGG), the haversine constant.
|
||||
const double kEarthRadiusM = 6371008.8;
|
||||
|
||||
/// A pair of fixes further apart in time than this earns neither distance
|
||||
/// nor moving time: a GPS dropout whose motion is unknown.
|
||||
///
|
||||
/// Mirrors `WALK_MAX_GAP_SECONDS` in the PC's `screen_locker/_constants.py`.
|
||||
/// This is a mirror of the rule the PC applies to a walk's trackpoints, so the
|
||||
/// app never reports moving time the PC would discard -- it is NOT a crediting
|
||||
/// threshold (those stay on the PC).
|
||||
const int kMaxCreditGapSeconds = 30;
|
||||
|
||||
/// A pair slower than this is standing still, not moving.
|
||||
///
|
||||
/// Mirrors the lower bound of `WALK_SPEED_MPS` on the PC.
|
||||
const double kMinMovingSpeedMps = 0.5;
|
||||
|
||||
/// Speed cap above which a fix is a GPS jump, 25 km/h for a run.
|
||||
const double kRunningMaxSpeedMps = 25 / 3.6;
|
||||
|
||||
/// Speed cap above which a fix is a GPS jump, 8 km/h for a walk.
|
||||
const double kWalkingMaxSpeedMps = 8 / 3.6;
|
||||
|
||||
/// The jump cap for [sport].
|
||||
double maxSpeedMps(RunSport sport) => switch (sport) {
|
||||
RunSport.running => kRunningMaxSpeedMps,
|
||||
RunSport.walking => kWalkingMaxSpeedMps,
|
||||
};
|
||||
|
||||
double _rad(double degrees) => degrees * math.pi / 180;
|
||||
|
||||
/// Great-circle distance in metres between two WGS-84 positions.
|
||||
double haversineM(double lat1, double lon1, double lat2, double lon2) {
|
||||
final dLat = _rad(lat2 - lat1);
|
||||
final dLon = _rad(lon2 - lon1);
|
||||
final a =
|
||||
math.pow(math.sin(dLat / 2), 2) +
|
||||
math.cos(_rad(lat1)) *
|
||||
math.cos(_rad(lat2)) *
|
||||
math.pow(math.sin(dLon / 2), 2);
|
||||
return 2 * kEarthRadiusM * math.asin(math.min(1, math.sqrt(a)));
|
||||
}
|
||||
|
||||
/// Whether the step from [a] to [b] counts as moving: seen within
|
||||
/// [kMaxCreditGapSeconds] and travelled at a speed in
|
||||
/// `[kMinMovingSpeedMps, maxSpeed]`.
|
||||
///
|
||||
/// Speed comes from the cumulative `distanceM` difference, exactly as the PC
|
||||
/// reads a TCX, so app and PC agree on which steps count.
|
||||
bool isMovingStep(RunPoint a, RunPoint b, double maxSpeed) {
|
||||
final gap = b.time.difference(a.time).inMilliseconds / 1000;
|
||||
if (gap <= 0 || gap > kMaxCreditGapSeconds) return false;
|
||||
final speed = (b.distanceM - a.distanceM) / gap;
|
||||
return speed >= kMinMovingSpeedMps && speed <= maxSpeed;
|
||||
}
|
||||
|
||||
/// Moving seconds over [points]: the summed gaps of every moving step.
|
||||
int movingSeconds(List<RunPoint> points, RunSport sport) {
|
||||
final cap = maxSpeedMps(sport);
|
||||
var total = 0.0;
|
||||
for (var i = 1; i < points.length; i++) {
|
||||
if (isMovingStep(points[i - 1], points[i], cap)) {
|
||||
total += points[i].time.difference(points[i - 1].time).inSeconds;
|
||||
}
|
||||
}
|
||||
return total.round();
|
||||
}
|
||||
@ -14,6 +14,8 @@ import 'package:workout_app/models/exercise.dart';
|
||||
import 'package:workout_app/models/exercise_state.dart';
|
||||
import 'package:workout_app/models/manual_deload.dart';
|
||||
import 'package:workout_app/models/progression.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/models/workout_plan.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/manual_workout_port.dart';
|
||||
@ -31,6 +33,7 @@ part 'storage_service_exercises.dart';
|
||||
part 'storage_service_export.dart';
|
||||
part 'storage_service_import.dart';
|
||||
part 'storage_service_pc_link.dart';
|
||||
part 'storage_service_runs.dart';
|
||||
part 'storage_service_sandbox.dart';
|
||||
part 'storage_service_schema.dart';
|
||||
part 'storage_service_sessions.dart';
|
||||
@ -94,7 +97,7 @@ class StorageService {
|
||||
// coverage:ignore-end
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
version: 8,
|
||||
version: 9,
|
||||
onCreate: _createSchema,
|
||||
onUpgrade: _migrateSchema,
|
||||
// In tests resetForTesting() reopens a ':memory:' DB per test; sqflite's
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
// Recorded and imported runs: the v9 `runs` / `run_points` tables.
|
||||
//
|
||||
// A `part` of StorageService for the same reason as the other parts: it needs
|
||||
// `_db`, and runs must not grow a second storage layer next to the schema.
|
||||
part of 'storage_service.dart';
|
||||
|
||||
/// The v9 tables, shared by create and migrate.
|
||||
///
|
||||
/// `tcx_sha256` is UNIQUE so the same export can never be stored twice (an
|
||||
/// import re-run, or a recorded run that also came back from the PC). The
|
||||
/// CHECK keeps the one rule that must hold even if app code is wrong: an
|
||||
/// imported run is never uploaded.
|
||||
const _runsTables = [
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
sport TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
total_seconds INTEGER NOT NULL,
|
||||
distance_m REAL NOT NULL,
|
||||
source TEXT NOT NULL CHECK (source IN ('app', 'runnerup_import')),
|
||||
upload_state TEXT NOT NULL
|
||||
CHECK (upload_state IN ('pending', 'uploaded', 'never')),
|
||||
tcx_filename TEXT NOT NULL,
|
||||
tcx_sha256 TEXT NOT NULL UNIQUE,
|
||||
CHECK (source <> 'runnerup_import' OR upload_state = 'never')
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS run_points (
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
alt REAL,
|
||||
distance_m REAL NOT NULL,
|
||||
PRIMARY KEY (run_id, seq)
|
||||
) WITHOUT ROWID
|
||||
''',
|
||||
];
|
||||
|
||||
/// Run persistence.
|
||||
extension StorageServiceRuns on StorageService {
|
||||
/// Stores [track]; returns false (storing nothing) when a run with the same
|
||||
/// [tcxSha256] or id is already there.
|
||||
///
|
||||
/// Throws [ArgumentError] for an imported run that is not
|
||||
/// [RunUploadState.never].
|
||||
Future<bool> insertRun(
|
||||
RunTrack track, {
|
||||
required String tcxFilename,
|
||||
required String tcxSha256,
|
||||
required RunUploadState uploadState,
|
||||
}) async {
|
||||
if (track.source == RunSource.runnerupImport &&
|
||||
uploadState != RunUploadState.never) {
|
||||
throw ArgumentError.value(
|
||||
uploadState,
|
||||
'uploadState',
|
||||
'an imported run must be RunUploadState.never',
|
||||
);
|
||||
}
|
||||
return await _db.transaction((txn) async {
|
||||
final clash = await txn.query(
|
||||
'runs',
|
||||
columns: ['id'],
|
||||
where: 'id = ? OR tcx_sha256 = ?',
|
||||
whereArgs: [track.id, tcxSha256],
|
||||
);
|
||||
if (clash.isNotEmpty) return false;
|
||||
await txn.insert('runs', {
|
||||
'id': track.id,
|
||||
'sport': track.sport.tcxName,
|
||||
'started_at': track.startedAt.millisecondsSinceEpoch,
|
||||
'total_seconds': track.totalSeconds,
|
||||
'distance_m': track.distanceM,
|
||||
'source': track.source.storageKey,
|
||||
'upload_state': uploadState.storageKey,
|
||||
'tcx_filename': tcxFilename,
|
||||
'tcx_sha256': tcxSha256,
|
||||
});
|
||||
final batch = txn.batch();
|
||||
for (final (seq, p) in track.points.indexed) {
|
||||
batch.insert('run_points', {
|
||||
'run_id': track.id,
|
||||
'seq': seq,
|
||||
'time_ms': p.time.millisecondsSinceEpoch,
|
||||
'lat': p.latitude,
|
||||
'lon': p.longitude,
|
||||
'alt': p.altitudeM,
|
||||
'distance_m': p.distanceM,
|
||||
});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a run with this TCX hash is stored.
|
||||
Future<bool> runExistsBySha(String tcxSha256) async => (await _db.query(
|
||||
'runs',
|
||||
columns: ['id'],
|
||||
where: 'tcx_sha256 = ?',
|
||||
whereArgs: [tcxSha256],
|
||||
)).isNotEmpty;
|
||||
|
||||
/// Every run, newest first, without points.
|
||||
Future<List<RunRecord>> listRuns({RunUploadState? uploadState}) async {
|
||||
final rows = await _db.query(
|
||||
'runs',
|
||||
where: uploadState == null ? null : 'upload_state = ?',
|
||||
whereArgs: uploadState == null ? null : [uploadState.storageKey],
|
||||
orderBy: 'started_at DESC',
|
||||
);
|
||||
return rows.map((r) => _runFromRow(r, const [])).toList();
|
||||
}
|
||||
|
||||
/// The run [id] with its points, or null.
|
||||
Future<RunRecord?> getRun(String id) async {
|
||||
final rows = await _db.query('runs', where: 'id = ?', whereArgs: [id]);
|
||||
if (rows.isEmpty) return null;
|
||||
final pointRows = await _db.query(
|
||||
'run_points',
|
||||
where: 'run_id = ?',
|
||||
whereArgs: [id],
|
||||
orderBy: 'seq',
|
||||
);
|
||||
final points = pointRows
|
||||
.map(
|
||||
(r) => RunPoint(
|
||||
time: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['time_ms']! as int,
|
||||
isUtc: true,
|
||||
),
|
||||
latitude: r['lat']! as double,
|
||||
longitude: r['lon']! as double,
|
||||
altitudeM: r['alt'] as double?,
|
||||
distanceM: r['distance_m']! as double,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return _runFromRow(rows.first, points);
|
||||
}
|
||||
|
||||
/// Moves run [id] to [state]; false when it does not exist.
|
||||
///
|
||||
/// An imported run cannot leave [RunUploadState.never] (the table's CHECK
|
||||
/// rejects it).
|
||||
Future<bool> setRunUploadState(String id, RunUploadState state) async =>
|
||||
await _db.update(
|
||||
'runs',
|
||||
{'upload_state': state.storageKey},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
) >
|
||||
0;
|
||||
|
||||
/// Deletes run [id] and its points; false when it does not exist.
|
||||
Future<bool> deleteRun(String id) async => await _db.transaction((txn) async {
|
||||
await txn.delete('run_points', where: 'run_id = ?', whereArgs: [id]);
|
||||
return await txn.delete('runs', where: 'id = ?', whereArgs: [id]) > 0;
|
||||
});
|
||||
|
||||
RunRecord _runFromRow(Map<String, Object?> r, List<RunPoint> points) =>
|
||||
RunRecord(
|
||||
track: RunTrack(
|
||||
id: r['id']! as String,
|
||||
sport: RunSport.fromTcxName(r['sport']! as String)!,
|
||||
startedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['started_at']! as int,
|
||||
isUtc: true,
|
||||
),
|
||||
points: points,
|
||||
totalSeconds: r['total_seconds']! as int,
|
||||
distanceM: r['distance_m']! as double,
|
||||
source: RunSource.fromStorageKey(r['source']! as String),
|
||||
),
|
||||
uploadState: RunUploadState.fromStorageKey(
|
||||
r['upload_state']! as String,
|
||||
),
|
||||
tcxFilename: r['tcx_filename']! as String,
|
||||
tcxSha256: r['tcx_sha256']! as String,
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,8 @@ extension StorageServiceSandbox on StorageService {
|
||||
Future<void> wipeAll() async {
|
||||
for (final table in const [
|
||||
'workout_history',
|
||||
'run_points',
|
||||
'runs',
|
||||
'progression_events',
|
||||
'active_session',
|
||||
'settings',
|
||||
|
||||
@ -83,6 +83,9 @@ extension StorageServiceSchema on StorageService {
|
||||
)
|
||||
''');
|
||||
await db.execute(_progressionEventsTable);
|
||||
for (final ddl in _runsTables) {
|
||||
await db.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateSchema(
|
||||
@ -146,6 +149,12 @@ extension StorageServiceSchema on StorageService {
|
||||
await db.execute('ALTER TABLE $table ADD COLUMN $_updatedAtColumn');
|
||||
}
|
||||
}
|
||||
if (oldVersion < 9) {
|
||||
// New tables, nothing to backfill: runs before v9 were RunnerUp's.
|
||||
for (final ddl in _runsTables) {
|
||||
await db.execute(ddl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDefaultsIfNeeded() async {
|
||||
|
||||
240
stronglift_replacement/workout_app/lib/services/tcx_codec.dart
Normal file
240
stronglift_replacement/workout_app/lib/services/tcx_codec.dart
Normal file
@ -0,0 +1,240 @@
|
||||
/// Reads and writes the TCX files the PC's RunnerUp verifier credits.
|
||||
///
|
||||
/// The contract is the PC's parser (`screen_locker/_runnerup_tcx.py`,
|
||||
/// `_walk_tcx.py`), pinned by a shared golden fixture on both sides:
|
||||
///
|
||||
/// * File name `RunnerUp_YYYY-MM-DD-HH-MM-SS_<Sport>.tcx`, stamp in LOCAL
|
||||
/// time (the PC globs today's local date in the name), suffix `Running` or
|
||||
/// `Walking` (a walk is recognised by that suffix or `Sport="Walking"`).
|
||||
/// * `<Id>` and every `<Time>` are UTC with a `Z`; one Lap whose
|
||||
/// `TotalTimeSeconds` is moving time and `DistanceMeters` the credited
|
||||
/// distance; every Trackpoint carries `Time` and a cumulative
|
||||
/// `DistanceMeters` (the PC derives a walk's speed from those).
|
||||
/// * The source tag is `Author/Name` and `Creator/Name`
|
||||
/// ([kRunSourceName]); the PC ignores both.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// Written into `Author/Name` and `Creator/Name`; marks an app-made file.
|
||||
const String kRunSourceName = 'kuhy workout_app';
|
||||
|
||||
const String _ns = 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2';
|
||||
const String _xsi = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
|
||||
String _utcStamp(DateTime t) => RunTrack.idFor(t);
|
||||
|
||||
String _num(double v, int digits) => v.toStringAsFixed(digits);
|
||||
|
||||
/// Encodes [track] as a TCX document the PC accepts unchanged.
|
||||
String encodeTcx(RunTrack track) {
|
||||
final b = XmlBuilder()..processing('xml', 'version="1.0" encoding="UTF-8"');
|
||||
b.element(
|
||||
'TrainingCenterDatabase',
|
||||
attributes: {
|
||||
'xmlns': _ns,
|
||||
'xmlns:xsi': _xsi,
|
||||
'xsi:schemaLocation':
|
||||
'$_ns http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd',
|
||||
},
|
||||
nest: () {
|
||||
b
|
||||
..element('Activities', nest: () => _activity(b, track))
|
||||
..element(
|
||||
'Author',
|
||||
attributes: {'xsi:type': 'Application_t'},
|
||||
nest: () {
|
||||
b
|
||||
..element('Name', nest: kRunSourceName)
|
||||
..element('Build', nest: () => _version(b))
|
||||
..element('LangID', nest: 'en')
|
||||
..element('PartNumber', nest: '000-00000-00');
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return b.buildDocument().toXmlString(pretty: true, indent: ' ');
|
||||
}
|
||||
|
||||
void _activity(XmlBuilder b, RunTrack track) => b.element(
|
||||
'Activity',
|
||||
attributes: {'Sport': track.sport.tcxName},
|
||||
nest: () {
|
||||
b.element('Id', nest: track.id);
|
||||
_lap(b, track);
|
||||
b.element(
|
||||
'Creator',
|
||||
attributes: {'xsi:type': 'Device_t'},
|
||||
nest: () {
|
||||
b
|
||||
..element('Name', nest: kRunSourceName)
|
||||
..element('UnitId', nest: '0')
|
||||
..element('ProductID', nest: '0');
|
||||
_version(b);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
void _version(XmlBuilder b) => b.element(
|
||||
'Version',
|
||||
nest: () {
|
||||
b
|
||||
..element('VersionMajor', nest: '1')
|
||||
..element('VersionMinor', nest: '0');
|
||||
},
|
||||
);
|
||||
|
||||
void _lap(XmlBuilder b, RunTrack track) => b.element(
|
||||
'Lap',
|
||||
attributes: {'StartTime': _utcStamp(track.startedAt)},
|
||||
nest: () {
|
||||
b
|
||||
..element('TotalTimeSeconds', nest: '${track.totalSeconds}')
|
||||
..element('DistanceMeters', nest: _num(track.distanceM, 3))
|
||||
..element('Intensity', nest: 'Active')
|
||||
..element('TriggerMethod', nest: 'Manual')
|
||||
..element('Track', nest: () => track.points.forEach(_point(b)));
|
||||
},
|
||||
);
|
||||
|
||||
void Function(RunPoint) _point(XmlBuilder b) =>
|
||||
(p) => b.element(
|
||||
'Trackpoint',
|
||||
nest: () {
|
||||
b
|
||||
..element('Time', nest: _utcStamp(p.time))
|
||||
..element(
|
||||
'Position',
|
||||
nest: () {
|
||||
b
|
||||
..element('LatitudeDegrees', nest: _num(p.latitude, 7))
|
||||
..element('LongitudeDegrees', nest: _num(p.longitude, 7));
|
||||
},
|
||||
);
|
||||
if (p.altitudeM != null) {
|
||||
b.element('AltitudeMeters', nest: _num(p.altitudeM!, 1));
|
||||
}
|
||||
b.element('DistanceMeters', nest: _num(p.distanceM, 3));
|
||||
},
|
||||
);
|
||||
|
||||
/// The export file name for [track]: `RunnerUp_<local stamp>_<Sport>.tcx`.
|
||||
///
|
||||
/// The stamp is local time. [utcOffset] overrides the device zone (tests,
|
||||
/// and any caller that knows the zone the run happened in).
|
||||
String tcxFilename(RunTrack track, {Duration? utcOffset}) {
|
||||
final local = utcOffset == null
|
||||
? track.startedAt.toLocal()
|
||||
: track.startedAt.add(utcOffset);
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
final stamp =
|
||||
'${local.year.toString().padLeft(4, '0')}-${two(local.month)}-'
|
||||
'${two(local.day)}-${two(local.hour)}-${two(local.minute)}-'
|
||||
'${two(local.second)}';
|
||||
return 'RunnerUp_${stamp}_${track.sport.tcxName}.tcx';
|
||||
}
|
||||
|
||||
/// Hex SHA-256 of the TCX text: the dedupe key of the `runs` table.
|
||||
String tcxSha256(String tcx) => sha256.convert(utf8.encode(tcx)).toString();
|
||||
|
||||
/// Decodes a RunnerUp or app-made TCX.
|
||||
///
|
||||
/// [fileName] is the ORIGINAL export name: RunnerUp writes `Sport="Other"`
|
||||
/// for a walk and only the `_Walking.tcx` suffix says so. Throws
|
||||
/// [FormatException] for malformed XML, a missing Activity, or a sport the
|
||||
/// app does not record (cycling and the rest).
|
||||
///
|
||||
/// Trackpoints without a `Position` are skipped: the map and the
|
||||
/// distance maths need coordinates. Laps are summed, as the PC sums them.
|
||||
RunTrack decodeTcx(String tcx, {String? fileName}) {
|
||||
final XmlDocument doc;
|
||||
try {
|
||||
doc = XmlDocument.parse(tcx);
|
||||
} on XmlException catch (e) {
|
||||
throw FormatException('TCX is not valid XML: ${e.message}');
|
||||
}
|
||||
final activity = doc.descendantElements
|
||||
.where((e) => e.localName == 'Activity')
|
||||
.firstOrNull;
|
||||
if (activity == null) throw const FormatException('TCX has no Activity');
|
||||
final sport = _sportOf(activity.getAttribute('Sport'), fileName);
|
||||
var seconds = 0.0;
|
||||
var metres = 0.0;
|
||||
DateTime? lapStart;
|
||||
for (final lap in activity.childElements.where((e) => e.localName == 'Lap')) {
|
||||
seconds += double.tryParse(_text(lap, 'TotalTimeSeconds') ?? '') ?? 0;
|
||||
metres += double.tryParse(_text(lap, 'DistanceMeters') ?? '') ?? 0;
|
||||
lapStart ??= DateTime.tryParse(lap.getAttribute('StartTime') ?? '');
|
||||
}
|
||||
final points = _points(activity);
|
||||
final startedAt =
|
||||
DateTime.tryParse(_text(activity, 'Id') ?? '') ??
|
||||
lapStart ??
|
||||
(points.isEmpty ? null : points.first.time);
|
||||
if (startedAt == null) throw const FormatException('TCX has no start time');
|
||||
return RunTrack(
|
||||
id: _text(activity, 'Id') ?? RunTrack.idFor(startedAt),
|
||||
sport: sport,
|
||||
startedAt: startedAt,
|
||||
points: points,
|
||||
totalSeconds: seconds.round(),
|
||||
distanceM: metres,
|
||||
source: _isOurs(doc) ? RunSource.app : RunSource.runnerupImport,
|
||||
);
|
||||
}
|
||||
|
||||
RunSport _sportOf(String? attribute, String? fileName) {
|
||||
for (final sport in RunSport.values) {
|
||||
if (fileName != null && fileName.endsWith('_${sport.tcxName}.tcx')) {
|
||||
return sport;
|
||||
}
|
||||
}
|
||||
return RunSport.fromTcxName(attribute) ??
|
||||
(throw FormatException('Unsupported TCX sport "$attribute"'));
|
||||
}
|
||||
|
||||
bool _isOurs(XmlDocument doc) => doc.descendantElements.any(
|
||||
(e) =>
|
||||
(e.localName == 'Author' || e.localName == 'Creator') &&
|
||||
_text(e, 'Name') == kRunSourceName,
|
||||
);
|
||||
|
||||
String? _text(XmlElement parent, String name) => parent.childElements
|
||||
.where((e) => e.localName == name)
|
||||
.firstOrNull
|
||||
?.innerText
|
||||
.trim();
|
||||
|
||||
List<RunPoint> _points(XmlElement activity) {
|
||||
final points = <RunPoint>[];
|
||||
for (final tp in activity.descendantElements.where(
|
||||
(e) => e.localName == 'Trackpoint',
|
||||
)) {
|
||||
final time = DateTime.tryParse(_text(tp, 'Time') ?? '');
|
||||
final position = tp.childElements
|
||||
.where((e) => e.localName == 'Position')
|
||||
.firstOrNull;
|
||||
final lat = double.tryParse(
|
||||
position == null ? '' : _text(position, 'LatitudeDegrees') ?? '',
|
||||
);
|
||||
final lon = double.tryParse(
|
||||
position == null ? '' : _text(position, 'LongitudeDegrees') ?? '',
|
||||
);
|
||||
if (time == null || lat == null || lon == null) continue;
|
||||
points.add(
|
||||
RunPoint(
|
||||
time: time,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitudeM: double.tryParse(_text(tp, 'AltitudeMeters') ?? ''),
|
||||
distanceM: double.tryParse(_text(tp, 'DistanceMeters') ?? '') ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return points..sort((a, b) => a.time.compareTo(b.time));
|
||||
}
|
||||
@ -5,10 +5,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
|
||||
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.5"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -149,11 +149,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.12.0"
|
||||
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
|
||||
ref: "crdt_sync_dart-v0.12.1"
|
||||
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.12.0"
|
||||
version: "0.12.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -170,23 +170,39 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
dart_earcut:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_earcut
|
||||
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
dart_polylabel2:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_polylabel2
|
||||
sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
data_transfer_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: data_transfer_flutter
|
||||
ref: "data_transfer_flutter-v0.2.0"
|
||||
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
|
||||
ref: "data_transfer_flutter-v0.2.1"
|
||||
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
|
||||
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.7.15"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -203,6 +219,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.11.1"
|
||||
dio_cache_interceptor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio_cache_interceptor
|
||||
sha256: "9472f9f3dcaa384ae68e60606c1d04e6314dea5eab39f18d94ff9b0a300dbbe4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -239,26 +279,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
|
||||
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.1"
|
||||
version: "13.3.2"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_darwin
|
||||
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
|
||||
sha256: b7d3a6bd26a18c503d29070fc2d7b26fbfe918d08a6bcd984b6e806ff5ad923f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.5.1"
|
||||
file_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_linux
|
||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
||||
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.0.4"
|
||||
file_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -416,6 +456,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_map:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.2"
|
||||
flutter_map_cache:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map_cache
|
||||
sha256: "5c6e91c5b6a3880f4f148dc9fb0ec81bee5bad2b76e91fe1ac3e2dfe8bc4c28b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -474,6 +530,70 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
geoclue:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geoclue
|
||||
sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
geolocator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: geolocator
|
||||
sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.1.2"
|
||||
geolocator_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_android
|
||||
sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
geolocator_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_apple
|
||||
sha256: a1e177f3081089724f2095856aeb96eb8d7d1c59e425aed8dd4de7aa2c11ab66
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
geolocator_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_linux
|
||||
sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.6"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_platform_interface
|
||||
sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.1"
|
||||
geolocator_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_web
|
||||
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.4"
|
||||
geolocator_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_windows
|
||||
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -530,6 +650,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
gsettings:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gsettings
|
||||
sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -546,6 +674,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_cache_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_cache_core
|
||||
sha256: d6de4ca6680e70f4eacd79e073d6a73734eb1da86305409e495adfabf404eddf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
http_cache_file_store:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_cache_file_store
|
||||
sha256: b83bc178226d82a52f56b97801639ff8d94caeaf5464d25cc96082015111f375
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -562,6 +706,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.3"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -594,6 +746,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong2
|
||||
sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -654,10 +814,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
version: "1.19.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -690,6 +858,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.2"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -798,10 +982,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
version: "7.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -826,6 +1010,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -914,6 +1106,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
simple_sparse_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_sparse_list
|
||||
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@ -931,50 +1131,50 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "21a9750de2deb464946e9f41c7ddf176e25553618afdfc774868cd454990c06a"
|
||||
sha256: ed2ff27268e051bde8f0848d6ab86e0f6c01566484d3d358a46ff5e842b36216
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
version: "2.4.6"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "3986f4cbdde19ca538aae877981c5a53400dcc4601499df18e7ed9e848a0bdd4"
|
||||
sha256: e75917dbfe721e6938fc4ff18d3e61549f17cf88a4eba189e2b270290d0c9be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
version: "2.4.6"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
|
||||
sha256: "35a65713b6695fbc5f3596921c8af1dfe767942c96b6c5c80a739b16a10d47eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.11"
|
||||
version: "2.5.17"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
|
||||
sha256: "26911d808e4d33b3965da8821440ef4b021d3bc7282f5eb76e6621827995dd86"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
version: "2.4.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
|
||||
sha256: "6ef57df819b502e306d0b58ba6cb614952d294b825c003feba3c47764c20a825"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3+1"
|
||||
version: "2.4.6"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "5b611dbcb9bab33e7073594ecac111bf0a86ffc342652c2081160d7038a45fb6"
|
||||
sha256: "7bfeb7f9e09761147e8e3a4ff3f1d8979fe46a736f03f3805f572599ade69308"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
version: "2.4.4"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1011,11 +1211,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: sync_settings_ui
|
||||
ref: "sync_settings_ui-v0.6.0"
|
||||
resolved-ref: "3121639732fac85f7e25f3df4e081a228d63aa74"
|
||||
ref: "sync_settings_ui-v0.6.2"
|
||||
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.6.0"
|
||||
version: "0.6.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1056,6 +1256,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1200,6 +1408,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1209,13 +1425,13 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
version: "7.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -12,7 +12,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.12.0
|
||||
ref: crdt_sync_dart-v0.12.1
|
||||
path: crdt_sync_dart
|
||||
# HMAC-SHA256 for the signed phone->PC workout poke (pc_poke_wire.dart).
|
||||
crypto: 3.0.8
|
||||
@ -20,8 +20,11 @@ dependencies:
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.0
|
||||
ref: data_transfer_flutter-v0.2.1
|
||||
path: data_transfer_flutter
|
||||
# Persistent tile cache for the run map (flutter_map_cache talks to dio).
|
||||
dio: 5.11.1
|
||||
dio_cache_interceptor: 4.0.7
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# Keeps the rest-period deadline ticking with the screen off, and owns the
|
||||
@ -32,28 +35,36 @@ dependencies:
|
||||
# The break-end alert itself, on its own high-importance channel so the OS
|
||||
# rings even if the Flutter isolate is busy.
|
||||
flutter_local_notifications: 23.0.0
|
||||
# The run map and its tile disk cache.
|
||||
flutter_map: 8.3.2
|
||||
flutter_map_cache: 2.1.2
|
||||
flutter_secure_storage: 11.2.0
|
||||
|
||||
# GPS fixes, behind the PositionSource seam (lib/services/position_source).
|
||||
geolocator: 14.1.2
|
||||
# One-tap sign-in for the sync account. App-side only: crdt_sync stays pure
|
||||
# Dart so it keeps working on Linux desktop and headless.
|
||||
google_sign_in: 7.2.0
|
||||
http: 1.6.0
|
||||
http_cache_file_store: 2.0.2
|
||||
latlong2: 0.10.1
|
||||
path: 1.9.1
|
||||
path_provider: 2.1.6
|
||||
permission_handler: 13.0.2
|
||||
shared_preferences: 2.5.6
|
||||
sqflite: 2.4.5
|
||||
sqflite: 2.4.6
|
||||
# Linux desktop has no sqflite plugin; the FFI factory drives the same
|
||||
# schema (and already backed the test suite before the desktop target).
|
||||
sqflite_common_ffi: 2.4.5
|
||||
sqflite_common_ffi: 2.4.6
|
||||
sync_settings_ui:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: sync_settings_ui-v0.6.0
|
||||
ref: sync_settings_ui-v0.6.2
|
||||
path: sync_settings_ui
|
||||
url_launcher: 6.3.3
|
||||
uuid: 4.6.0
|
||||
vibration: 3.2.1
|
||||
# TCX read/write (lib/services/tcx_codec.dart).
|
||||
xml: 7.1.0
|
||||
|
||||
dev_dependencies:
|
||||
audioplayers_platform_interface: 7.2.0
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
|
||||
void main() {
|
||||
group('RunSport', () {
|
||||
test('maps the TCX sport name both ways', () {
|
||||
expect(RunSport.fromTcxName('Running'), RunSport.running);
|
||||
expect(RunSport.fromTcxName('Walking'), RunSport.walking);
|
||||
expect(RunSport.fromTcxName('Other'), isNull);
|
||||
expect(RunSport.fromTcxName(null), isNull);
|
||||
expect(RunSport.walking.tcxName, 'Walking');
|
||||
});
|
||||
});
|
||||
|
||||
group('storage keys', () {
|
||||
test('RunSource round-trips and rejects unknown keys', () {
|
||||
for (final s in RunSource.values) {
|
||||
expect(RunSource.fromStorageKey(s.storageKey), s);
|
||||
}
|
||||
expect(RunSource.runnerupImport.storageKey, 'runnerup_import');
|
||||
expect(() => RunSource.fromStorageKey('nope'), throwsStateError);
|
||||
});
|
||||
|
||||
test('RunUploadState round-trips and rejects unknown keys', () {
|
||||
for (final s in RunUploadState.values) {
|
||||
expect(RunUploadState.fromStorageKey(s.storageKey), s);
|
||||
}
|
||||
expect(() => RunUploadState.fromStorageKey('nope'), throwsStateError);
|
||||
});
|
||||
});
|
||||
|
||||
group('RunTrack', () {
|
||||
test('idFor is the UTC second-precision Z stamp', () {
|
||||
expect(
|
||||
RunTrack.idFor(DateTime.utc(2026, 11, 9, 16, 30, 5, 999)),
|
||||
'2026-11-09T16:30:05Z',
|
||||
);
|
||||
expect(
|
||||
RunTrack.idFor(DateTime.parse('2026-11-09T17:30:05+01:00')),
|
||||
'2026-11-09T16:30:05Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('started derives the id and normalises times to UTC', () {
|
||||
final local = DateTime.parse('2026-11-09T17:30:00+01:00');
|
||||
final track = RunTrack.started(
|
||||
sport: RunSport.walking,
|
||||
startedAt: local,
|
||||
points: [
|
||||
RunPoint(time: local, latitude: 1, longitude: 2, distanceM: 0),
|
||||
],
|
||||
totalSeconds: 0,
|
||||
distanceM: 0,
|
||||
);
|
||||
expect(track.id, '2026-11-09T16:30:00Z');
|
||||
expect(track.startedAt.isUtc, isTrue);
|
||||
expect(track.points.single.time.isUtc, isTrue);
|
||||
expect(track.source, RunSource.app);
|
||||
});
|
||||
});
|
||||
|
||||
group('RunPoint', () {
|
||||
final t = DateTime.utc(2026, 11, 9);
|
||||
RunPoint make({double? alt = 1}) => RunPoint(
|
||||
time: t,
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
altitudeM: alt,
|
||||
distanceM: 3,
|
||||
);
|
||||
|
||||
test('has value equality', () {
|
||||
expect(make(), make());
|
||||
expect(make().hashCode, make().hashCode);
|
||||
expect(make(), isNot(make(alt: null)));
|
||||
expect(make(), isNot('x'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
import 'package:workout_app/services/run_math.dart';
|
||||
|
||||
/// One degree of latitude in metres, so tests can place fixes by distance.
|
||||
const double _degM = 111194.9;
|
||||
|
||||
GpsFix _fix(
|
||||
int seconds,
|
||||
double northM, {
|
||||
double accuracy = 5,
|
||||
bool mocked = false,
|
||||
int millis = 0,
|
||||
}) => GpsFix(
|
||||
time: DateTime.utc(2026, 11, 9, 12, 0, seconds, millis),
|
||||
latitude: 48.8584 + northM / _degM,
|
||||
longitude: 2.2945,
|
||||
altitudeM: 30,
|
||||
accuracyM: accuracy,
|
||||
isMocked: mocked,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('rejections', () {
|
||||
test('mocked fixes are dropped', () {
|
||||
final f = GpsFilter(sport: RunSport.running);
|
||||
expect(f.add(_fix(0, 0, mocked: true)), FixVerdict.mocked);
|
||||
expect(f.points, isEmpty);
|
||||
});
|
||||
|
||||
test('accuracy above 25 m, negative or NaN is dropped', () {
|
||||
final f = GpsFilter(sport: RunSport.running);
|
||||
expect(f.add(_fix(0, 0, accuracy: 25.1)), FixVerdict.inaccurate);
|
||||
expect(f.add(_fix(0, 0, accuracy: -1)), FixVerdict.inaccurate);
|
||||
expect(f.add(_fix(0, 0, accuracy: double.nan)), FixVerdict.inaccurate);
|
||||
expect(f.add(_fix(0, 0, accuracy: 25)), FixVerdict.accepted);
|
||||
});
|
||||
|
||||
test('same-second and older fixes are out of order', () {
|
||||
final f = GpsFilter(sport: RunSport.running)..add(_fix(10, 0));
|
||||
expect(f.add(_fix(10, 5, millis: 900)), FixVerdict.outOfOrder);
|
||||
expect(f.add(_fix(9, 5)), FixVerdict.outOfOrder);
|
||||
expect(f.points, hasLength(1));
|
||||
});
|
||||
|
||||
test('a jump above the sport cap is dropped, below it kept', () {
|
||||
final run = GpsFilter(sport: RunSport.running)..add(_fix(0, 0));
|
||||
// 7.0 m/s > 25 km/h (6.94 m/s)
|
||||
expect(run.add(_fix(10, 70)), FixVerdict.tooFast);
|
||||
expect(run.add(_fix(20, 60)), FixVerdict.accepted);
|
||||
|
||||
final walk = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
|
||||
// 3 m/s is fine for a run but over the 8 km/h walking cap (2.22 m/s)
|
||||
expect(walk.add(_fix(10, 30)), FixVerdict.tooFast);
|
||||
expect(walk.add(_fix(20, 20)), FixVerdict.accepted);
|
||||
});
|
||||
|
||||
test('five consecutive jumps re-anchor without crediting distance', () {
|
||||
final f = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
|
||||
for (var i = 1; i < kMaxConsecutiveJumps; i++) {
|
||||
expect(f.add(_fix(i * 5, 5000)), FixVerdict.tooFast);
|
||||
}
|
||||
expect(f.add(_fix(25, 5000)), FixVerdict.accepted);
|
||||
expect(f.distanceM, 0);
|
||||
expect(f.add(_fix(35, 5010)), FixVerdict.accepted);
|
||||
expect(f.distanceM, closeTo(10, 0.1));
|
||||
});
|
||||
|
||||
test('an accepted fix resets the jump counter', () {
|
||||
final f = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
|
||||
for (var i = 1; i < kMaxConsecutiveJumps; i++) {
|
||||
f.add(_fix(i * 5, 5000));
|
||||
}
|
||||
f.add(_fix(30, 20)); // good fix, resets
|
||||
expect(f.add(_fix(35, 5000)), FixVerdict.tooFast);
|
||||
});
|
||||
});
|
||||
|
||||
group('distance credit', () {
|
||||
test('moving steps accumulate haversine distance', () {
|
||||
final f = GpsFilter(sport: RunSport.running);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
f.add(_fix(i * 10, i * 27.0));
|
||||
}
|
||||
expect(f.distanceM, closeTo(81, 0.2));
|
||||
expect(f.points.last.distanceM, f.distanceM);
|
||||
expect(f.points.first.distanceM, 0);
|
||||
expect(f.points.first.altitudeM, 30);
|
||||
});
|
||||
|
||||
test('standing-still jitter and a long dropout earn no distance', () {
|
||||
final f = GpsFilter(sport: RunSport.running)
|
||||
..add(_fix(0, 0))
|
||||
..add(_fix(10, 2)); // 0.2 m/s: standing
|
||||
expect(f.distanceM, 0);
|
||||
f.add(_fix(60, 100)); // 50 s later: unseen motion
|
||||
expect(f.distanceM, 0);
|
||||
f.add(_fix(70, 127)); // seen again, moving
|
||||
expect(f.distanceM, closeTo(27, 0.2));
|
||||
expect(f.points, hasLength(4));
|
||||
});
|
||||
|
||||
test('times are truncated to whole UTC seconds', () {
|
||||
final f = GpsFilter(sport: RunSport.running)
|
||||
..add(_fix(3, 0, millis: 750));
|
||||
expect(f.points.single.time, DateTime.utc(2026, 11, 9, 12, 0, 3));
|
||||
});
|
||||
|
||||
test('points is a read-only view and distance starts at zero', () {
|
||||
final f = GpsFilter(sport: RunSport.running);
|
||||
expect(f.distanceM, 0);
|
||||
f.add(_fix(0, 0));
|
||||
expect(() => f.points.clear(), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
group('consistency with movingSeconds', () {
|
||||
test('every credited step is a moving step and vice versa', () {
|
||||
final f = GpsFilter(sport: RunSport.walking);
|
||||
var north = 0.0;
|
||||
for (var s = 0; s <= 300; s += 10) {
|
||||
// walk 1.4 m/s, stand 40-70 s, resume
|
||||
final standing = s > 100 && s <= 140;
|
||||
if (!standing) north += 14;
|
||||
f.add(_fix(s, north));
|
||||
}
|
||||
expect(movingSeconds(f.points, RunSport.walking), 260);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
import 'package:workout_app/services/position_source.dart';
|
||||
import 'package:workout_app/services/position_source_geolocator.dart';
|
||||
import 'package:workout_app/services/position_source_scripted.dart';
|
||||
|
||||
/// A platform that answers from fields instead of a method channel.
|
||||
class _FakeGeolocator extends GeolocatorPlatform {
|
||||
bool serviceOn = true;
|
||||
LocationPermission current = LocationPermission.denied;
|
||||
LocationPermission afterRequest = LocationPermission.whileInUse;
|
||||
int requests = 0;
|
||||
LocationSettings? settings;
|
||||
List<Position> positions = [];
|
||||
|
||||
@override
|
||||
Future<bool> isLocationServiceEnabled() async => serviceOn;
|
||||
|
||||
@override
|
||||
Future<LocationPermission> checkPermission() async => current;
|
||||
|
||||
@override
|
||||
Future<LocationPermission> requestPermission() async {
|
||||
requests++;
|
||||
return current = afterRequest;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Position> getPositionStream({LocationSettings? locationSettings}) {
|
||||
settings = locationSettings;
|
||||
return Stream.fromIterable(positions);
|
||||
}
|
||||
}
|
||||
|
||||
Position _position({bool mocked = false}) => Position(
|
||||
latitude: 48.8584,
|
||||
longitude: 2.2945,
|
||||
timestamp: DateTime.utc(2026, 11, 9, 12),
|
||||
accuracy: 4.5,
|
||||
altitude: 33,
|
||||
altitudeAccuracy: 1,
|
||||
heading: 0,
|
||||
headingAccuracy: 1,
|
||||
speed: 2,
|
||||
speedAccuracy: 1,
|
||||
isMocked: mocked,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('only granted access can read fixes', () {
|
||||
expect(LocationAccess.values.where((a) => a.isGranted), [
|
||||
LocationAccess.granted,
|
||||
]);
|
||||
});
|
||||
|
||||
group('GeolocatorPositionSource', () {
|
||||
late _FakeGeolocator platform;
|
||||
final original = GeolocatorPlatform.instance;
|
||||
|
||||
setUp(() {
|
||||
platform = _FakeGeolocator();
|
||||
GeolocatorPlatform.instance = platform;
|
||||
});
|
||||
tearDown(() => GeolocatorPlatform.instance = original);
|
||||
|
||||
test('service off wins over any permission', () async {
|
||||
platform.serviceOn = false;
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.serviceOff,
|
||||
);
|
||||
expect(platform.requests, 0);
|
||||
});
|
||||
|
||||
test('a denied permission is requested once and then granted', () async {
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.granted,
|
||||
);
|
||||
expect(platform.requests, 1);
|
||||
});
|
||||
|
||||
test('an already granted permission is not requested again', () async {
|
||||
platform.current = LocationPermission.always;
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.granted,
|
||||
);
|
||||
expect(platform.requests, 0);
|
||||
});
|
||||
|
||||
test('refusals map to denied / deniedForever', () async {
|
||||
platform.afterRequest = LocationPermission.denied;
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.denied,
|
||||
);
|
||||
platform.current = LocationPermission.deniedForever;
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.deniedForever,
|
||||
);
|
||||
platform.current = LocationPermission.unableToDetermine;
|
||||
expect(
|
||||
await GeolocatorPositionSource().requestAccess(),
|
||||
LocationAccess.denied,
|
||||
);
|
||||
});
|
||||
|
||||
test('fixes maps positions and asks for navigation accuracy', () async {
|
||||
platform.positions = [_position(), _position(mocked: true)];
|
||||
final fixes = await GeolocatorPositionSource(
|
||||
forceLocationManager: true,
|
||||
).fixes(interval: const Duration(seconds: 3)).toList();
|
||||
expect(fixes, hasLength(2));
|
||||
expect(fixes.first.latitude, 48.8584);
|
||||
expect(fixes.first.altitudeM, 33);
|
||||
expect(fixes.first.accuracyM, 4.5);
|
||||
expect(fixes.first.time, DateTime.utc(2026, 11, 9, 12));
|
||||
expect(fixes.first.isMocked, isFalse);
|
||||
expect(fixes.last.isMocked, isTrue);
|
||||
final android = platform.settings! as AndroidSettings;
|
||||
expect(android.accuracy, LocationAccuracy.bestForNavigation);
|
||||
expect(android.intervalDuration, const Duration(seconds: 3));
|
||||
expect(android.forceLocationManager, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ScriptedPositionSource', () {
|
||||
final script = [
|
||||
GpsFix(
|
||||
time: DateTime.utc(2026, 11, 9, 12),
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
accuracyM: 3,
|
||||
),
|
||||
GpsFix(
|
||||
time: DateTime.utc(2026, 11, 9, 12, 0, 2),
|
||||
latitude: 1.0001,
|
||||
longitude: 2,
|
||||
accuracyM: 3,
|
||||
),
|
||||
];
|
||||
|
||||
test('replays the script in order and answers the given access', () async {
|
||||
final source = ScriptedPositionSource(
|
||||
script,
|
||||
access: LocationAccess.deniedForever,
|
||||
);
|
||||
expect(await source.requestAccess(), LocationAccess.deniedForever);
|
||||
expect(await source.fixes().toList(), script);
|
||||
});
|
||||
|
||||
test('defaults to granted and honours a per-fix delay', () async {
|
||||
final source = ScriptedPositionSource(
|
||||
script,
|
||||
fixDelay: const Duration(milliseconds: 1),
|
||||
);
|
||||
expect(await source.requestAccess(), LocationAccess.granted);
|
||||
expect(await source.fixes().length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/run_math.dart';
|
||||
|
||||
RunPoint _p(int seconds, double distanceM) => RunPoint(
|
||||
time: DateTime.utc(2026, 11, 9, 12, 0, seconds),
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
distanceM: distanceM,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('haversineM', () {
|
||||
test('is zero for the same point', () {
|
||||
expect(haversineM(48.85, 2.29, 48.85, 2.29), 0);
|
||||
});
|
||||
|
||||
test('one degree of latitude is about 111.2 km', () {
|
||||
expect(haversineM(0, 0, 1, 0), closeTo(111195, 5));
|
||||
});
|
||||
|
||||
test('is symmetric and shrinks east-west away from the equator', () {
|
||||
final a = haversineM(60, 0, 60, 1);
|
||||
expect(a, haversineM(60, 1, 60, 0));
|
||||
expect(a, closeTo(55597, 20));
|
||||
});
|
||||
|
||||
test('antipodes stay finite (clamped asin argument)', () {
|
||||
expect(haversineM(0, 0, 0, 180), closeTo(20015087, 50));
|
||||
});
|
||||
});
|
||||
|
||||
group('maxSpeedMps', () {
|
||||
test('caps are 25 km/h for a run and 8 km/h for a walk', () {
|
||||
expect(maxSpeedMps(RunSport.running), closeTo(25 / 3.6, 1e-9));
|
||||
expect(maxSpeedMps(RunSport.walking), closeTo(8 / 3.6, 1e-9));
|
||||
});
|
||||
});
|
||||
|
||||
group('isMovingStep / movingSeconds', () {
|
||||
const cap = 3.0;
|
||||
|
||||
test('a normal step counts', () {
|
||||
expect(isMovingStep(_p(0, 0), _p(10, 13), cap), isTrue);
|
||||
});
|
||||
|
||||
test('standing still, too fast and non-forward steps do not', () {
|
||||
expect(isMovingStep(_p(0, 0), _p(10, 4), cap), isFalse);
|
||||
expect(isMovingStep(_p(0, 0), _p(10, 40), cap), isFalse);
|
||||
expect(isMovingStep(_p(5, 0), _p(5, 10), cap), isFalse);
|
||||
expect(isMovingStep(_p(10, 0), _p(5, 10), cap), isFalse);
|
||||
});
|
||||
|
||||
test('a gap over 30 s earns nothing, 30 s exactly does', () {
|
||||
expect(isMovingStep(_p(0, 0), _p(30, 40), cap), isTrue);
|
||||
expect(isMovingStep(_p(0, 0), _p(31, 40), cap), isFalse);
|
||||
});
|
||||
|
||||
test('movingSeconds sums only the moving steps', () {
|
||||
final points = [
|
||||
_p(0, 0),
|
||||
_p(10, 13), // moving, 10 s
|
||||
_p(20, 13), // standing, 0 distance
|
||||
_p(55, 60), // 35 s gap, not credited
|
||||
_p(65, 73), // moving, 10 s
|
||||
];
|
||||
expect(movingSeconds(points, RunSport.walking), 20);
|
||||
expect(movingSeconds(const [], RunSport.running), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
StorageService get _svc => StorageService.instance;
|
||||
|
||||
RunTrack _track({
|
||||
String start = '2026-11-09T16:30:00Z',
|
||||
RunSource source = RunSource.app,
|
||||
RunSport sport = RunSport.running,
|
||||
}) {
|
||||
final t = DateTime.parse(start);
|
||||
return RunTrack.started(
|
||||
sport: sport,
|
||||
startedAt: t,
|
||||
totalSeconds: 20,
|
||||
distanceM: 27.5,
|
||||
source: source,
|
||||
points: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
RunPoint(
|
||||
time: t.add(Duration(seconds: i * 10)),
|
||||
latitude: 48.8584 + i * 0.0001,
|
||||
longitude: 2.2945,
|
||||
altitudeM: i == 1 ? null : 31.5,
|
||||
distanceM: i * 13.75,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _insert(
|
||||
RunTrack t, {
|
||||
RunUploadState state = RunUploadState.pending,
|
||||
String? sha,
|
||||
}) => _svc.insertRun(
|
||||
t,
|
||||
tcxFilename: tcxFilename(t, utcOffset: Duration.zero),
|
||||
tcxSha256: sha ?? tcxSha256(encodeTcx(t)),
|
||||
uploadState: state,
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
});
|
||||
|
||||
tearDown(StorageService.resetForTesting);
|
||||
|
||||
test('insert then getRun returns the points in order', () async {
|
||||
final t = _track();
|
||||
expect(await _insert(t), isTrue);
|
||||
final rec = (await _svc.getRun(t.id))!;
|
||||
expect(rec.uploadState, RunUploadState.pending);
|
||||
expect(rec.tcxFilename, 'RunnerUp_2026-11-09-16-30-00_Running.tcx');
|
||||
expect(rec.tcxSha256, tcxSha256(encodeTcx(t)));
|
||||
expect(rec.track.id, t.id);
|
||||
expect(rec.track.sport, RunSport.running);
|
||||
expect(rec.track.startedAt, t.startedAt);
|
||||
expect(rec.track.totalSeconds, 20);
|
||||
expect(rec.track.distanceM, 27.5);
|
||||
expect(rec.track.source, RunSource.app);
|
||||
expect(rec.track.points, t.points);
|
||||
});
|
||||
|
||||
test('getRun of an unknown id is null', () async {
|
||||
expect(await _svc.getRun('nope'), isNull);
|
||||
});
|
||||
|
||||
test('the same TCX hash or id is stored only once', () async {
|
||||
final t = _track();
|
||||
expect(await _insert(t, sha: 'aa'), isTrue);
|
||||
expect(
|
||||
await _insert(_track(start: '2026-11-10T08:00:00Z'), sha: 'aa'),
|
||||
isFalse,
|
||||
);
|
||||
expect(await _insert(t, sha: 'bb'), isFalse);
|
||||
expect(await _svc.runExistsBySha('aa'), isTrue);
|
||||
expect(await _svc.runExistsBySha('bb'), isFalse);
|
||||
expect(await _svc.listRuns(), hasLength(1));
|
||||
expect((await _svc.getRun(t.id))!.track.points, hasLength(3));
|
||||
});
|
||||
|
||||
test('listRuns is newest first, without points, and can filter', () async {
|
||||
await _insert(_track(), sha: '1');
|
||||
await _insert(
|
||||
_track(start: '2026-11-10T08:00:00Z', sport: RunSport.walking),
|
||||
sha: '2',
|
||||
);
|
||||
await _insert(
|
||||
_track(start: '2026-11-08T08:00:00Z', source: RunSource.runnerupImport),
|
||||
state: RunUploadState.never,
|
||||
sha: '3',
|
||||
);
|
||||
final all = await _svc.listRuns();
|
||||
expect(all.map((r) => r.track.id), [
|
||||
'2026-11-10T08:00:00Z',
|
||||
'2026-11-09T16:30:00Z',
|
||||
'2026-11-08T08:00:00Z',
|
||||
]);
|
||||
expect(all.every((r) => r.track.points.isEmpty), isTrue);
|
||||
expect(all.first.track.sport, RunSport.walking);
|
||||
final pending = await _svc.listRuns(uploadState: RunUploadState.pending);
|
||||
expect(pending, hasLength(2));
|
||||
final never = await _svc.listRuns(uploadState: RunUploadState.never);
|
||||
expect(never.single.track.source, RunSource.runnerupImport);
|
||||
});
|
||||
|
||||
test('an imported run can only be stored as never', () async {
|
||||
final imported = _track(source: RunSource.runnerupImport);
|
||||
await expectLater(_insert(imported), throwsA(isA<ArgumentError>()));
|
||||
expect(await _insert(imported, state: RunUploadState.never), isTrue);
|
||||
expect(await _svc.listRuns(), hasLength(1));
|
||||
});
|
||||
|
||||
test('the table itself rejects an imported run that is not never', () async {
|
||||
final imported = _track(source: RunSource.runnerupImport);
|
||||
await _insert(imported, state: RunUploadState.never);
|
||||
await expectLater(
|
||||
_svc.setRunUploadState(imported.id, RunUploadState.uploaded),
|
||||
throwsA(isA<DatabaseException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('setRunUploadState moves a recorded run and reports misses', () async {
|
||||
final t = _track();
|
||||
await _insert(t);
|
||||
expect(await _svc.setRunUploadState(t.id, RunUploadState.uploaded), isTrue);
|
||||
expect((await _svc.getRun(t.id))!.uploadState, RunUploadState.uploaded);
|
||||
expect(await _svc.setRunUploadState('nope', RunUploadState.never), isFalse);
|
||||
});
|
||||
|
||||
test('deleteRun removes the run and its points', () async {
|
||||
final t = _track();
|
||||
await _insert(t);
|
||||
expect(await _svc.deleteRun(t.id), isTrue);
|
||||
expect(await _svc.getRun(t.id), isNull);
|
||||
expect(await _svc.deleteRun(t.id), isFalse);
|
||||
// Re-inserting works: no orphan points block the primary key.
|
||||
expect(await _insert(t), isTrue);
|
||||
expect((await _svc.getRun(t.id))!.track.points, hasLength(3));
|
||||
});
|
||||
|
||||
test('wipeAll clears runs and their points', () async {
|
||||
final t = _track();
|
||||
await _insert(t);
|
||||
await _svc.wipeAll();
|
||||
expect(await _svc.listRuns(), isEmpty);
|
||||
expect(await _insert(t), isTrue);
|
||||
});
|
||||
|
||||
test('v8 -> v9 migration creates the run tables and keeps data', () async {
|
||||
final dir = await Directory.systemTemp.createTemp('runs_v9');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final path = p.join(dir.path, 'v8.db');
|
||||
StorageService.resetForTesting(dbPath: path);
|
||||
await StorageService.init();
|
||||
final t = _track();
|
||||
await _insert(t);
|
||||
StorageService.resetForTesting();
|
||||
|
||||
final raw = await databaseFactory.openDatabase(path);
|
||||
await raw.execute('DROP TABLE runs');
|
||||
await raw.execute('DROP TABLE run_points');
|
||||
await raw.execute('PRAGMA user_version = 8');
|
||||
await raw.close();
|
||||
|
||||
StorageService.resetForTesting(dbPath: path);
|
||||
await StorageService.init();
|
||||
expect(await _svc.listRuns(), isEmpty);
|
||||
expect(await _insert(t), isTrue);
|
||||
expect((await _svc.getRun(t.id))!.track.points, hasLength(3));
|
||||
// Exercise seeding survived: the pre-existing tables are untouched.
|
||||
expect(await _svc.getNextWorkoutType(), 'A');
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
RunTrack _track({
|
||||
RunSport sport = RunSport.running,
|
||||
double? alt = 31.5,
|
||||
RunSource source = RunSource.app,
|
||||
}) {
|
||||
final start = DateTime.utc(2026, 11, 9, 16, 30);
|
||||
return RunTrack.started(
|
||||
sport: sport,
|
||||
startedAt: start,
|
||||
totalSeconds: 20,
|
||||
distanceM: 27.5,
|
||||
source: source,
|
||||
points: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
RunPoint(
|
||||
time: start.add(Duration(seconds: i * 10)),
|
||||
latitude: 48.8584 + i * 0.0001,
|
||||
longitude: 2.2945,
|
||||
altitudeM: alt,
|
||||
distanceM: i * 13.75,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('encodeTcx', () {
|
||||
test('writes the PC-facing structure and the source tag', () {
|
||||
final xml = encodeTcx(_track());
|
||||
expect(xml, contains('<Activity Sport="Running">'));
|
||||
expect(xml, contains('<Id>2026-11-09T16:30:00Z</Id>'));
|
||||
expect(xml, contains('<Lap StartTime="2026-11-09T16:30:00Z">'));
|
||||
expect(xml, contains('<TotalTimeSeconds>20</TotalTimeSeconds>'));
|
||||
expect(xml, contains('<DistanceMeters>27.500</DistanceMeters>'));
|
||||
expect(xml, contains('<Time>2026-11-09T16:30:10Z</Time>'));
|
||||
expect(xml, contains('<Name>$kRunSourceName</Name>'));
|
||||
expect(
|
||||
xml,
|
||||
contains(
|
||||
'xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('omits AltitudeMeters when the point has none', () {
|
||||
expect(encodeTcx(_track(alt: null)), isNot(contains('AltitudeMeters')));
|
||||
expect(encodeTcx(_track()), contains('<AltitudeMeters>31.5<'));
|
||||
});
|
||||
|
||||
test('is deterministic', () {
|
||||
expect(encodeTcx(_track()), encodeTcx(_track()));
|
||||
});
|
||||
});
|
||||
|
||||
group('round trip', () {
|
||||
for (final sport in RunSport.values) {
|
||||
test('${sport.tcxName} survives encode -> decode', () {
|
||||
final original = _track(sport: sport);
|
||||
final back = decodeTcx(
|
||||
encodeTcx(original),
|
||||
fileName: tcxFilename(original, utcOffset: Duration.zero),
|
||||
);
|
||||
expect(back.id, original.id);
|
||||
expect(back.sport, sport);
|
||||
expect(back.startedAt, original.startedAt);
|
||||
expect(back.totalSeconds, 20);
|
||||
expect(back.distanceM, closeTo(27.5, 1e-9));
|
||||
expect(back.source, RunSource.app);
|
||||
expect(back.points, hasLength(3));
|
||||
for (final (i, p) in back.points.indexed) {
|
||||
final o = original.points[i];
|
||||
expect(p.time, o.time);
|
||||
expect(p.latitude, closeTo(o.latitude, 1e-7));
|
||||
expect(p.longitude, closeTo(o.longitude, 1e-7));
|
||||
expect(p.altitudeM, closeTo(o.altitudeM!, 0.05));
|
||||
expect(p.distanceM, closeTo(o.distanceM, 1e-3));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a point without altitude stays without', () {
|
||||
final back = decodeTcx(encodeTcx(_track(alt: null)));
|
||||
expect(back.points.every((p) => p.altitudeM == null), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('tcxFilename', () {
|
||||
test('stamps LOCAL time (offset applied to the UTC start)', () {
|
||||
final t = _track();
|
||||
expect(
|
||||
tcxFilename(t, utcOffset: const Duration(hours: 1)),
|
||||
'RunnerUp_2026-11-09-17-30-00_Running.tcx',
|
||||
);
|
||||
expect(
|
||||
tcxFilename(
|
||||
_track(sport: RunSport.walking),
|
||||
utcOffset: const Duration(hours: 9, minutes: 45),
|
||||
),
|
||||
'RunnerUp_2026-11-10-02-15-00_Walking.tcx',
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults to the device zone and keeps the shape', () {
|
||||
expect(
|
||||
tcxFilename(_track()),
|
||||
matches(r'^RunnerUp_\d{4}-\d\d-\d\d-\d\d-\d\d-\d\d_Running\.tcx$'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('tcxSha256 is the hex SHA-256 of the text', () {
|
||||
expect(
|
||||
tcxSha256('abc'),
|
||||
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
const String _fixtures = '../../screen_locker/tests/fixtures/run_tcx';
|
||||
|
||||
String _tcx({
|
||||
String sport = 'Running',
|
||||
String id = '<Id>2026-11-09T16:30:00Z</Id>',
|
||||
String lapStart = ' StartTime="2026-11-09T16:30:00Z"',
|
||||
String points = '',
|
||||
String author = '',
|
||||
}) =>
|
||||
'''
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
|
||||
<Activities>
|
||||
<Activity Sport="$sport">
|
||||
$id
|
||||
<Lap$lapStart>
|
||||
<TotalTimeSeconds>60</TotalTimeSeconds>
|
||||
<DistanceMeters>100</DistanceMeters>
|
||||
<Track>$points</Track>
|
||||
</Lap>
|
||||
</Activity>
|
||||
</Activities>$author
|
||||
</TrainingCenterDatabase>''';
|
||||
|
||||
String _tp(String time, {bool position = true}) =>
|
||||
'<Trackpoint><Time>$time</Time>'
|
||||
'${position ? '<Position><LatitudeDegrees>1.5</LatitudeDegrees>'
|
||||
'<LongitudeDegrees>2.5</LongitudeDegrees></Position>' : ''}'
|
||||
'<DistanceMeters>7</DistanceMeters></Trackpoint>';
|
||||
|
||||
void main() {
|
||||
group('decodeTcx on a real RunnerUp export', () {
|
||||
final text = File(
|
||||
'$_fixtures/RunnerUp_2026-08-12-17-42-11_Running.tcx',
|
||||
).readAsStringSync();
|
||||
|
||||
test('sums the laps and reads every trackpoint', () {
|
||||
final t = decodeTcx(
|
||||
text,
|
||||
fileName: 'RunnerUp_2026-08-12-17-42-11_Running.tcx',
|
||||
);
|
||||
expect(t.sport, RunSport.running);
|
||||
expect(t.source, RunSource.runnerupImport);
|
||||
expect(t.id, '2026-08-12T15:42:11Z');
|
||||
expect(t.startedAt, DateTime.utc(2026, 8, 12, 15, 42, 11));
|
||||
expect(t.totalSeconds, 624 + 626);
|
||||
expect(t.distanceM, closeTo(983.6306 + 980.7304, 1e-3));
|
||||
expect(t.points, hasLength(10));
|
||||
expect(t.points.first.latitude, closeTo(48.858438, 1e-6));
|
||||
expect(t.points.first.altitudeM, closeTo(110.066, 1e-3));
|
||||
expect(t.points.first.distanceM, closeTo(2.14297, 1e-4));
|
||||
});
|
||||
|
||||
test('works without a file name when the Sport attribute says so', () {
|
||||
expect(decodeTcx(text).sport, RunSport.running);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeTcx edge cases', () {
|
||||
test('_Walking file name wins over Sport="Other" (RunnerUp walks)', () {
|
||||
final t = decodeTcx(
|
||||
_tcx(sport: 'Other'),
|
||||
fileName: 'RunnerUp_Pixel_6a_2026-11-09-17-30-00_Walking.tcx',
|
||||
);
|
||||
expect(t.sport, RunSport.walking);
|
||||
});
|
||||
|
||||
test('Sport="Walking" without a file name is a walk', () {
|
||||
expect(decodeTcx(_tcx(sport: 'Walking')).sport, RunSport.walking);
|
||||
});
|
||||
|
||||
test('an unsupported sport is a FormatException', () {
|
||||
expect(
|
||||
() => decodeTcx(_tcx(sport: 'Biking')),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
expect(
|
||||
() => decodeTcx(_tcx(sport: 'Other'), fileName: 'x_Biking.tcx'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed XML and a missing Activity are FormatExceptions', () {
|
||||
expect(() => decodeTcx('<a'), throwsA(isA<FormatException>()));
|
||||
expect(
|
||||
() => decodeTcx('<TrainingCenterDatabase/>'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('no Id falls back to the lap start, then to the first point', () {
|
||||
final lap = decodeTcx(_tcx(id: ''));
|
||||
expect(lap.id, '2026-11-09T16:30:00Z');
|
||||
final pts = decodeTcx(
|
||||
_tcx(id: '', lapStart: '', points: _tp('2026-11-09T10:00:00Z')),
|
||||
);
|
||||
expect(pts.startedAt, DateTime.utc(2026, 11, 9, 10));
|
||||
expect(
|
||||
() => decodeTcx(_tcx(id: '', lapStart: '')),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'points without a position or a usable time are skipped, rest sorted',
|
||||
() {
|
||||
final t = decodeTcx(
|
||||
_tcx(
|
||||
points:
|
||||
_tp('2026-11-09T16:30:20Z') +
|
||||
_tp('2026-11-09T16:30:10Z', position: false) +
|
||||
_tp('garbage') +
|
||||
_tp('2026-11-09T16:30:05Z'),
|
||||
),
|
||||
);
|
||||
expect(t.points.map((p) => p.time.second), [5, 20]);
|
||||
},
|
||||
);
|
||||
|
||||
test('a lap with unreadable totals counts as zero', () {
|
||||
final t = decodeTcx(
|
||||
_tcx().replaceAll('>60<', '>x<').replaceAll('>100<', '>y<'),
|
||||
);
|
||||
expect(t.totalSeconds, 0);
|
||||
expect(t.distanceM, 0);
|
||||
});
|
||||
|
||||
test('a foreign Author is an import, ours is an app file', () {
|
||||
expect(
|
||||
decodeTcx(
|
||||
_tcx(author: '<Author><Name>org.runnerup - 2.11</Name></Author>'),
|
||||
).source,
|
||||
RunSource.runnerupImport,
|
||||
);
|
||||
expect(
|
||||
decodeTcx(
|
||||
_tcx(author: '<Author><Name>$kRunSourceName</Name></Author>'),
|
||||
).source,
|
||||
RunSource.app,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/services/run_math.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
/// Cross-language golden test for the TCX the PC credits.
|
||||
///
|
||||
/// Each language's own round trip passes happily while the other side's
|
||||
/// parser disagrees, so this test encodes the tracks in `golden_tracks.json`
|
||||
/// and compares the result byte for byte with the checked-in `.tcx` files.
|
||||
/// The Python twin, `screen_locker/tests/test_run_tcx_golden.py`, feeds those
|
||||
/// SAME files to the real verifier and asserts the km / minutes in the same
|
||||
/// JSON. Regenerate the files after a deliberate codec change with
|
||||
/// `UPDATE_GOLDEN=1 flutter test test/services/tcx_golden_fixture_test.dart`.
|
||||
const String _dir = '../../screen_locker/tests/fixtures/run_tcx';
|
||||
|
||||
RunTrack _track(Map<String, dynamic> spec) {
|
||||
final points = [
|
||||
for (final raw in spec['points'] as List<dynamic>)
|
||||
RunPoint(
|
||||
time: DateTime.parse((raw as List<dynamic>)[0] as String),
|
||||
latitude: (raw[1] as num).toDouble(),
|
||||
longitude: (raw[2] as num).toDouble(),
|
||||
altitudeM: (raw[3] as num).toDouble(),
|
||||
distanceM: (raw[4] as num).toDouble(),
|
||||
),
|
||||
];
|
||||
return RunTrack.started(
|
||||
sport: RunSport.fromTcxName(spec['sport'] as String)!,
|
||||
startedAt: DateTime.parse(spec['started_at'] as String),
|
||||
points: points,
|
||||
totalSeconds: spec['total_seconds'] as int,
|
||||
distanceM: (spec['distance_m'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final tracks =
|
||||
jsonDecode(File('$_dir/golden_tracks.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
final update = Platform.environment['UPDATE_GOLDEN'] == '1';
|
||||
|
||||
for (final entry in tracks.entries) {
|
||||
final spec = entry.value as Map<String, dynamic>;
|
||||
final track = _track(spec);
|
||||
final file = File('$_dir/${spec['file']}');
|
||||
|
||||
group('golden ${entry.key}', () {
|
||||
test('encodes to exactly the checked-in file', () {
|
||||
final encoded = encodeTcx(track);
|
||||
if (update) file.writeAsStringSync(encoded);
|
||||
expect(file.readAsStringSync(), encoded);
|
||||
});
|
||||
|
||||
test('file name is the local-time stamp the PC globs for', () {
|
||||
final offset = Duration(minutes: spec['utc_offset_minutes'] as int);
|
||||
expect(tcxFilename(track, utcOffset: offset), spec['file']);
|
||||
});
|
||||
|
||||
test('stored moving time equals the app-side moving-time rule', () {
|
||||
expect(movingSeconds(track.points, track.sport), track.totalSeconds);
|
||||
});
|
||||
|
||||
test('the checked-in file decodes back to the same track', () {
|
||||
final back = decodeTcx(
|
||||
file.readAsStringSync(),
|
||||
fileName: spec['file'] as String,
|
||||
);
|
||||
expect(back.sport, track.sport);
|
||||
expect(back.source, RunSource.app);
|
||||
expect(back.totalSeconds, track.totalSeconds);
|
||||
expect(back.points, hasLength(track.points.length));
|
||||
expect(back.distanceM, closeTo(track.distanceM, 1e-3));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user