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:
Krzysztof Rudnicki 2026-11-10 15:37:41 +01:00
parent 1020421371
commit c2544c4a9c
29 changed files with 5857 additions and 47 deletions

View 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>

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

View 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"

View File

@ -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).

View File

@ -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

View File

@ -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;
}

View 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;
}

View 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,
),
);
}
}

View File

@ -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)});
}

View File

@ -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,
),
);
}

View File

@ -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;
}
}
}

View File

@ -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();
}

View File

@ -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

View File

@ -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,
);
}

View File

@ -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',

View File

@ -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 {

View 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));
}

View File

@ -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:

View File

@ -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

View File

@ -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'));
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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');
});
}

View File

@ -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',
);
});
}

View File

@ -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,
);
});
});
}

View File

@ -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));
});
});
}
}