Compare commits
16 Commits
436633568c
...
app-owner/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ecb8e9965 | ||
|
|
0e95da26b4 | ||
|
|
bac642c221 | ||
|
|
f6cc1b8fa4 | ||
|
|
1aa3f30fce | ||
|
|
e22ae29b3f | ||
|
|
db2439c52e | ||
|
|
36f583169c | ||
|
|
16c2ada09b | ||
|
|
5e87d81bb6 | ||
|
|
f9fb9f59f4 | ||
|
|
71fcb9b63f | ||
|
|
a788cebea3 | ||
|
|
11cad9a78a | ||
|
|
0b4fb83546 | ||
|
|
dc3a45e699 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -6,6 +6,18 @@
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# Local iOS deployment target (contains a personal device UDID).
|
||||
.ios-device
|
||||
|
||||
# Apple signing credentials and provisioning artifacts. Keep these in Keychain or
|
||||
# an approved secret store; never commit them to the application repository.
|
||||
*.cer
|
||||
*.p12
|
||||
*.p8
|
||||
*.mobileprovision
|
||||
*.provisionprofile
|
||||
*.certSigningRequest
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
@@ -482,3 +494,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# SEC-1: local Google Maps key injection — never commit the real key
|
||||
maps.key.props
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/GeneratedTokens.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly DeviceConnectionManager _connectionManager;
|
||||
private readonly IMqttSyncService _syncService;
|
||||
|
||||
public App(DeviceConnectionManager connectionManager)
|
||||
public App(DeviceConnectionManager connectionManager, IMqttSyncService syncService)
|
||||
{
|
||||
InitializeComponent();
|
||||
_connectionManager = connectionManager;
|
||||
_syncService = syncService;
|
||||
|
||||
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
|
||||
Console.WriteLine("===== UM TRACE APP STARTING =====");
|
||||
Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}");
|
||||
|
||||
// Root navigation now opens on Jobs, so connection recovery must not depend on
|
||||
// the former Home page appearing first.
|
||||
_connectionManager.Start();
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
@@ -24,6 +31,7 @@ public partial class App : Application
|
||||
{
|
||||
Console.WriteLine("App: Window closing, disconnecting devices...");
|
||||
await DisconnectAllDevicesAsync();
|
||||
await _syncService.StopAsync();
|
||||
};
|
||||
|
||||
return window;
|
||||
|
||||
@@ -4,14 +4,37 @@
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:views="clr-namespace:FieldLogger.Views"
|
||||
Title="Field Logger">
|
||||
Title="UM Trace"
|
||||
FlyoutBehavior="Flyout"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}"
|
||||
ForegroundColor="{DynamicResource UlColorText}"
|
||||
TitleColor="{DynamicResource UlColorText}"
|
||||
UnselectedColor="{DynamicResource UlColorTextMuted}"
|
||||
TabBarBackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
TabBarForegroundColor="{DynamicResource UlColorPrimary}"
|
||||
TabBarTitleColor="{DynamicResource UlColorPrimary}"
|
||||
TabBarUnselectedColor="{DynamicResource UlColorTextMuted}">
|
||||
|
||||
<TabBar>
|
||||
<ShellContent Title="Home" Route="home" ContentTemplate="{DataTemplate views:HomePage}" />
|
||||
<ShellContent Title="Jobs" Route="jobs" ContentTemplate="{DataTemplate views:JobsPage}" />
|
||||
<ShellContent Title="Map" Route="map" ContentTemplate="{DataTemplate views:MapPage}" />
|
||||
<ShellContent Title="Debug" Route="debug" ContentTemplate="{DataTemplate views:DebugPage}" />
|
||||
<ShellContent Title="Settings" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
|
||||
</TabBar>
|
||||
<Shell.FlyoutHeader>
|
||||
<Grid BackgroundColor="{DynamicResource UlColorSurfaceStrong}" Padding="20,28,20,20">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<Label Text="UM TRACE" FontSize="20" FontAttributes="Bold"
|
||||
CharacterSpacing="1.5" TextColor="{DynamicResource UlColorOnStrong}" />
|
||||
<Label Text="Utility locating" FontSize="14"
|
||||
TextColor="{DynamicResource UlColorBorder}" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Shell.FlyoutHeader>
|
||||
|
||||
<!-- Jobs is intentionally first: Shell selects the first flyout item at startup. -->
|
||||
<FlyoutItem Title="Jobs" Route="jobs">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:JobsPage}" />
|
||||
</FlyoutItem>
|
||||
<FlyoutItem Title="Map" Route="map">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:MapPage}" />
|
||||
</FlyoutItem>
|
||||
<FlyoutItem Title="Settings" Route="settings">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:SettingsPage}" />
|
||||
</FlyoutItem>
|
||||
|
||||
</Shell>
|
||||
|
||||
@@ -10,5 +10,10 @@ public partial class AppShell : Shell
|
||||
|
||||
Routing.RegisterRoute("devicescan", typeof(DeviceScanPage));
|
||||
Routing.RegisterRoute("jobdetail", typeof(JobDetailPage));
|
||||
Routing.RegisterRoute("debug", typeof(HomePage));
|
||||
#if DEBUG
|
||||
// The raw device console is a development-only maintenance interface.
|
||||
Routing.RegisterRoute("deviceconsole", typeof(DebugPage));
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>
|
||||
|
||||
<!-- Display name -->
|
||||
<ApplicationTitle>Field Logger</ApplicationTitle>
|
||||
<ApplicationTitle>UM Trace</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.undergroundmagnetics.fieldlogger</ApplicationId>
|
||||
<ApplicationId>com.umagul.trace</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
@@ -46,12 +46,28 @@
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Apple Developer team used for iOS distribution: W2N8APPQ2C.
|
||||
Certificate/profile selection stays outside source control and is supplied by
|
||||
Keychain plus the installed com.umagul.trace provisioning profile at publish time. -->
|
||||
|
||||
<!-- SEC-1: the Google Maps Android key is injected at build time, never committed.
|
||||
Resolution order: (1) MapsApiKey MSBuild property (pass -p:MapsApiKey=... in CI from a
|
||||
secret), else (2) the MAPS_API_KEY environment variable, else (3) a gitignored
|
||||
maps.key.props at the repo root (copy maps.key.props.example). Left empty for local
|
||||
builds without a key — maps stay blank; the build does not embed a literal. -->
|
||||
<Import Project="$(MSBuildThisFileDirectory)..\maps.key.props"
|
||||
Condition="Exists('$(MSBuildThisFileDirectory)..\maps.key.props')" />
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
|
||||
<MapsApiKey Condition="'$(MapsApiKey)' == ''">$(MAPS_API_KEY)</MapsApiKey>
|
||||
<AndroidManifestPlaceholders>MAPS_API_KEY=$(MapsApiKey)</AndroidManifestPlaceholders>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- App Icon -->
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.png" Color="#FFFFFF" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
|
||||
<MauiSplashScreen Include="Resources\Splash\um_dual_receiver.png" Color="#EEF3F2" BaseSize="480,480" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
@@ -73,6 +89,10 @@
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Native map control (Google Maps on Android, Apple Maps on iOS/macOS). Not available on Windows,
|
||||
where MapPage falls back to a Google Maps JavaScript WebView instead. -->
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class MauiProgram
|
||||
builder.Services.AddSingleton<MaglinkService>();
|
||||
builder.Services.AddSingleton<DeviceConnectionManager>();
|
||||
builder.Services.AddSingleton<PointLogger>();
|
||||
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
|
||||
builder.Services.AddSingleton<IMqttSyncService, MqttSyncService>();
|
||||
|
||||
// View models
|
||||
builder.Services.AddSingleton<HomeViewModel>();
|
||||
@@ -44,6 +44,7 @@ public static class MauiProgram
|
||||
builder.Services.AddSingleton<MapViewModel>();
|
||||
builder.Services.AddSingleton<DebugViewModel>();
|
||||
builder.Services.AddSingleton<SettingsViewModel>();
|
||||
builder.Services.AddSingleton<AppStatusViewModel>();
|
||||
builder.Services.AddTransient<DeviceScanViewModel>();
|
||||
builder.Services.AddTransient<JobDetailViewModel>();
|
||||
|
||||
@@ -60,6 +61,7 @@ public static class MauiProgram
|
||||
|
||||
// Instantiate the point logger so it listens for receiver packets from startup.
|
||||
_ = app.Services.GetRequiredService<PointLogger>();
|
||||
_ = app.Services.GetRequiredService<IMqttSyncService>().StartAsync();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,13 @@ public sealed class LoggedPoint
|
||||
public string GpsRawSentence { get; set; } = "";
|
||||
public string GpsSerialNumber { get; set; } = "";
|
||||
|
||||
/// <summary>Reserved for MQTT sync.</summary>
|
||||
/// <summary>Stable UUIDv7 used for MQTT retries and cloud idempotency.</summary>
|
||||
[Indexed(Unique = true)]
|
||||
public string? SyncPointId { get; set; }
|
||||
|
||||
/// <summary>Machine-readable terminal/configuration error; null while pending or after success.</summary>
|
||||
public string? SyncError { get; set; }
|
||||
|
||||
public bool Synced { get; set; }
|
||||
|
||||
[Ignore]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true">
|
||||
<!-- Google Maps API key: replace with your key from https://console.cloud.google.com (Maps SDK for Android) -->
|
||||
<meta-data android:name="com.google.android.geo.API_KEY" android:value="AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI" />
|
||||
<!-- Google Maps API key is injected at build time from the MAPS_API_KEY MSBuild
|
||||
placeholder (see FieldLogger.csproj / maps.key.props.example). Never commit a key
|
||||
literal here — SEC-1. -->
|
||||
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${MAPS_API_KEY}" />
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
|
||||
<string>UM Trace uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Field Logger uses your location to display logged points on the map.</string>
|
||||
<string>UM Trace uses your location to display logged points on the map.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -29,8 +29,19 @@
|
||||
<key>XSAppIconAssets</key>
|
||||
<string>Assets.xcassets/appicon.appiconset</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Field Logger uses Bluetooth to connect to your Underground Magnetics locating receiver and Maglink RTK GPS receiver.</string>
|
||||
<string>UM Trace uses Bluetooth to connect to your Underground Magnetics locating receiver and MagLink RTK GNSS receiver.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Field Logger uses your location to display logged points on the map.</string>
|
||||
<string>UM Trace uses your location to display and record georeferenced locate points during an active field session.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>UM Trace uses your location during an active locating session to keep georeferenced field records current while the app is in the background.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<!-- The app uses standard, exempt TLS for HTTPS and MQTTS/WSS transport; it does
|
||||
not implement proprietary or non-standard cryptography. -->
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -36,8 +36,6 @@ More information: https://aka.ms/maui-privacy-manifest
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!--
|
||||
The entry below is only needed when you're using the Preferences API in your app.
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
@@ -45,7 +43,7 @@ More information: https://aka.ms/maui-privacy-manifest
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict> -->
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
|
||||
<rect x="0" y="0" width="456" height="456" fill="#FFFFFF" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 229 B |
BIN
FieldLogger/Resources/AppIcon/appiconfg.png
Normal file
BIN
FieldLogger/Resources/AppIcon/appiconfg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
FieldLogger/Resources/Splash/um_dual_receiver.png
Normal file
BIN
FieldLogger/Resources/Splash/um_dual_receiver.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -1,45 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xaml-comp compile="true" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
|
||||
|
||||
<Color x:Key="Primary">#512BD4</Color>
|
||||
<Color x:Key="PrimaryDark">#ac99ea</Color>
|
||||
<Color x:Key="PrimaryDarkText">#242424</Color>
|
||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
||||
<Color x:Key="SecondaryDarkText">#9880e5</Color>
|
||||
<Color x:Key="Tertiary">#2B0B98</Color>
|
||||
|
||||
<Color x:Key="White">White</Color>
|
||||
<Color x:Key="Black">Black</Color>
|
||||
<Color x:Key="Magenta">#D600AA</Color>
|
||||
<Color x:Key="MidnightBlue">#190649</Color>
|
||||
<Color x:Key="OffBlack">#1f1f1f</Color>
|
||||
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
||||
<Color x:Key="Gray300">#ACACAC</Color>
|
||||
<Color x:Key="Gray400">#919191</Color>
|
||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
||||
<Color x:Key="Gray600">#404040</Color>
|
||||
<Color x:Key="Gray900">#212121</Color>
|
||||
<Color x:Key="Gray950">#141414</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
|
||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
|
||||
|
||||
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
|
||||
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
|
||||
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
|
||||
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
|
||||
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
|
||||
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
|
||||
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
|
||||
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
|
||||
<!--
|
||||
Color values and legacy aliases are generated into GeneratedTokens.xaml from
|
||||
meta/contracts/design-tokens.json. Keep palette literals out of this file.
|
||||
-->
|
||||
</ResourceDictionary>
|
||||
183
FieldLogger/Resources/Styles/GeneratedTokens.xaml
Normal file
183
FieldLogger/Resources/Styles/GeneratedTokens.xaml
Normal file
@@ -0,0 +1,183 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xaml-comp compile="true" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- GENERATED FILE — DO NOT EDIT.
|
||||
Source: meta/contracts/design-tokens.json v3.0.0
|
||||
Run: node meta/scripts/generate-design-tokens.mjs -->
|
||||
<x:String x:Key="UlTokenVersion">3.0.0</x:String>
|
||||
|
||||
<!-- Named mode values. Runtime theme switching replaces the unqualified semantic colors below. -->
|
||||
<Color x:Key="UlDefaultColorCanvas">#EEF3F2</Color>
|
||||
<Color x:Key="UlDefaultColorSurface">#FCFEFD</Color>
|
||||
<Color x:Key="UlDefaultColorSurfaceRaised">#FFFFFF</Color>
|
||||
<Color x:Key="UlDefaultColorSurfaceStrong">#14252A</Color>
|
||||
<Color x:Key="UlDefaultColorText">#14252A</Color>
|
||||
<Color x:Key="UlDefaultColorTextMuted">#5D6B6E</Color>
|
||||
<Color x:Key="UlDefaultColorPrimary">#0B7774</Color>
|
||||
<Color x:Key="UlDefaultColorPrimaryPressed">#075F5D</Color>
|
||||
<Color x:Key="UlDefaultColorAccent">#B82D46</Color>
|
||||
<Color x:Key="UlDefaultColorBorder">#C7D2D0</Color>
|
||||
<Color x:Key="UlDefaultColorFocus">#B82D46</Color>
|
||||
<Color x:Key="UlDefaultColorMap">#DCE9E7</Color>
|
||||
<Color x:Key="UlDefaultColorSuccess">#347A55</Color>
|
||||
<Color x:Key="UlDefaultColorWarning">#A85C00</Color>
|
||||
<Color x:Key="UlDefaultColorError">#B42318</Color>
|
||||
<Color x:Key="UlDefaultColorNeutral">#40565B</Color>
|
||||
<Color x:Key="UlDefaultColorDisabled">#819092</Color>
|
||||
<Color x:Key="UlDefaultColorOnStrong">#FFFFFF</Color>
|
||||
<Color x:Key="UlDefaultColorOnPrimary">#FFFFFF</Color>
|
||||
<Color x:Key="UlDefaultColorOnAccent">#FFFFFF</Color>
|
||||
|
||||
<Color x:Key="UlSunlightColorCanvas">#FFFFFF</Color>
|
||||
<Color x:Key="UlSunlightColorSurface">#FFFFFF</Color>
|
||||
<Color x:Key="UlSunlightColorSurfaceRaised">#FFFFFF</Color>
|
||||
<Color x:Key="UlSunlightColorSurfaceStrong">#000000</Color>
|
||||
<Color x:Key="UlSunlightColorText">#000000</Color>
|
||||
<Color x:Key="UlSunlightColorTextMuted">#344448</Color>
|
||||
<Color x:Key="UlSunlightColorPrimary">#075F5D</Color>
|
||||
<Color x:Key="UlSunlightColorPrimaryPressed">#14252A</Color>
|
||||
<Color x:Key="UlSunlightColorAccent">#982138</Color>
|
||||
<Color x:Key="UlSunlightColorBorder">#5D6B6E</Color>
|
||||
<Color x:Key="UlSunlightColorFocus">#982138</Color>
|
||||
<Color x:Key="UlSunlightColorMap">#E8F0EF</Color>
|
||||
<Color x:Key="UlSunlightColorSuccess">#245F40</Color>
|
||||
<Color x:Key="UlSunlightColorWarning">#754000</Color>
|
||||
<Color x:Key="UlSunlightColorError">#871A12</Color>
|
||||
<Color x:Key="UlSunlightColorNeutral">#14252A</Color>
|
||||
<Color x:Key="UlSunlightColorDisabled">#344448</Color>
|
||||
<Color x:Key="UlSunlightColorOnStrong">#FFFFFF</Color>
|
||||
<Color x:Key="UlSunlightColorOnPrimary">#FFFFFF</Color>
|
||||
<Color x:Key="UlSunlightColorOnAccent">#FFFFFF</Color>
|
||||
|
||||
<!-- Active values start in default mode. All handwritten styles consume only these keys. -->
|
||||
<Color x:Key="UlColorCanvas">#EEF3F2</Color>
|
||||
<SolidColorBrush x:Key="UlBrushCanvas" Color="{StaticResource UlColorCanvas}" />
|
||||
<Color x:Key="UlColorSurface">#FCFEFD</Color>
|
||||
<SolidColorBrush x:Key="UlBrushSurface" Color="{StaticResource UlColorSurface}" />
|
||||
<Color x:Key="UlColorSurfaceRaised">#FFFFFF</Color>
|
||||
<SolidColorBrush x:Key="UlBrushSurfaceRaised" Color="{StaticResource UlColorSurfaceRaised}" />
|
||||
<Color x:Key="UlColorSurfaceStrong">#14252A</Color>
|
||||
<SolidColorBrush x:Key="UlBrushSurfaceStrong" Color="{StaticResource UlColorSurfaceStrong}" />
|
||||
<Color x:Key="UlColorText">#14252A</Color>
|
||||
<SolidColorBrush x:Key="UlBrushText" Color="{StaticResource UlColorText}" />
|
||||
<Color x:Key="UlColorTextMuted">#5D6B6E</Color>
|
||||
<SolidColorBrush x:Key="UlBrushTextMuted" Color="{StaticResource UlColorTextMuted}" />
|
||||
<Color x:Key="UlColorPrimary">#0B7774</Color>
|
||||
<SolidColorBrush x:Key="UlBrushPrimary" Color="{StaticResource UlColorPrimary}" />
|
||||
<Color x:Key="UlColorPrimaryPressed">#075F5D</Color>
|
||||
<SolidColorBrush x:Key="UlBrushPrimaryPressed" Color="{StaticResource UlColorPrimaryPressed}" />
|
||||
<Color x:Key="UlColorAccent">#B82D46</Color>
|
||||
<SolidColorBrush x:Key="UlBrushAccent" Color="{StaticResource UlColorAccent}" />
|
||||
<Color x:Key="UlColorBorder">#C7D2D0</Color>
|
||||
<SolidColorBrush x:Key="UlBrushBorder" Color="{StaticResource UlColorBorder}" />
|
||||
<Color x:Key="UlColorFocus">#B82D46</Color>
|
||||
<SolidColorBrush x:Key="UlBrushFocus" Color="{StaticResource UlColorFocus}" />
|
||||
<Color x:Key="UlColorMap">#DCE9E7</Color>
|
||||
<SolidColorBrush x:Key="UlBrushMap" Color="{StaticResource UlColorMap}" />
|
||||
<Color x:Key="UlColorSuccess">#347A55</Color>
|
||||
<SolidColorBrush x:Key="UlBrushSuccess" Color="{StaticResource UlColorSuccess}" />
|
||||
<Color x:Key="UlColorWarning">#A85C00</Color>
|
||||
<SolidColorBrush x:Key="UlBrushWarning" Color="{StaticResource UlColorWarning}" />
|
||||
<Color x:Key="UlColorError">#B42318</Color>
|
||||
<SolidColorBrush x:Key="UlBrushError" Color="{StaticResource UlColorError}" />
|
||||
<Color x:Key="UlColorNeutral">#40565B</Color>
|
||||
<SolidColorBrush x:Key="UlBrushNeutral" Color="{StaticResource UlColorNeutral}" />
|
||||
<Color x:Key="UlColorDisabled">#819092</Color>
|
||||
<SolidColorBrush x:Key="UlBrushDisabled" Color="{StaticResource UlColorDisabled}" />
|
||||
<Color x:Key="UlColorOnStrong">#FFFFFF</Color>
|
||||
<SolidColorBrush x:Key="UlBrushOnStrong" Color="{StaticResource UlColorOnStrong}" />
|
||||
<Color x:Key="UlColorOnPrimary">#FFFFFF</Color>
|
||||
<SolidColorBrush x:Key="UlBrushOnPrimary" Color="{StaticResource UlColorOnPrimary}" />
|
||||
<Color x:Key="UlColorOnAccent">#FFFFFF</Color>
|
||||
<SolidColorBrush x:Key="UlBrushOnAccent" Color="{StaticResource UlColorOnAccent}" />
|
||||
|
||||
<!-- Temporary aliases keep unmigrated MAUI screens compatible without a second color source. -->
|
||||
<Color x:Key="Primary">#0B7774</Color>
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}" />
|
||||
<Color x:Key="PrimaryDark">#075F5D</Color>
|
||||
<SolidColorBrush x:Key="PrimaryDarkBrush" Color="{StaticResource PrimaryDark}" />
|
||||
<Color x:Key="PrimaryDarkText">#14252A</Color>
|
||||
<SolidColorBrush x:Key="PrimaryDarkTextBrush" Color="{StaticResource PrimaryDarkText}" />
|
||||
<Color x:Key="Secondary">#DCE9E7</Color>
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}" />
|
||||
<Color x:Key="SecondaryDarkText">#5D6B6E</Color>
|
||||
<SolidColorBrush x:Key="SecondaryDarkTextBrush" Color="{StaticResource SecondaryDarkText}" />
|
||||
<Color x:Key="Tertiary">#B82D46</Color>
|
||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}" />
|
||||
<Color x:Key="White">#FFFFFF</Color>
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}" />
|
||||
<Color x:Key="Black">#14252A</Color>
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}" />
|
||||
<Color x:Key="Magenta">#B82D46</Color>
|
||||
<SolidColorBrush x:Key="MagentaBrush" Color="{StaticResource Magenta}" />
|
||||
<Color x:Key="MidnightBlue">#14252A</Color>
|
||||
<SolidColorBrush x:Key="MidnightBlueBrush" Color="{StaticResource MidnightBlue}" />
|
||||
<Color x:Key="OffBlack">#14252A</Color>
|
||||
<SolidColorBrush x:Key="OffBlackBrush" Color="{StaticResource OffBlack}" />
|
||||
<Color x:Key="Gray100">#EEF3F2</Color>
|
||||
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}" />
|
||||
<Color x:Key="Gray200">#C7D2D0</Color>
|
||||
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}" />
|
||||
<Color x:Key="Gray300">#819092</Color>
|
||||
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}" />
|
||||
<Color x:Key="Gray400">#5D6B6E</Color>
|
||||
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}" />
|
||||
<Color x:Key="Gray500">#5D6B6E</Color>
|
||||
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}" />
|
||||
<Color x:Key="Gray600">#40565B</Color>
|
||||
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}" />
|
||||
<Color x:Key="Gray900">#14252A</Color>
|
||||
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}" />
|
||||
<Color x:Key="Gray950">#14252A</Color>
|
||||
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}" />
|
||||
|
||||
<!-- Cross-platform dimensions are dp in MAUI and generated as px on the web. -->
|
||||
<x:Double x:Key="UlSpace2xs">4</x:Double>
|
||||
<x:Double x:Key="UlSpaceXs">8</x:Double>
|
||||
<x:Double x:Key="UlSpaceSm">12</x:Double>
|
||||
<x:Double x:Key="UlSpaceMd">16</x:Double>
|
||||
<x:Double x:Key="UlSpaceLg">24</x:Double>
|
||||
<x:Double x:Key="UlSpaceXl">32</x:Double>
|
||||
<x:Double x:Key="UlSpace2xl">48</x:Double>
|
||||
<x:Double x:Key="UlRadiusSm">8</x:Double>
|
||||
<x:Double x:Key="UlRadiusMd">12</x:Double>
|
||||
<x:Double x:Key="UlRadiusLg">20</x:Double>
|
||||
<x:Double x:Key="UlRadiusPill">999</x:Double>
|
||||
<x:Double x:Key="UlTouchMinimum">48</x:Double>
|
||||
<x:Double x:Key="UlTouchComfortable">56</x:Double>
|
||||
<x:Double x:Key="UlTouchPrimary">64</x:Double>
|
||||
|
||||
<!-- IBM Plex is the target family. Checked-in Open Sans/system mono are offline-safe fallbacks until licensed Plex assets land. -->
|
||||
<x:String x:Key="UlTypeFamilySans">OpenSansRegular</x:String>
|
||||
<x:String x:Key="UlTypeFamilySansStrong">OpenSansSemibold</x:String>
|
||||
<x:String x:Key="UlTypeFamilyMono">monospace</x:String>
|
||||
<x:Double x:Key="UlTypeDisplaySize">40</x:Double>
|
||||
<x:Double x:Key="UlTypeDisplayLineHeight">48</x:Double>
|
||||
<x:Int32 x:Key="UlTypeDisplayWeight">700</x:Int32>
|
||||
<x:Double x:Key="UlTypePageSize">28</x:Double>
|
||||
<x:Double x:Key="UlTypePageLineHeight">34</x:Double>
|
||||
<x:Int32 x:Key="UlTypePageWeight">700</x:Int32>
|
||||
<x:Double x:Key="UlTypeSectionSize">22</x:Double>
|
||||
<x:Double x:Key="UlTypeSectionLineHeight">28</x:Double>
|
||||
<x:Int32 x:Key="UlTypeSectionWeight">600</x:Int32>
|
||||
<x:Double x:Key="UlTypeBodySize">16</x:Double>
|
||||
<x:Double x:Key="UlTypeBodyLineHeight">24</x:Double>
|
||||
<x:Int32 x:Key="UlTypeBodyWeight">400</x:Int32>
|
||||
<x:Double x:Key="UlTypeBodySmSize">14</x:Double>
|
||||
<x:Double x:Key="UlTypeBodySmLineHeight">20</x:Double>
|
||||
<x:Int32 x:Key="UlTypeBodySmWeight">400</x:Int32>
|
||||
<x:Double x:Key="UlTypeLabelSize">14</x:Double>
|
||||
<x:Double x:Key="UlTypeLabelLineHeight">20</x:Double>
|
||||
<x:Int32 x:Key="UlTypeLabelWeight">600</x:Int32>
|
||||
<x:Double x:Key="UlTypeMonoSize">16</x:Double>
|
||||
<x:Double x:Key="UlTypeMonoLineHeight">22</x:Double>
|
||||
<x:Int32 x:Key="UlTypeMonoWeight">500</x:Int32>
|
||||
<x:UInt32 x:Key="UlMotionFast">120</x:UInt32>
|
||||
<x:UInt32 x:Key="UlMotionBase">200</x:UInt32>
|
||||
<x:Double x:Key="UlBreakpointCompact">600</x:Double>
|
||||
<x:Double x:Key="UlBreakpointMedium">900</x:Double>
|
||||
<x:Double x:Key="UlBreakpointWide">1200</x:Double>
|
||||
</ResourceDictionary>
|
||||
@@ -1,444 +1,293 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xaml-comp compile="true" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- Survey Teal shared component foundation. DynamicResource colors allow sunlight switching. -->
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorCanvas}" />
|
||||
</Style>
|
||||
<Style TargetType="ActivityIndicator">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Color" Value="{DynamicResource UlColorPrimary}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="IndicatorView">
|
||||
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
||||
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="StrokeShape" Value="Rectangle"/>
|
||||
<Setter Property="StrokeThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="BoxView">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="Stroke" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="StrokeShape" Value="RoundRectangle 12" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14,10"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorOnPrimary}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySansStrong}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeLabelSize}" />
|
||||
<Setter Property="FontAttributes" Value="Bold" />
|
||||
<Setter Property="BorderColor" Value="Transparent" />
|
||||
<Setter Property="BorderWidth" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState x:Name="Pressed">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorPrimaryPressed}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorSurfaceStrong}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorDisabled}" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup x:Name="FocusStates">
|
||||
<VisualState x:Name="Unfocused" />
|
||||
<VisualState x:Name="Focused">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BorderColor" Value="{DynamicResource UlColorFocus}" />
|
||||
<Setter Property="BorderWidth" Value="3" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DatePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Style x:Key="UlButtonPrimary" TargetType="Button" />
|
||||
<Style x:Key="UlButtonSecondary" TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="BorderColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="BorderWidth" Value="2" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Style x:Key="UlButtonAccent" TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorOnAccent}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorAccent}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Style x:Key="UlButtonPrimaryField" TargetType="Button">
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchPrimary}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
<Setter Property="Padding" Value="24,16" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ImageButton">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
<Setter Property="BorderColor" Value="Transparent"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="0"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="BorderColor" Value="Transparent" />
|
||||
<Setter Property="BorderWidth" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</VisualState.Setters>
|
||||
<VisualState x:Name="Pressed">
|
||||
<VisualState.Setters><Setter Property="Opacity" Value="0.72" /></VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters><Setter Property="Opacity" Value="0.45" /></VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="UlIconButton" TargetType="ImageButton">
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="BorderColor" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="BorderWidth" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Span">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="Headline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="32" />
|
||||
<Style x:Key="UlPageTitle" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySansStrong}" />
|
||||
<Setter Property="FontAttributes" Value="Bold" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypePageSize}" />
|
||||
</Style>
|
||||
<Style x:Key="UlSectionTitle" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySansStrong}" />
|
||||
<Setter Property="FontAttributes" Value="Bold" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeSectionSize}" />
|
||||
</Style>
|
||||
<Style x:Key="UlBodyMuted" TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorTextMuted}" />
|
||||
</Style>
|
||||
<Style x:Key="UlTechnicalValue" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilyMono}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeMonoSize}" />
|
||||
</Style>
|
||||
<Style x:Key="Headline" TargetType="Label" BasedOn="{StaticResource UlPageTitle}">
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style x:Key="SubHeadline" TargetType="Label" BasedOn="{StaticResource UlSectionTitle}">
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="SubHeadline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Style x:Key="UlCard" TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurfaceRaised}" />
|
||||
<Setter Property="Stroke" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="StrokeShape" Value="RoundRectangle 12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
</Style>
|
||||
<Style x:Key="UlJobRow" TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="Stroke" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="16,12" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchComfortable}" />
|
||||
</Style>
|
||||
<Style x:Key="UlStatusBadge" TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="Stroke" Value="{DynamicResource UlColorNeutral}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="StrokeShape" Value="RoundRectangle 999" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<!-- Consumers must supply visible status text and an icon; color is supplemental. -->
|
||||
</Style>
|
||||
<Style x:Key="UlDeviceChip" TargetType="Border" BasedOn="{StaticResource UlStatusBadge}">
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
</Style>
|
||||
<Style x:Key="UlMetricValue" TargetType="Label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilyMono}" />
|
||||
<Setter Property="FontAttributes" Value="Bold" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeDisplaySize}" />
|
||||
</Style>
|
||||
<Style x:Key="UlEmptyStateTitle" TargetType="Label" BasedOn="{StaticResource UlSectionTitle}">
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style x:Key="UlEmptyStateBody" TargetType="Label" BasedOn="{StaticResource UlBodyMuted}">
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ListView">
|
||||
<Setter Property="SeparatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="RefreshControlColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="PlaceholderColor" Value="{DynamicResource UlColorTextMuted}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
<Style x:Key="UlInput" TargetType="Entry" />
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="PlaceholderColor" Value="{DynamicResource UlColorTextMuted}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchComfortable}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Picker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="TitleColor" Value="{DynamicResource UlColorTextMuted}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Style TargetType="DatePicker">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RefreshView">
|
||||
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchBar">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchHandler">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shadow">
|
||||
<Setter Property="Radius" Value="15" />
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Offset" Value="10,10" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SwipeItem">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="On">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Off">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TimePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
<Style TargetType="TitleBar">
|
||||
<Setter Property="MinimumHeightRequest" Value="32"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="TitleActiveStates">
|
||||
<VisualState x:Name="TitleBarTitleActive">
|
||||
<VisualState.Setters>
|
||||
<Style TargetType="SearchBar">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="PlaceholderColor" Value="{DynamicResource UlColorTextMuted}" />
|
||||
<Setter Property="CancelButtonColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="BackgroundColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="TextColor" Value="{DynamicResource UlColorText}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="TitleBarTitleInactive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
<Setter Property="FontFamily" Value="{StaticResource UlTypeFamilySans}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource UlTypeBodySize}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
-->
|
||||
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="ThumbColor" Value="{DynamicResource UlColorSurface}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
<Setter Property="MinimumWidthRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="ThumbColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="{StaticResource UlTouchMinimum}" />
|
||||
</Style>
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="ProgressColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
</Style>
|
||||
<Style TargetType="RefreshView">
|
||||
<Setter Property="RefreshColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
</Style>
|
||||
<Style TargetType="ListView">
|
||||
<Setter Property="SeparatorColor" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="RefreshControlColor" Value="{DynamicResource UlColorPrimary}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="Shell.BackgroundColor" Value="{DynamicResource UlColorSurfaceStrong}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="Shell.DisabledColor" Value="{DynamicResource UlColorDisabled}" />
|
||||
<Setter Property="Shell.UnselectedColor" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{DynamicResource UlColorSurfaceStrong}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{DynamicResource UlColorBorder}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BarBackgroundColor" Value="{DynamicResource UlColorSurfaceStrong}" />
|
||||
<Setter Property="BarTextColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="IconColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TabbedPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BarBackgroundColor" Value="{DynamicResource UlColorSurfaceStrong}" />
|
||||
<Setter Property="BarTextColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
<Setter Property="UnselectedTabColor" Value="{DynamicResource UlColorBorder}" />
|
||||
<Setter Property="SelectedTabColor" Value="{DynamicResource UlColorOnStrong}" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
38
FieldLogger/Resources/Styles/ThemeManager.cs
Normal file
38
FieldLogger/Resources/Styles/ThemeManager.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace FieldLogger.Resources.Styles;
|
||||
|
||||
public enum UlThemeMode
|
||||
{
|
||||
Default,
|
||||
Sunlight,
|
||||
}
|
||||
|
||||
/// <summary>Applies generated semantic colors. Call on the UI thread after app resources initialize.</summary>
|
||||
public static class ThemeManager
|
||||
{
|
||||
private static readonly string[] SemanticColors =
|
||||
[
|
||||
"Canvas", "Surface", "SurfaceRaised", "SurfaceStrong", "Text", "TextMuted",
|
||||
"Primary", "PrimaryPressed", "Accent", "Border", "Focus", "Map", "Success",
|
||||
"Warning", "Error", "Neutral", "Disabled", "OnStrong", "OnPrimary", "OnAccent",
|
||||
];
|
||||
|
||||
public static UlThemeMode Current { get; private set; } = UlThemeMode.Default;
|
||||
|
||||
public static void Apply(UlThemeMode mode)
|
||||
{
|
||||
var resources = Application.Current?.Resources
|
||||
?? throw new InvalidOperationException("Application resources are not initialized.");
|
||||
var prefix = mode == UlThemeMode.Sunlight ? "UlSunlightColor" : "UlDefaultColor";
|
||||
|
||||
foreach (var name in SemanticColors)
|
||||
{
|
||||
if (!resources.TryGetValue($"{prefix}{name}", out var value) || value is not Color color)
|
||||
{
|
||||
throw new InvalidOperationException($"Generated theme token {prefix}{name} is missing.");
|
||||
}
|
||||
resources[$"UlColor{name}"] = color;
|
||||
resources[$"UlBrush{name}"] = new SolidColorBrush(color);
|
||||
}
|
||||
Current = mode;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ public sealed class BleScanner
|
||||
new InvalidOperationException("Bluetooth is turned off."),
|
||||
BluetoothState.Unauthorized =>
|
||||
new PermissionException(
|
||||
"Bluetooth access is denied. Enable Field Logger in System Settings > Privacy & Security > Bluetooth."),
|
||||
"Bluetooth access is denied. Enable UM Trace in System Settings > Privacy & Security > Bluetooth."),
|
||||
BluetoothState.Unavailable =>
|
||||
new InvalidOperationException("Bluetooth is not available on this Mac."),
|
||||
_ => new InvalidOperationException($"Bluetooth is not ready (state: {_bluetooth.State}).")
|
||||
|
||||
@@ -76,6 +76,23 @@ public sealed class AppDatabase
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<LoggedPoint>> GetUnsyncedPointsAsync()
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<LoggedPoint>()
|
||||
.Where(point => !point.Synced)
|
||||
.OrderBy(point => point.TimestampUtc)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LoggedPoint?> GetPointBySyncIdAsync(string syncPointId)
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<LoggedPoint>()
|
||||
.Where(point => point.SyncPointId == syncPointId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetPointCountAsync(int jobId)
|
||||
{
|
||||
var db = await Db;
|
||||
@@ -87,4 +104,27 @@ public sealed class AppDatabase
|
||||
var db = await Db;
|
||||
await db.DeleteAsync<LoggedPoint>(pointId);
|
||||
}
|
||||
|
||||
public async Task UpdatePointAsync(LoggedPoint point)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.UpdateAsync(point);
|
||||
}
|
||||
|
||||
public async Task MarkPointSyncedAsync(string syncPointId)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.ExecuteAsync(
|
||||
"UPDATE points SET Synced = 1, SyncError = NULL WHERE SyncPointId = ?",
|
||||
syncPointId);
|
||||
}
|
||||
|
||||
public async Task MarkPointSyncErrorAsync(string syncPointId, string reasonCode)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.ExecuteAsync(
|
||||
"UPDATE points SET Synced = 0, SyncError = ? WHERE SyncPointId = ?",
|
||||
reasonCode,
|
||||
syncPointId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Data;
|
||||
using FieldLogger.Services.Sync;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FieldLogger.Services;
|
||||
@@ -14,6 +15,7 @@ public sealed class PointLogger
|
||||
private readonly MaglinkService _gps;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IMqttSyncService _sync;
|
||||
private readonly ILogger<PointLogger> _logger;
|
||||
|
||||
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
|
||||
@@ -23,12 +25,13 @@ public sealed class PointLogger
|
||||
public event EventHandler? PacketIgnoredNoJob;
|
||||
|
||||
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
|
||||
SettingsService settings, ILogger<PointLogger> logger)
|
||||
SettingsService settings, IMqttSyncService sync, ILogger<PointLogger> logger)
|
||||
{
|
||||
_locator = locator;
|
||||
_gps = gps;
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
_sync = sync;
|
||||
_logger = logger;
|
||||
|
||||
_locator.PacketReceived += OnPacketReceived;
|
||||
@@ -53,10 +56,13 @@ public sealed class PointLogger
|
||||
|
||||
var fix = _gps.FreshFix();
|
||||
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
|
||||
point.SyncPointId = Guid.CreateVersion7().ToString();
|
||||
await _database.AddPointAsync(point);
|
||||
await _sync.PublishPointAsync(point);
|
||||
|
||||
_logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})",
|
||||
point.Id, jobId, point.GpsValid);
|
||||
_logger.LogInformation(
|
||||
"Point {Id}/{SyncPointId} saved to job {JobId} (gps valid: {GpsValid}, sync error: {SyncError})",
|
||||
point.Id, point.SyncPointId, jobId, point.GpsValid, point.SyncError);
|
||||
MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed class SettingsService
|
||||
private const string RtkNameKey = "device.rtk.name";
|
||||
private const string ActiveJobKey = "job.active.id";
|
||||
private const string MapsApiKeyKey = "maps.apikey";
|
||||
private const string MqttEnabledKey = "mqtt.enabled";
|
||||
private const string MqttHostKey = "mqtt.host";
|
||||
private const string MqttPortKey = "mqtt.port";
|
||||
private const string MqttOrgIdKey = "mqtt.org.id";
|
||||
private const string MqttClientIdKey = "mqtt.client.id";
|
||||
private const string MqttPasswordKey = "mqtt.password";
|
||||
|
||||
public Guid? GetSavedDeviceId(DeviceKind kind)
|
||||
{
|
||||
@@ -53,6 +59,52 @@ public sealed class SettingsService
|
||||
set => Preferences.Default.Set(MapsApiKeyKey, value);
|
||||
}
|
||||
|
||||
public bool MqttEnabled
|
||||
{
|
||||
get => Preferences.Default.Get(MqttEnabledKey, false);
|
||||
set => Preferences.Default.Set(MqttEnabledKey, value);
|
||||
}
|
||||
|
||||
public string MqttHost
|
||||
{
|
||||
get => Preferences.Default.Get(MqttHostKey, "dev.hub.umagul.net");
|
||||
set => Preferences.Default.Set(MqttHostKey, value.Trim());
|
||||
}
|
||||
|
||||
public int MqttPort
|
||||
{
|
||||
get => Preferences.Default.Get(MqttPortKey, 443);
|
||||
set => Preferences.Default.Set(MqttPortKey, value);
|
||||
}
|
||||
|
||||
/// <summary>The interim MQTT username is the organization id.</summary>
|
||||
public string MqttOrgId
|
||||
{
|
||||
get => Preferences.Default.Get(MqttOrgIdKey, string.Empty);
|
||||
set => Preferences.Default.Set(MqttOrgIdKey, value.Trim());
|
||||
}
|
||||
|
||||
public string MqttClientId
|
||||
{
|
||||
get
|
||||
{
|
||||
var existing = Preferences.Default.Get(MqttClientIdKey, string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(existing)) return existing;
|
||||
var created = $"fieldlogger-{Guid.NewGuid():N}";
|
||||
Preferences.Default.Set(MqttClientIdKey, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string?> GetMqttPasswordAsync() => SecureStorage.Default.GetAsync(MqttPasswordKey);
|
||||
|
||||
public Task SetMqttPasswordAsync(string password) =>
|
||||
string.IsNullOrWhiteSpace(password)
|
||||
? throw new ArgumentException("MQTT password cannot be empty.", nameof(password))
|
||||
: SecureStorage.Default.SetAsync(MqttPasswordKey, password);
|
||||
|
||||
public void ClearMqttPassword() => SecureStorage.Default.Remove(MqttPasswordKey);
|
||||
|
||||
private static string IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey;
|
||||
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
|
||||
}
|
||||
|
||||
@@ -3,25 +3,30 @@ using FieldLogger.Models;
|
||||
namespace FieldLogger.Services.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for the future MQTT synchronization layer:
|
||||
/// - subscribe to receive jobs configured on the server
|
||||
/// - publish logged points as they are captured
|
||||
/// - reconcile the Synced flags on <see cref="Job"/> and <see cref="LoggedPoint"/>
|
||||
/// Planned implementation: MQTTnet client against the configured broker.
|
||||
/// Durable app MQTT synchronization. Captures are persisted locally before this service queues
|
||||
/// them, and queue rows are released only by an application-level ACCEPTED/DUPLICATE ack.
|
||||
/// </summary>
|
||||
public interface IMqttSyncService
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
string Status { get; }
|
||||
event EventHandler<string>? StatusChanged;
|
||||
event EventHandler<PointSyncFailureEventArgs>? PointRejected;
|
||||
Task StartAsync(CancellationToken cancellationToken = default);
|
||||
Task StopAsync();
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync();
|
||||
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>No-op stand-in until the MQTT backend exists.</summary>
|
||||
public sealed class NullMqttSyncService : IMqttSyncService
|
||||
public sealed class PointSyncFailureEventArgs : EventArgs
|
||||
{
|
||||
public bool IsConnected => false;
|
||||
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync() => Task.CompletedTask;
|
||||
public Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public LoggedPoint Point { get; }
|
||||
public string ReasonCode { get; }
|
||||
|
||||
public PointSyncFailureEventArgs(LoggedPoint point, string reasonCode)
|
||||
{
|
||||
Point = point;
|
||||
ReasonCode = reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
398
FieldLogger/Services/Sync/MqttSyncService.cs
Normal file
398
FieldLogger/Services/Sync/MqttSyncService.cs
Normal file
@@ -0,0 +1,398 @@
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SyncCore = FieldLogger.Sync;
|
||||
|
||||
namespace FieldLogger.Services.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the MAUI capture database to the workload-free durable MQTT engine. Network loss never
|
||||
/// deletes a capture: the local point and outbound queue are separate durable records, and an
|
||||
/// application acknowledgement is the only path that marks the local point synced.
|
||||
/// </summary>
|
||||
public sealed class MqttSyncService : IMqttSyncService
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly ILogger<MqttSyncService> _logger;
|
||||
private readonly SyncCore.SqliteOutboundStore _store;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _pumpGate = new(1, 1);
|
||||
private CancellationTokenSource? _lifetime;
|
||||
private Task? _backgroundLoop;
|
||||
private SyncCore.MqttnetTransport? _transport;
|
||||
private SyncCore.MqttSyncEngine? _engine;
|
||||
private bool _started;
|
||||
|
||||
public bool IsConnected => _transport?.IsConnected == true;
|
||||
public string Status { get; private set; } = "Not started";
|
||||
public event EventHandler<string>? StatusChanged;
|
||||
public event EventHandler<PointSyncFailureEventArgs>? PointRejected;
|
||||
|
||||
public MqttSyncService(SettingsService settings, AppDatabase database, ILogger<MqttSyncService> logger)
|
||||
{
|
||||
_settings = settings;
|
||||
_database = database;
|
||||
_logger = logger;
|
||||
_store = new SyncCore.SqliteOutboundStore(
|
||||
Path.Combine(FileSystem.AppDataDirectory, "fieldlogger-sync.db3"));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
await _store.InitAsync();
|
||||
_lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_backgroundLoop = RunAsync(_lifetime.Token);
|
||||
|
||||
if (_settings.MqttEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Initial MQTT connection failed; durable retry loop remains active");
|
||||
SetStatus($"Waiting to reconnect: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus("Sync disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_lifetime?.Cancel();
|
||||
if (_backgroundLoop is not null)
|
||||
{
|
||||
try { await _backgroundLoop; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
await DisconnectTransportAsync();
|
||||
SetStatus("Stopped");
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ConnectCoreAsync(forceReconnect: true, cancellationToken);
|
||||
await QueueUnsyncedAsync(cancellationToken);
|
||||
await DrainAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
await DisconnectTransportAsync();
|
||||
SetStatus("Disconnected");
|
||||
}
|
||||
|
||||
public async Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _pumpGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await QueuePointAsync(point, cancellationToken);
|
||||
if (IsConnected && _engine is not null)
|
||||
await _engine.DrainOnceAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pumpGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task QueuePointAsync(LoggedPoint point, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(point.SyncPointId))
|
||||
{
|
||||
point.SyncPointId = Guid.CreateVersion7().ToString();
|
||||
point.Synced = false;
|
||||
await _database.UpdatePointAsync(point);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.MqttOrgId))
|
||||
{
|
||||
await RecordFailureAsync(point, "SYNC_NOT_CONFIGURED");
|
||||
return;
|
||||
}
|
||||
|
||||
var job = await _database.GetJobAsync(point.JobId);
|
||||
if (job is null)
|
||||
{
|
||||
await RecordFailureAsync(point, "LOCAL_JOB_NOT_FOUND");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = ToSyncPoint(point);
|
||||
var topic = $"ul/{_settings.MqttOrgId}/app/{_settings.MqttClientId}/log/points";
|
||||
string? remoteJobId = string.IsNullOrWhiteSpace(job.RemoteId) ? null : job.RemoteId;
|
||||
string? ticket = remoteJobId is null ? TicketFor(job) : null;
|
||||
var outbound = SyncCore.OutboundMessage.FromPoint(
|
||||
record,
|
||||
topic,
|
||||
DateTimeOffset.UtcNow,
|
||||
jobId: remoteJobId,
|
||||
ticket: ticket);
|
||||
await _store.EnqueueAsync(outbound);
|
||||
point.SyncError = null;
|
||||
await _database.UpdatePointAsync(point);
|
||||
}
|
||||
catch (SyncCore.PointNotPublishableException ex)
|
||||
{
|
||||
await RecordFailureAsync(point, ex.ReasonCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enqueue point {PointId}", point.SyncPointId);
|
||||
await RecordFailureAsync(point, "QUEUE_ERROR");
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
private async Task QueueUnsyncedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var point in await _database.GetUnsyncedPointsAsync())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Permanent capture/server rejections require operator action, not an automatic loop.
|
||||
if (point.SyncError is not null && point.SyncError is not "SYNC_NOT_CONFIGURED" and not "QUEUE_ERROR")
|
||||
continue;
|
||||
await QueuePointAsync(point, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectCoreAsync(bool forceReconnect, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_settings.MqttEnabled)
|
||||
throw new InvalidOperationException("MQTT sync is disabled in Settings.");
|
||||
if (string.IsNullOrWhiteSpace(_settings.MqttHost) || string.IsNullOrWhiteSpace(_settings.MqttOrgId))
|
||||
throw new InvalidOperationException("MQTT host and organization id are required.");
|
||||
string password = await _settings.GetMqttPasswordAsync()
|
||||
?? throw new InvalidOperationException("MQTT password is required.");
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (IsConnected && !forceReconnect) return;
|
||||
await DisconnectTransportAsync();
|
||||
|
||||
_transport = new SyncCore.MqttnetTransport(new SyncCore.MqttBrokerConfig
|
||||
{
|
||||
Host = _settings.MqttHost,
|
||||
Port = _settings.MqttPort,
|
||||
WebSocketUri = _settings.MqttPort == 443
|
||||
? $"wss://{_settings.MqttHost}/mqtt"
|
||||
: null,
|
||||
ClientId = _settings.MqttClientId,
|
||||
Username = _settings.MqttOrgId,
|
||||
Password = password,
|
||||
UseTls = true,
|
||||
});
|
||||
_engine = new SyncCore.MqttSyncEngine(
|
||||
_transport,
|
||||
_store,
|
||||
new SyncCore.SyncOptions
|
||||
{
|
||||
OrgId = _settings.MqttOrgId,
|
||||
ClientId = _settings.MqttClientId,
|
||||
});
|
||||
_engine.PointAccepted += HandleAcceptedAsync;
|
||||
_engine.PointRejected += HandleRejectedAsync;
|
||||
await _engine.ConnectAsync(cancellationToken);
|
||||
SetStatus("Connected");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
if (!_settings.MqttEnabled)
|
||||
{
|
||||
SetStatus("Sync disabled");
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
SetStatus("Connecting…");
|
||||
await ConnectCoreAsync(forceReconnect: false, cancellationToken);
|
||||
await QueueUnsyncedAsync(cancellationToken);
|
||||
}
|
||||
await DrainAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MQTT sync iteration failed; queued data retained");
|
||||
SetStatus($"Offline — retrying: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_engine is null || !IsConnected) return;
|
||||
await _pumpGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
int published = await _engine.DrainOnceAsync(cancellationToken);
|
||||
if (published > 0) SetStatus($"Connected — {published} point(s) awaiting ack");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pumpGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAcceptedAsync(string pointId)
|
||||
{
|
||||
await _database.MarkPointSyncedAsync(pointId);
|
||||
SetStatus("Connected — synced");
|
||||
}
|
||||
|
||||
private async Task HandleRejectedAsync(SyncCore.OutboundMessage message)
|
||||
{
|
||||
string reason = message.LastReason ?? "REJECTED";
|
||||
await _database.MarkPointSyncErrorAsync(message.PointId, reason);
|
||||
var point = await _database.GetPointBySyncIdAsync(message.PointId);
|
||||
if (point is not null) PointRejected?.Invoke(this, new PointSyncFailureEventArgs(point, reason));
|
||||
SetStatus($"Point rejected: {reason}");
|
||||
}
|
||||
|
||||
private async Task RecordFailureAsync(LoggedPoint point, string reasonCode)
|
||||
{
|
||||
point.SyncError = reasonCode;
|
||||
point.Synced = false;
|
||||
await _database.UpdatePointAsync(point);
|
||||
PointRejected?.Invoke(this, new PointSyncFailureEventArgs(point, reasonCode));
|
||||
SetStatus($"Point not queued: {reasonCode}");
|
||||
}
|
||||
|
||||
private async Task DisconnectTransportAsync()
|
||||
{
|
||||
if (_transport is null) return;
|
||||
try { await _transport.DisposeAsync(); }
|
||||
finally
|
||||
{
|
||||
_transport = null;
|
||||
_engine = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetStatus(string value)
|
||||
{
|
||||
if (Status == value) return;
|
||||
Status = value;
|
||||
StatusChanged?.Invoke(this, value);
|
||||
}
|
||||
|
||||
private static string TicketFor(Job job)
|
||||
{
|
||||
string value = $"FL-{job.Id}-{job.Name}".Trim();
|
||||
return value.Length <= 64 ? value : value[..64];
|
||||
}
|
||||
|
||||
private static SyncCore.PointRecord ToSyncPoint(LoggedPoint point)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(DateTime.SpecifyKind(point.TimestampUtc, DateTimeKind.Utc));
|
||||
DateTimeOffset? positionEpoch = point.GpsUnixTimestamp > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(point.GpsUnixTimestamp)
|
||||
: null;
|
||||
return new SyncCore.PointRecord
|
||||
{
|
||||
PointId = point.SyncPointId!,
|
||||
Origin = SyncCore.PointOrigin.APP,
|
||||
UploadPath = SyncCore.UploadPath.APP_MQTT,
|
||||
CaptureTrigger = SyncCore.CaptureTrigger.LOCATOR_BUTTON,
|
||||
CreatedAt = timestamp,
|
||||
Position = point.GpsValid
|
||||
? new SyncCore.PositionGroup
|
||||
{
|
||||
Lat = point.Latitude,
|
||||
Lon = point.Longitude,
|
||||
EllipsoidalHeight = point.Altitude,
|
||||
OrthometricHeight = point.AltitudeCorrected,
|
||||
PositionEpoch = positionEpoch,
|
||||
}
|
||||
: null,
|
||||
Gnss = new SyncCore.GnssGroup
|
||||
{
|
||||
FixType = FixType(point.FixStatusEnum),
|
||||
SatsUsed = point.SatellitesUsed,
|
||||
Hdop = point.Hdop,
|
||||
Hrms = point.Hrms,
|
||||
Vrms = point.Vrms,
|
||||
CorrectionAge = point.CorrectionAgeSeconds,
|
||||
ReceiverSerial = point.GpsSerialNumber,
|
||||
TiltAngle = point.TiltAngle,
|
||||
Source = string.IsNullOrWhiteSpace(point.GpsSerialNumber) ? "PHONE" : "MAGLINK",
|
||||
},
|
||||
Locate = new SyncCore.LocateGroup
|
||||
{
|
||||
Depth = point.DepthMeters,
|
||||
DepthUnits = point.DepthMeters is null ? null : "m",
|
||||
SignalCurrent = point.CurrentMilliamps,
|
||||
SignalStrength = point.Signal,
|
||||
Frequency = point.Frequency,
|
||||
Gain = point.GainDb,
|
||||
LocateMode = LocateMode(point),
|
||||
PhaseDegrees = point.LdPhase,
|
||||
CompassDegrees = point.CompassAngle,
|
||||
LocatorModel = point.LocatorModel,
|
||||
LocatorSerial = point.LocatorSerialNumber,
|
||||
TelemetryEpoch = timestamp,
|
||||
},
|
||||
Attributes = new SyncCore.AttributesGroup { UtilityType = UtilityType(point.UtilityEnum) },
|
||||
Quality = new SyncCore.QualityGroup
|
||||
{
|
||||
QualityFlag = point.GpsValid && point.Hrms > 0 && point.Hrms <= 0.10
|
||||
? "IN_SPEC"
|
||||
: "OUT_OF_SPEC",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string FixType(GnssFixStatus status) => status switch
|
||||
{
|
||||
GnssFixStatus.Single => "AUTONOMOUS",
|
||||
GnssFixStatus.Dgps => "DGPS",
|
||||
GnssFixStatus.RtkFloat => "FLOAT",
|
||||
GnssFixStatus.RtkFixed => "FIXED",
|
||||
_ => "NO_FIX",
|
||||
};
|
||||
|
||||
private static string UtilityType(UmUtility utility) => utility switch
|
||||
{
|
||||
UmUtility.Gas => "GAS",
|
||||
UmUtility.Power => "ELECTRIC",
|
||||
UmUtility.Communications => "TELECOM",
|
||||
UmUtility.Water => "WATER",
|
||||
UmUtility.Sewer => "SEWER",
|
||||
UmUtility.Fiber => "FIBER",
|
||||
_ => "UNKNOWN",
|
||||
};
|
||||
|
||||
private static string LocateMode(LoggedPoint point) => point.FreqType == (int)UmFreqType.Sonde
|
||||
? "SONDE"
|
||||
: (UmMode)point.Mode switch
|
||||
{
|
||||
UmMode.Null => "NULL",
|
||||
UmMode.Omni or UmMode.TwinOmni => "BROAD_PEAK",
|
||||
_ => "PEAK",
|
||||
};
|
||||
}
|
||||
105
FieldLogger/ViewModels/AppStatusViewModel.cs
Normal file
105
FieldLogger/ViewModels/AppStatusViewModel.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared, glanceable hardware status shown above each primary workflow. The timer marks
|
||||
/// position data stale even when the receiver stops sending without disconnecting.
|
||||
/// </summary>
|
||||
public sealed partial class AppStatusViewModel : ObservableObject
|
||||
{
|
||||
private static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly MaglinkService _gps;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _locatorState;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _gnssState;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _locatorStatus = "Locator disconnected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _gnssStatus = "GNSS disconnected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _positionSummary = "No GNSS fix · accuracy —";
|
||||
|
||||
public AppStatusViewModel(DeviceConnectionManager manager, MaglinkService gps)
|
||||
{
|
||||
_manager = manager;
|
||||
_gps = gps;
|
||||
|
||||
_manager.PropertyChanged += OnConnectionPropertyChanged;
|
||||
_gps.FixReceived += OnFixReceived;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnConnectionPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(DeviceConnectionManager.LocatorState)
|
||||
or nameof(DeviceConnectionManager.GpsState)
|
||||
or nameof(DeviceConnectionManager.LocatorName)
|
||||
or nameof(DeviceConnectionManager.GpsName))
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFixReceived(object? sender, GnssFix fix) => Refresh();
|
||||
|
||||
/// <summary>Refreshes status safely from device callbacks or a UI dispatcher timer.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher?.IsDispatchRequired == true)
|
||||
{
|
||||
dispatcher.Dispatch(RefreshCore);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshCore();
|
||||
}
|
||||
|
||||
private void RefreshCore()
|
||||
{
|
||||
LocatorState = _manager.LocatorState;
|
||||
GnssState = _manager.GpsState;
|
||||
LocatorStatus = DeviceLabel("Locator", _manager.LocatorName, LocatorState);
|
||||
GnssStatus = DeviceLabel("GNSS", _manager.GpsName, GnssState);
|
||||
|
||||
var fix = _gps.LatestFix;
|
||||
if (fix is null)
|
||||
{
|
||||
PositionSummary = GnssState == ConnectionState.Connected
|
||||
? "Waiting for fix · accuracy —"
|
||||
: "No GNSS fix · accuracy —";
|
||||
return;
|
||||
}
|
||||
|
||||
var stale = DateTime.UtcNow - fix.ReceivedUtc > StaleAfter;
|
||||
var staleLabel = stale ? " · STALE" : string.Empty;
|
||||
PositionSummary = $"{fix.StatusLabel}{staleLabel} · H ±{fix.Hrms:F3} m · " +
|
||||
$"V ±{fix.Vrms:F3} m · {fix.SatellitesUsed} sats · corr {fix.CorrectionAgeSeconds:F1} s";
|
||||
}
|
||||
|
||||
private static string DeviceLabel(string kind, string? name, ConnectionState state)
|
||||
{
|
||||
var stateText = state switch
|
||||
{
|
||||
ConnectionState.Connected => "connected",
|
||||
ConnectionState.Connecting => "connecting",
|
||||
_ => "disconnected",
|
||||
};
|
||||
|
||||
return state == ConnectionState.Disconnected || string.IsNullOrWhiteSpace(name)
|
||||
? $"{kind} {stateText}"
|
||||
: $"{kind} {stateText} · {name}";
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services;
|
||||
using FieldLogger.Services.Data;
|
||||
using FieldLogger.Services.Sync;
|
||||
|
||||
namespace FieldLogger.ViewModels;
|
||||
|
||||
@@ -14,6 +15,7 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
private readonly PointLogger _pointLogger;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IMqttSyncService _sync;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
@@ -35,32 +37,49 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _fixAccuracy = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _syncStatus = "";
|
||||
|
||||
public bool HasNoActiveJob => !HasActiveJob;
|
||||
|
||||
public bool IsDeviceConsoleAvailable { get; } =
|
||||
#if DEBUG
|
||||
true;
|
||||
#else
|
||||
false;
|
||||
#endif
|
||||
|
||||
public string ActiveJobPointSummary => PointCount == 1
|
||||
? "1 point saved locally"
|
||||
: $"{PointCount} points saved locally";
|
||||
|
||||
public string CaptureReadinessText => HasActiveJob
|
||||
? "READY · Press LOG on the locating receiver"
|
||||
: "BLOCKED · Choose or create an active job first";
|
||||
|
||||
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
|
||||
|
||||
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
|
||||
PointLogger pointLogger, AppDatabase database, SettingsService settings)
|
||||
PointLogger pointLogger, AppDatabase database, SettingsService settings, IMqttSyncService sync)
|
||||
{
|
||||
_manager = manager;
|
||||
_gps = gps;
|
||||
_pointLogger = pointLogger;
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
_sync = sync;
|
||||
SyncStatus = sync.Status;
|
||||
|
||||
_gps.FixReceived += OnFixReceived;
|
||||
_pointLogger.PointSaved += OnPointSaved;
|
||||
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
|
||||
_sync.StatusChanged += OnSyncStatusChanged;
|
||||
_sync.PointRejected += OnPointRejected;
|
||||
}
|
||||
|
||||
/// <summary>Called from the page's OnAppearing.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// First launch: nothing saved yet, send the user to device selection.
|
||||
if (!_manager.HasSavedDevice(DeviceKind.Locator) && !_manager.HasSavedDevice(DeviceKind.RtkGps))
|
||||
{
|
||||
await Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.Start();
|
||||
await RefreshJobAsync();
|
||||
}
|
||||
@@ -120,6 +139,9 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private Task ViewMapAsync() => Shell.Current.GoToAsync("//map");
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenDeviceConsoleAsync() => Shell.Current.GoToAsync("deviceconsole");
|
||||
|
||||
private void OnFixReceived(object? sender, GnssFix fix)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
@@ -140,10 +162,29 @@ public sealed partial class HomeViewModel : ObservableObject
|
||||
PointCount++;
|
||||
}
|
||||
|
||||
partial void OnHasActiveJobChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasNoActiveJob));
|
||||
OnPropertyChanged(nameof(CaptureReadinessText));
|
||||
}
|
||||
|
||||
partial void OnPointCountChanged(int value) =>
|
||||
OnPropertyChanged(nameof(ActiveJobPointSummary));
|
||||
|
||||
private async void OnPacketIgnoredNoJob(object? sender, EventArgs e)
|
||||
{
|
||||
await Shell.Current.DisplayAlert("No Active Job",
|
||||
"A point was logged on the receiver, but no job is active so it was not saved. Create or select a job first.",
|
||||
"OK");
|
||||
}
|
||||
|
||||
private void OnSyncStatusChanged(object? sender, string status) =>
|
||||
MainThread.BeginInvokeOnMainThread(() => SyncStatus = status);
|
||||
|
||||
private void OnPointRejected(object? sender, PointSyncFailureEventArgs e) =>
|
||||
MainThread.BeginInvokeOnMainThread(async () =>
|
||||
await Shell.Current.DisplayAlert(
|
||||
"Point Saved Locally — Sync Needs Attention",
|
||||
$"Point #{e.Point.Id} remains on this device and was not uploaded. Reason: {e.ReasonCode}.",
|
||||
"OK"));
|
||||
}
|
||||
|
||||
@@ -12,53 +12,140 @@ public sealed partial class JobListItem : ObservableObject
|
||||
public required Job Job { get; init; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(JobSummary))]
|
||||
[NotifyPropertyChangedFor(nameof(DeleteDescription))]
|
||||
[NotifyPropertyChangedFor(nameof(AccessibilityDescription))]
|
||||
private int _pointCount;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ActiveStatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ActiveActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ActiveActionDescription))]
|
||||
[NotifyPropertyChangedFor(nameof(AccessibilityDescription))]
|
||||
private bool _isActive;
|
||||
|
||||
public string Name => Job.Name;
|
||||
public string CreatedLabel => Job.CreatedUtc.ToLocalTime().ToString("g");
|
||||
public string ActiveStatusLabel => IsActive ? "ACTIVE JOB" : "NOT ACTIVE";
|
||||
public string ActiveActionLabel => IsActive ? "Active" : "Set active";
|
||||
public string JobSummary => $"Created {CreatedLabel} · {PointCount} {(PointCount == 1 ? "point" : "points")}";
|
||||
public string ActiveActionDescription => IsActive
|
||||
? $"{Name} is already the active job"
|
||||
: $"Make {Name} the active capture job";
|
||||
public string DeleteDescription => $"Delete {Name} and its {PointCount} saved points";
|
||||
public string AccessibilityDescription => $"{Name}. {ActiveStatusLabel}. {JobSummary}.";
|
||||
}
|
||||
|
||||
public sealed partial class JobsViewModel : ObservableObject
|
||||
{
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
|
||||
public ObservableCollection<JobListItem> Jobs { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isRefreshing;
|
||||
|
||||
public JobsViewModel(AppDatabase database, SettingsService settings)
|
||||
{
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
public Task LoadAsync()
|
||||
=> ReloadAsync(showRefreshIndicator: false, skipIfBusy: true);
|
||||
|
||||
[RelayCommand]
|
||||
private Task RefreshAsync()
|
||||
=> ReloadAsync(showRefreshIndicator: true, skipIfBusy: false);
|
||||
|
||||
private async Task ReloadAsync(bool showRefreshIndicator, bool skipIfBusy)
|
||||
{
|
||||
IsBusy = true;
|
||||
var entered = skipIfBusy
|
||||
? await _refreshGate.WaitAsync(0)
|
||||
: await WaitForRefreshGateAsync();
|
||||
if (!entered)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
if (showRefreshIndicator)
|
||||
IsRefreshing = true;
|
||||
|
||||
var jobs = await _database.GetJobsAsync();
|
||||
var activeId = _settings.ActiveJobId;
|
||||
|
||||
Jobs.Clear();
|
||||
// Build a snapshot off-screen, then reconcile it with the existing observable
|
||||
// collection. Keeping the existing rows prevents CollectionView from repeatedly
|
||||
// discarding its measured cells and jumping back to the top.
|
||||
var items = new List<JobListItem>(jobs.Count);
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
Jobs.Add(new JobListItem
|
||||
items.Add(new JobListItem
|
||||
{
|
||||
Job = job,
|
||||
PointCount = await _database.GetPointCountAsync(job.Id),
|
||||
IsActive = job.Id == activeId,
|
||||
});
|
||||
}
|
||||
|
||||
ApplySnapshot(items);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (showRefreshIndicator)
|
||||
IsRefreshing = false;
|
||||
IsBusy = false;
|
||||
_refreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> WaitForRefreshGateAsync()
|
||||
{
|
||||
await _refreshGate.WaitAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ApplySnapshot(IReadOnlyList<JobListItem> incoming)
|
||||
{
|
||||
var incomingIds = incoming.Select(item => item.Job.Id).ToHashSet();
|
||||
|
||||
for (var index = Jobs.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (!incomingIds.Contains(Jobs[index].Job.Id))
|
||||
Jobs.RemoveAt(index);
|
||||
}
|
||||
|
||||
for (var targetIndex = 0; targetIndex < incoming.Count; targetIndex++)
|
||||
{
|
||||
var replacement = incoming[targetIndex];
|
||||
var existingIndex = -1;
|
||||
for (var index = targetIndex; index < Jobs.Count; index++)
|
||||
{
|
||||
if (Jobs[index].Job.Id == replacement.Job.Id)
|
||||
{
|
||||
existingIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingIndex < 0)
|
||||
{
|
||||
Jobs.Insert(targetIndex, replacement);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = Jobs[existingIndex];
|
||||
existing.PointCount = replacement.PointCount;
|
||||
existing.IsActive = replacement.IsActive;
|
||||
|
||||
if (existingIndex != targetIndex)
|
||||
Jobs.Move(existingIndex, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +172,7 @@ public sealed partial class JobsViewModel : ObservableObject
|
||||
_settings.ActiveJobId = job.Id;
|
||||
Console.WriteLine($"NewJobAsync: Set active job to {job.Id}");
|
||||
|
||||
await RefreshAsync();
|
||||
await ReloadAsync(showRefreshIndicator: false, skipIfBusy: false);
|
||||
Console.WriteLine("NewJobAsync: Refresh complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -119,6 +206,6 @@ public sealed partial class JobsViewModel : ObservableObject
|
||||
await _database.DeleteJobAsync(item.Job.Id);
|
||||
if (_settings.ActiveJobId == item.Job.Id)
|
||||
_settings.ActiveJobId = null;
|
||||
await RefreshAsync();
|
||||
await ReloadAsync(showRefreshIndicator: false, skipIfBusy: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ public sealed partial class MapViewModel : ObservableObject
|
||||
private int _jobId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _jobName = "";
|
||||
private string _jobName = "Active job map";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusText = "";
|
||||
private string _statusText = "Loading locally saved points…";
|
||||
|
||||
public SettingsService Settings => _settings;
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed partial class MapViewModel : ObservableObject
|
||||
var jobId = JobId > 0 ? JobId : _settings.ActiveJobId;
|
||||
if (jobId is null)
|
||||
{
|
||||
JobName = "";
|
||||
JobName = "No active job";
|
||||
StatusText = "No job selected. Create or activate a job to see its points.";
|
||||
return new List<LoggedPoint>();
|
||||
}
|
||||
|
||||
@@ -8,30 +8,25 @@ namespace FieldLogger.ViewModels;
|
||||
public sealed partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly DeviceConnectionManager _manager;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public DeviceConnectionManager Manager => _manager;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _mapsApiKey = "";
|
||||
|
||||
public string AppVersion => AppInfo.Current.VersionString;
|
||||
|
||||
public SettingsViewModel(DeviceConnectionManager manager, SettingsService settings)
|
||||
public SettingsViewModel(DeviceConnectionManager manager)
|
||||
{
|
||||
_manager = manager;
|
||||
_settings = settings;
|
||||
MapsApiKey = settings.GoogleMapsApiKey;
|
||||
}
|
||||
|
||||
partial void OnMapsApiKeyChanged(string value) => _settings.GoogleMapsApiKey = value.Trim();
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task ChangeGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}");
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenDebugAsync() => Shell.Current.GoToAsync("debug");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ForgetLocatorAsync()
|
||||
{
|
||||
|
||||
35
FieldLogger/Views/Controls/AppStatusBar.xaml
Normal file
35
FieldLogger/Views/Controls/AppStatusBar.xaml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
x:Class="FieldLogger.Views.Controls.AppStatusBar"
|
||||
x:DataType="vm:AppStatusViewModel">
|
||||
<Border BackgroundColor="{DynamicResource UlColorSurfaceStrong}"
|
||||
StrokeThickness="0" Padding="12,8">
|
||||
<Grid RowDefinitions="Auto,Auto" RowSpacing="4">
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<HorizontalStackLayout Spacing="6">
|
||||
<Label Text="●" FontSize="13"
|
||||
TextColor="{Binding LocatorState, Converter={StaticResource StateColor}}"
|
||||
VerticalTextAlignment="Center" />
|
||||
<Label Text="{Binding LocatorStatus}" FontSize="12"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</HorizontalStackLayout>
|
||||
<HorizontalStackLayout Grid.Column="1" Spacing="6">
|
||||
<Label Text="●" FontSize="13"
|
||||
TextColor="{Binding GnssState, Converter={StaticResource StateColor}}"
|
||||
VerticalTextAlignment="Center" />
|
||||
<Label Text="{Binding GnssStatus}" FontSize="12"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</HorizontalStackLayout>
|
||||
</Grid>
|
||||
<Label Grid.Row="1" Text="{Binding PositionSummary}" FontSize="12"
|
||||
FontFamily="{StaticResource UlTypeFamilyMono}"
|
||||
TextColor="{DynamicResource UlColorOnStrong}"
|
||||
LineBreakMode="TailTruncation"
|
||||
SemanticProperties.Description="Current GNSS fix type, estimated accuracy, satellites, and correction age" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ContentView>
|
||||
40
FieldLogger/Views/Controls/AppStatusBar.xaml.cs
Normal file
40
FieldLogger/Views/Controls/AppStatusBar.xaml.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using FieldLogger.ViewModels;
|
||||
|
||||
namespace FieldLogger.Views.Controls;
|
||||
|
||||
public partial class AppStatusBar : ContentView
|
||||
{
|
||||
private IDispatcherTimer? _staleTimer;
|
||||
|
||||
public AppStatusBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnHandlerChanged()
|
||||
{
|
||||
base.OnHandlerChanged();
|
||||
|
||||
if (Handler is null)
|
||||
{
|
||||
_staleTimer?.Stop();
|
||||
_staleTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_staleTimer is not null)
|
||||
return;
|
||||
|
||||
_staleTimer = Dispatcher.CreateTimer();
|
||||
_staleTimer.Interval = TimeSpan.FromSeconds(1);
|
||||
_staleTimer.Tick += (_, _) => (BindingContext as AppStatusViewModel)?.Refresh();
|
||||
_staleTimer.Start();
|
||||
(BindingContext as AppStatusViewModel)?.Refresh();
|
||||
}
|
||||
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
(BindingContext as AppStatusViewModel)?.Refresh();
|
||||
}
|
||||
}
|
||||
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml
Normal file
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="FieldLogger.Views.Controls.FloatingMenuButton">
|
||||
<Button Text="☰" FontSize="24" FontAttributes="Bold"
|
||||
WidthRequest="56" HeightRequest="56" CornerRadius="28"
|
||||
Padding="0" Margin="16"
|
||||
BackgroundColor="{DynamicResource UlColorPrimary}"
|
||||
TextColor="{DynamicResource UlColorOnPrimary}"
|
||||
BorderColor="{DynamicResource UlColorSurfaceRaised}" BorderWidth="2"
|
||||
Clicked="OnMenuClicked"
|
||||
SemanticProperties.Description="Open UM Trace menu"
|
||||
MinimumHeightRequest="{StaticResource UlTouchComfortable}"
|
||||
MinimumWidthRequest="{StaticResource UlTouchComfortable}" />
|
||||
</ContentView>
|
||||
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml.cs
Normal file
15
FieldLogger/Views/Controls/FloatingMenuButton.xaml.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace FieldLogger.Views.Controls;
|
||||
|
||||
public partial class FloatingMenuButton : ContentView
|
||||
{
|
||||
public FloatingMenuButton()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnMenuClicked(object? sender, EventArgs e)
|
||||
{
|
||||
if (Shell.Current is not null)
|
||||
Shell.Current.FlyoutIsPresented = true;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.DebugPage"
|
||||
x:DataType="vm:DebugViewModel"
|
||||
Title="Debug">
|
||||
<ScrollView>
|
||||
Title="Device Console">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16" Spacing="12">
|
||||
|
||||
<Label Text="Connection Status" FontAttributes="Bold" FontSize="16" />
|
||||
@@ -88,4 +91,5 @@
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,11 +6,12 @@ public partial class DebugPage : ContentPage
|
||||
{
|
||||
private readonly DebugViewModel _viewModel;
|
||||
|
||||
public DebugPage(DebugViewModel viewModel)
|
||||
public DebugPage(DebugViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
BindingContext = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override void OnAppearing()
|
||||
|
||||
@@ -3,81 +3,168 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.HomePage"
|
||||
x:DataType="vm:HomeViewModel"
|
||||
Title="Field Logger">
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="16" Spacing="12">
|
||||
|
||||
<!-- Device connection status -->
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<Grid ColumnDefinitions="16,*,Auto" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="10">
|
||||
<Ellipse Grid.Row="0" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
|
||||
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
|
||||
<VerticalStackLayout Grid.Row="0" Grid.Column="1" Spacing="0">
|
||||
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Locating Receiver — not selected'}" FontAttributes="Bold" />
|
||||
<Label Text="{Binding Manager.LocatorState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Row="0" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
|
||||
Command="{Binding SelectLocatorCommand}" />
|
||||
|
||||
<Ellipse Grid.Row="1" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
|
||||
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
|
||||
<VerticalStackLayout Grid.Row="1" Grid.Column="1" Spacing="0">
|
||||
<Label Text="{Binding Manager.GpsName, TargetNullValue='Maglink RTK GPS — not selected'}" FontAttributes="Bold" />
|
||||
<Label Text="{Binding Manager.GpsState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Row="1" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
|
||||
Command="{Binding SelectGpsCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Live GNSS fix -->
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
Title="Debug"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16" Spacing="16">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<HorizontalStackLayout Spacing="8">
|
||||
<Label Text="GPS" FontAttributes="Bold" />
|
||||
<Label Text="{Binding FixStatusLabel}" TextColor="DodgerBlue" FontAttributes="Bold" />
|
||||
</HorizontalStackLayout>
|
||||
<Label Text="{Binding FixSummary}" FontFamily="Courier New" FontSize="13" />
|
||||
<Label Text="{Binding FixAccuracy}" FontSize="12" TextColor="Gray" />
|
||||
<Label Text="UM TRACE DEBUG" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<Label Text="Capture diagnostics" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<!-- Active job -->
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<VerticalStackLayout Spacing="0">
|
||||
<Label Text="{Binding ActiveJobName}" FontAttributes="Bold" FontSize="16" />
|
||||
<Label FontSize="12" TextColor="Gray">
|
||||
<Label.Text>
|
||||
<Binding Path="PointCount" StringFormat="{}{0} points logged" />
|
||||
</Label.Text>
|
||||
</Label>
|
||||
</VerticalStackLayout>
|
||||
<HorizontalStackLayout Grid.Column="1" Spacing="6">
|
||||
<Button Text="New Job" FontSize="12" Padding="10,4" Command="{Binding NewJobCommand}" />
|
||||
<Button Text="Jobs" FontSize="12" Padding="10,4" Command="{Binding SelectJobCommand}" />
|
||||
</HorizontalStackLayout>
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 12" Padding="16"
|
||||
SemanticProperties.Description="Active job and local capture count">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="8" ColumnSpacing="12">
|
||||
<Label Text="ACTIVE JOB" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Label Grid.Row="1" Text="{Binding ActiveJobName}" FontSize="22" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorText}" LineBreakMode="TailTruncation" />
|
||||
<Label Grid.Row="2" Text="{Binding ActiveJobPointSummary}" FontSize="16"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Button Grid.RowSpan="3" Grid.Column="1" Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Jobs" Command="{Binding SelectJobCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" MinimumWidthRequest="72" FontAttributes="Bold"
|
||||
SemanticProperties.Description="Choose or review the active job" />
|
||||
</Grid>
|
||||
<Label Text="Press the log button on the receiver to capture a point."
|
||||
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
|
||||
</Border>
|
||||
|
||||
<!-- Production capture remains receiver-triggered. This is deliberately guidance,
|
||||
not an app button that would invent an unsupported device command. -->
|
||||
<Border BackgroundColor="{DynamicResource UlColorPrimary}" StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 20" Padding="20,16" MinimumHeightRequest="{StaticResource UlTouchPrimary}"
|
||||
SemanticProperties.Description="Hardware capture action. Press the log button on the locating receiver.">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Label Text="◎" FontSize="30" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorOnPrimary}" VerticalTextAlignment="Center" />
|
||||
<VerticalStackLayout Grid.Column="1" Spacing="2" VerticalOptions="Center">
|
||||
<Label Text="CAPTURE FROM RECEIVER" FontSize="18" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorOnPrimary}" />
|
||||
<Label Text="{Binding CaptureReadinessText}" FontSize="14"
|
||||
TextColor="{DynamicResource UlColorOnPrimary}" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Button Style="{StaticResource UlButtonPrimaryField}"
|
||||
Text="Choose an active job" IsVisible="{Binding HasNoActiveJob}"
|
||||
Command="{Binding SelectJobCommand}" MinimumHeightRequest="{StaticResource UlTouchComfortable}" FontAttributes="Bold"
|
||||
SemanticProperties.Description="Capture is blocked. Choose or create an active job." />
|
||||
|
||||
<Label Text="DEVICE STATUS" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}"
|
||||
SemanticProperties.HeadingLevel="Level2" />
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12"
|
||||
Padding="12" MinimumHeightRequest="96">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="LOCATOR" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Not selected'}"
|
||||
FontSize="16" FontAttributes="Bold" TextColor="{DynamicResource UlColorText}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding Manager.LocatorState, Converter={StaticResource StateText}}"
|
||||
FontSize="14" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Button Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Select locator" Command="{Binding SelectLocatorCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" Margin="0,4,0,0"
|
||||
SemanticProperties.Description="Select the locating receiver" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12"
|
||||
Padding="12" MinimumHeightRequest="96">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="RTK GPS" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Label Text="{Binding Manager.GpsName, TargetNullValue='Not selected'}"
|
||||
FontSize="16" FontAttributes="Bold" TextColor="{DynamicResource UlColorText}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding Manager.GpsState, Converter={StaticResource StateText}}"
|
||||
FontSize="14" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Button Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Select GPS" Command="{Binding SelectGpsCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" Margin="0,4,0,0"
|
||||
SemanticProperties.Description="Select the Maglink RTK GPS receiver" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="16">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<Label Text="POSITION FIX" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Label Grid.Column="1" Text="{Binding FixStatusLabel}" FontSize="16" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.Description="Current GPS fix quality" />
|
||||
</Grid>
|
||||
<Label Text="{Binding FixSummary}" FontSize="18" FontFamily="{StaticResource UlTypeFamilyMono}"
|
||||
TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="{Binding FixAccuracy}" FontSize="14"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<!-- Recent points -->
|
||||
<Label Text="Recent Points" FontAttributes="Bold" Margin="4,8,0,0" />
|
||||
<CollectionView ItemsSource="{Binding RecentPoints}" HeightRequest="320">
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="16">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Label Text="↕" FontSize="22" TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<VerticalStackLayout Grid.Column="1" Spacing="2">
|
||||
<Label Text="SYNC STATUS" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Label Text="{Binding SyncStatus}" FontSize="16" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.Description="Current synchronization status" />
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label Text="RECENT CAPTURES" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorTextMuted}"
|
||||
SemanticProperties.HeadingLevel="Level2" />
|
||||
<Button Grid.Column="1" Style="{StaticResource UlButtonSecondary}"
|
||||
Text="View map" Command="{Binding ViewMapCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" MinimumWidthRequest="96"
|
||||
SemanticProperties.Description="View captured points on the map" />
|
||||
</Grid>
|
||||
|
||||
<CollectionView ItemsSource="{Binding RecentPoints}" HeightRequest="360"
|
||||
SemanticProperties.Description="Ten most recent locally saved capture points">
|
||||
<CollectionView.EmptyView>
|
||||
<Label Text="No points logged yet." TextColor="Gray" Margin="8" />
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeShape="RoundRectangle 12" Padding="20">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="No captures yet" Style="{StaticResource UlEmptyStateTitle}"
|
||||
HorizontalTextAlignment="Center" TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="With an active job, press LOG on the receiver to save the first point."
|
||||
Style="{StaticResource UlEmptyStateBody}"
|
||||
FontSize="14" HorizontalTextAlignment="Center"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</CollectionView.EmptyView>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:LoggedPoint">
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#FAFAFA, Dark=#242426}"
|
||||
StrokeShape="RoundRectangle 8" Padding="10" Margin="0,2">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||
<Label Grid.Row="0" FontSize="13" FontAttributes="Bold">
|
||||
<Border Style="{StaticResource UlJobRow}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeShape="RoundRectangle 12" Padding="12" Margin="0,0,0,8"
|
||||
MinimumHeightRequest="76">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" RowSpacing="6" ColumnSpacing="12">
|
||||
<Label FontSize="16" FontAttributes="Bold" TextColor="{DynamicResource UlColorText}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}{0} · {1} Hz · depth {2}">
|
||||
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
|
||||
@@ -86,9 +173,10 @@
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
|
||||
<Label Grid.Column="1" FontSize="14" TextColor="{DynamicResource UlColorTextMuted}"
|
||||
Text="{Binding TimestampUtc, StringFormat='{0:HH:mm:ss}'}" />
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12" TextColor="Gray">
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="14" FontFamily="{StaticResource UlTypeFamilyMono}"
|
||||
TextColor="{DynamicResource UlColorTextMuted}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}{0:F7}, {1:F7} · {2}">
|
||||
<Binding Path="Latitude" />
|
||||
@@ -103,6 +191,12 @@
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
<Button Style="{StaticResource UlButtonSecondary}"
|
||||
Text="Open device console" Command="{Binding OpenDeviceConsoleCommand}"
|
||||
IsVisible="{Binding IsDeviceConsoleAvailable}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Open raw locator and GNSS device messages" />
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -7,10 +7,11 @@ public partial class HomePage : ContentPage
|
||||
private readonly HomeViewModel _viewModel;
|
||||
private bool _initialized;
|
||||
|
||||
public HomePage(HomeViewModel viewModel)
|
||||
public HomePage(HomeViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
|
||||
@@ -3,39 +3,69 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:models="clr-namespace:FieldLogger.Models"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.JobDetailPage"
|
||||
x:DataType="vm:JobDetailViewModel"
|
||||
Title="{Binding JobName}">
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="Map" Command="{Binding ViewMapCommand}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
Title="{Binding JobName}"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" Padding="16" RowSpacing="16">
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="16">
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="6" ColumnSpacing="12">
|
||||
<VerticalStackLayout>
|
||||
<Label Text="JOB DETAIL" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<Label Text="{Binding JobName}" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}" LineBreakMode="TailTruncation"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Style="{StaticResource UlButtonSecondary}"
|
||||
Text="View map" Command="{Binding ViewMapCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}" MinimumWidthRequest="96"
|
||||
SemanticProperties.Description="View this job's captured points on the map" />
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="16"
|
||||
TextColor="{DynamicResource UlColorTextMuted}"
|
||||
Text="{Binding PointCount, StringFormat='{0} locally logged points'}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Padding="16" RowSpacing="8">
|
||||
<Label Grid.Row="0" FontSize="13" TextColor="Gray"
|
||||
Text="{Binding PointCount, StringFormat='{0} logged points'}" />
|
||||
|
||||
<CollectionView Grid.Row="1" ItemsSource="{Binding Points}">
|
||||
<CollectionView Grid.Row="1" ItemsSource="{Binding Points}"
|
||||
SemanticProperties.Description="Captured points for this job">
|
||||
<CollectionView.EmptyView>
|
||||
<Label Text="No points logged for this job yet." TextColor="Gray" Margin="8" />
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeShape="RoundRectangle 12" Padding="24">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="No captures for this job" Style="{StaticResource UlEmptyStateTitle}"
|
||||
HorizontalTextAlignment="Center" TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="Open Debug from Settings and press LOG on the receiver when the job is active."
|
||||
Style="{StaticResource UlEmptyStateBody}"
|
||||
FontSize="16" HorizontalTextAlignment="Center"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</CollectionView.EmptyView>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:LoggedPoint">
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
|
||||
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="2">
|
||||
<Label Grid.Row="0" FontAttributes="Bold" FontSize="14">
|
||||
<Border Style="{StaticResource UlJobRow}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12"
|
||||
Padding="16" Margin="0,0,0,12" MinimumHeightRequest="168">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="8" ColumnSpacing="12">
|
||||
<Label FontSize="18" FontAttributes="Bold" TextColor="{DynamicResource UlColorText}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}#{0} · {1} · {2} Hz">
|
||||
<MultiBinding StringFormat="{}Point #{0} · {1} · {2} Hz">
|
||||
<Binding Path="Id" />
|
||||
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
|
||||
<Binding Path="Frequency" />
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
|
||||
<Label Grid.Column="1" FontSize="14" TextColor="{DynamicResource UlColorTextMuted}"
|
||||
Text="{Binding TimestampUtc, StringFormat='{0:g}'}" />
|
||||
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12">
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="16"
|
||||
TextColor="{DynamicResource UlColorText}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="Depth {0} · Current {1} · Signal {2} · Gain {3} dB">
|
||||
<Binding Path="DepthRaw" />
|
||||
@@ -45,8 +75,8 @@
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
|
||||
<Label Grid.Row="2" Grid.ColumnSpan="2" FontSize="12" FontFamily="Courier New">
|
||||
<Label Grid.Row="2" Grid.ColumnSpan="2" FontSize="16" FontFamily="{StaticResource UlTypeFamilyMono}"
|
||||
TextColor="{DynamicResource UlColorText}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}{0:F8}, {1:F8}">
|
||||
<Binding Path="Latitude" />
|
||||
@@ -54,24 +84,27 @@
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
|
||||
<Label Grid.Row="3" FontSize="11" TextColor="Gray">
|
||||
<Label Grid.Row="3" FontSize="14" TextColor="{DynamicResource UlColorTextMuted}">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}{0} · H ±{1:F3} m · {2} sats">
|
||||
<MultiBinding StringFormat="{}{0} · H ±{1:F3} m · {2} satellites">
|
||||
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
|
||||
<Binding Path="Hrms" />
|
||||
<Binding Path="SatellitesUsed" />
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
<Button Grid.Row="3" Grid.Column="1" Text="Delete" FontSize="11" Padding="8,2"
|
||||
BackgroundColor="Transparent" TextColor="IndianRed"
|
||||
<Button Grid.Row="3" Grid.Column="1" Style="{StaticResource UlButtonAccent}"
|
||||
Text="Delete point" MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
BackgroundColor="{DynamicResource UlColorAccent}"
|
||||
TextColor="{DynamicResource UlColorOnAccent}"
|
||||
Command="{Binding DeletePointCommand, Source={RelativeSource AncestorType={x:Type vm:JobDetailViewModel}}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
CommandParameter="{Binding .}"
|
||||
SemanticProperties.Description="Delete this locally saved point" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,10 +6,11 @@ public partial class JobDetailPage : ContentPage
|
||||
{
|
||||
private readonly JobDetailViewModel _viewModel;
|
||||
|
||||
public JobDetailPage(JobDetailViewModel viewModel)
|
||||
public JobDetailPage(JobDetailViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
|
||||
@@ -2,59 +2,114 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.JobsPage"
|
||||
x:DataType="vm:JobsViewModel"
|
||||
Title="Jobs">
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem Text="New" Command="{Binding NewJobCommand}" />
|
||||
</ContentPage.ToolbarItems>
|
||||
Title="Jobs"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
|
||||
<Grid Padding="16">
|
||||
<CollectionView ItemsSource="{Binding Jobs}">
|
||||
<CollectionView.EmptyView>
|
||||
<VerticalStackLayout Spacing="8" VerticalOptions="Center">
|
||||
<Label Text="No jobs yet." TextColor="Gray" HorizontalOptions="Center" />
|
||||
<Button Text="Create First Job" Command="{Binding NewJobCommand}" HorizontalOptions="Center" />
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" Padding="16" RowSpacing="16"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="FIELD WORK" FontSize="14" FontAttributes="Bold" CharacterSpacing="1.5"
|
||||
TextColor="{DynamicResource UlColorPrimary}" />
|
||||
<Label Text="Jobs" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
<Label Text="Choose the active job before capturing points."
|
||||
FontSize="16" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Button Grid.Row="1" Style="{StaticResource UlButtonPrimaryField}"
|
||||
Text="+ Create new job" Command="{Binding NewJobCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchComfortable}" FontSize="16" FontAttributes="Bold"
|
||||
SemanticProperties.Description="Create a new job and make it active" />
|
||||
|
||||
<RefreshView Grid.Row="2"
|
||||
Command="{Binding RefreshCommand}"
|
||||
IsRefreshing="{Binding IsRefreshing, Mode=OneWay}"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill">
|
||||
<CollectionView ItemsSource="{Binding Jobs}"
|
||||
SelectionMode="None"
|
||||
ItemSizingStrategy="MeasureFirstItem"
|
||||
ItemsUpdatingScrollMode="KeepItemsInView"
|
||||
HorizontalOptions="Fill" VerticalOptions="Fill"
|
||||
SemanticProperties.Description="Jobs available on this device">
|
||||
<CollectionView.EmptyView>
|
||||
<Border Style="{StaticResource UlCard}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeShape="RoundRectangle 12" Padding="24">
|
||||
<VerticalStackLayout Spacing="8" VerticalOptions="Center">
|
||||
<Label Text="No jobs yet" Style="{StaticResource UlEmptyStateTitle}"
|
||||
HorizontalTextAlignment="Center" TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="Create a job to establish the capture context."
|
||||
Style="{StaticResource UlEmptyStateBody}"
|
||||
FontSize="16" HorizontalTextAlignment="Center"
|
||||
TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Button Style="{StaticResource UlButtonPrimary}"
|
||||
Text="Create first job" Command="{Binding NewJobCommand}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchComfortable}" HorizontalOptions="Fill"
|
||||
SemanticProperties.Description="Create the first job" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</CollectionView.EmptyView>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:JobListItem">
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
|
||||
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
|
||||
<Border Style="{StaticResource UlJobRow}" BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeThickness="1" StrokeShape="RoundRectangle 12"
|
||||
Padding="16" Margin="0,0,0,12" MinimumHeightRequest="136"
|
||||
SemanticProperties.Description="{Binding AccessibilityDescription}"
|
||||
SemanticProperties.Hint="Double tap to open job details">
|
||||
<Border.GestureRecognizers>
|
||||
<TapGestureRecognizer
|
||||
Command="{Binding OpenCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</Border.GestureRecognizers>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<VerticalStackLayout Spacing="2">
|
||||
<HorizontalStackLayout Spacing="8">
|
||||
<Label Text="{Binding Name}" FontAttributes="Bold" />
|
||||
<Border IsVisible="{Binding IsActive}" Background="DodgerBlue"
|
||||
StrokeThickness="0" StrokeShape="RoundRectangle 6" Padding="6,1">
|
||||
<Label Text="ACTIVE" FontSize="10" TextColor="White" />
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="8" ColumnSpacing="12">
|
||||
<Label Text="{Binding Name}" FontSize="20" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorText}" LineBreakMode="TailTruncation" />
|
||||
<Border Grid.Column="1" Style="{StaticResource UlStatusBadge}"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}"
|
||||
Stroke="{DynamicResource UlColorBorder}"
|
||||
StrokeShape="RoundRectangle 8" Padding="8,4">
|
||||
<Label Text="{Binding ActiveStatusLabel}" FontSize="14" FontAttributes="Bold"
|
||||
TextColor="{DynamicResource UlColorText}" />
|
||||
</Border>
|
||||
</HorizontalStackLayout>
|
||||
<Label FontSize="12" TextColor="Gray">
|
||||
<Label.Text>
|
||||
<MultiBinding StringFormat="{}{0} · {1} points">
|
||||
<Binding Path="CreatedLabel" />
|
||||
<Binding Path="PointCount" />
|
||||
</MultiBinding>
|
||||
</Label.Text>
|
||||
</Label>
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Text="Set Active" FontSize="12" Padding="10,4" VerticalOptions="Center"
|
||||
<Label Grid.Row="1" Grid.ColumnSpan="2" Text="{Binding JobSummary}"
|
||||
FontSize="16" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
<Grid Grid.Row="2" Grid.ColumnSpan="2" ColumnDefinitions="*,*" ColumnSpacing="8">
|
||||
<Button Style="{StaticResource UlButtonSecondary}"
|
||||
Text="{Binding ActiveActionLabel}"
|
||||
IsEnabled="{Binding IsActive, Converter={StaticResource InvertBool}}"
|
||||
Command="{Binding SetActiveCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
<Button Grid.Column="2" Text="✕" FontSize="12" Padding="10,4" VerticalOptions="Center"
|
||||
BackgroundColor="IndianRed" TextColor="White"
|
||||
CommandParameter="{Binding .}" MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="{Binding ActiveActionDescription}" />
|
||||
<Button Grid.Column="1" Style="{StaticResource UlButtonAccent}"
|
||||
Text="Delete job"
|
||||
BackgroundColor="{DynamicResource UlColorAccent}"
|
||||
TextColor="{DynamicResource UlColorOnAccent}"
|
||||
Command="{Binding DeleteCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
|
||||
CommandParameter="{Binding .}" />
|
||||
CommandParameter="{Binding .}" MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="{Binding DeleteDescription}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
<CollectionView.Footer>
|
||||
<!-- Keeps the final row scrollable above the overlaid menu without
|
||||
reserving a permanent bottom band in the page layout. -->
|
||||
<ContentView HeightRequest="72" />
|
||||
</CollectionView.Footer>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
</Grid>
|
||||
|
||||
<controls:FloatingMenuButton Grid.RowSpan="2" HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -6,15 +6,16 @@ public partial class JobsPage : ContentPage
|
||||
{
|
||||
private readonly JobsViewModel _viewModel;
|
||||
|
||||
public JobsPage(JobsViewModel viewModel)
|
||||
public JobsPage(JobsViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await _viewModel.RefreshAsync();
|
||||
await _viewModel.LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,31 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.MapPage"
|
||||
x:DataType="vm:MapViewModel"
|
||||
Title="Map">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Label Grid.Row="0" Text="{Binding StatusText}" FontSize="12" TextColor="Gray" Padding="12,6" />
|
||||
Title="Map"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorMap}">
|
||||
<Grid>
|
||||
<!-- Populated in code-behind: native Map control on iOS/Android/macOS, Google Maps WebView on Windows. -->
|
||||
<ContentView Grid.Row="1" x:Name="MapHost" />
|
||||
<ContentView x:Name="MapHost"
|
||||
BackgroundColor="{DynamicResource UlColorMap}"
|
||||
SemanticProperties.Description="Map of locally captured points for the selected job" />
|
||||
|
||||
<controls:AppStatusBar x:Name="AppStatus" VerticalOptions="Start" ZIndex="20" />
|
||||
|
||||
<Button Text="↻" FontSize="22" FontAttributes="Bold"
|
||||
WidthRequest="48" HeightRequest="48" CornerRadius="24" Padding="0"
|
||||
Margin="16,82,16,16" HorizontalOptions="End" VerticalOptions="Start"
|
||||
BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
TextColor="{DynamicResource UlColorPrimary}"
|
||||
BorderColor="{DynamicResource UlColorBorder}" BorderWidth="1"
|
||||
Clicked="OnRefreshClicked" ZIndex="20"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
MinimumWidthRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Refresh captured points on the map" />
|
||||
|
||||
<controls:FloatingMenuButton HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -23,15 +23,26 @@ public partial class MapPage : ContentPage
|
||||
private Map? _map;
|
||||
#endif
|
||||
|
||||
public MapPage(MapViewModel viewModel)
|
||||
public MapPage(MapViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = _viewModel = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
|
||||
protected override async void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
await RefreshMapAsync();
|
||||
}
|
||||
|
||||
private async void OnRefreshClicked(object? sender, EventArgs e)
|
||||
{
|
||||
await RefreshMapAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshMapAsync()
|
||||
{
|
||||
await EnsureMapCreatedAsync();
|
||||
var points = await _viewModel.LoadPointsAsync();
|
||||
ShowPoints(points);
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
|
||||
xmlns:controls="clr-namespace:FieldLogger.Views.Controls"
|
||||
x:Class="FieldLogger.Views.SettingsPage"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="Settings">
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Padding="16" Spacing="12">
|
||||
Title="Settings"
|
||||
Shell.NavBarIsVisible="False"
|
||||
BackgroundColor="{DynamicResource UlColorCanvas}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<controls:AppStatusBar x:Name="AppStatus" />
|
||||
<ScrollView Grid.Row="1">
|
||||
<VerticalStackLayout Padding="16,16,16,88" Spacing="12">
|
||||
|
||||
<Label Text="Settings" Style="{StaticResource UlPageTitle}"
|
||||
TextColor="{DynamicResource UlColorText}"
|
||||
SemanticProperties.HeadingLevel="Level1" />
|
||||
|
||||
<Label Text="Devices" FontAttributes="Bold" FontSize="16" />
|
||||
|
||||
@@ -40,25 +49,33 @@
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Label Text="Map" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Google Maps API Key (Windows map view)" FontSize="12" TextColor="Gray" />
|
||||
<Entry Text="{Binding MapsApiKey}" Placeholder="AIza…" IsPassword="False" />
|
||||
<Label FontSize="11" TextColor="Gray"
|
||||
Text="On Android the key is configured in AndroidManifest.xml; iOS and macOS use Apple Maps and need no key." />
|
||||
<VerticalStackLayout Spacing="8" Margin="0,12,0,0">
|
||||
<Label Text="Diagnostics" FontAttributes="Bold" FontSize="16" />
|
||||
<Border Style="{StaticResource UlCard}"
|
||||
BackgroundColor="{DynamicResource UlColorSurfaceRaised}"
|
||||
Stroke="{DynamicResource UlColorBorder}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<VerticalStackLayout Spacing="2" VerticalOptions="Center">
|
||||
<Label Text="Debug" FontAttributes="Bold" FontSize="16"
|
||||
TextColor="{DynamicResource UlColorText}" />
|
||||
<Label Text="Capture readiness, device state, sync, recent points, and console tools"
|
||||
FontSize="12" TextColor="{DynamicResource UlColorTextMuted}" />
|
||||
</VerticalStackLayout>
|
||||
<Button Grid.Column="1" Text="Open" Command="{Binding OpenDebugCommand}"
|
||||
Style="{StaticResource UlButtonSecondary}"
|
||||
MinimumHeightRequest="{StaticResource UlTouchMinimum}"
|
||||
SemanticProperties.Description="Open UM Trace diagnostics" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||
<Label Text="MQTT job sync is planned. Data is currently stored locally on this device."
|
||||
FontSize="12" TextColor="Gray" />
|
||||
</Border>
|
||||
|
||||
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
|
||||
FontSize="11" TextColor="Gray" HorizontalOptions="Center" Margin="0,20,0,0" />
|
||||
<Label Text="{Binding AppVersion, StringFormat='UM Trace v{0}'}"
|
||||
FontSize="11" TextColor="{DynamicResource UlColorTextMuted}"
|
||||
HorizontalOptions="Center" Margin="0,20,0,0" />
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
|
||||
<controls:FloatingMenuButton Grid.RowSpan="2" HorizontalOptions="End" VerticalOptions="End" ZIndex="20" />
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -4,9 +4,10 @@ namespace FieldLogger.Views;
|
||||
|
||||
public partial class SettingsPage : ContentPage
|
||||
{
|
||||
public SettingsPage(SettingsViewModel viewModel)
|
||||
public SettingsPage(SettingsViewModel viewModel, AppStatusViewModel appStatusViewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = viewModel;
|
||||
AppStatus.BindingContext = appStatusViewModel;
|
||||
}
|
||||
}
|
||||
|
||||
48
Jenkinsfile
vendored
Normal file
48
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
pipeline {
|
||||
agent { label 'um-trace' }
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 45, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
environment {
|
||||
DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'
|
||||
DOTNET_ROOT = '/Users/brent/.dotnet'
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT = '1'
|
||||
DOTNET_NOLOGO = '1'
|
||||
NUGET_XMLDOC_MODE = 'skip'
|
||||
PATH = "/Users/brent/.dotnet:/opt/homebrew/bin:${env.PATH}"
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Toolchain') {
|
||||
steps {
|
||||
sh './scripts/ci/verify-macos-agent.sh'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Tests') {
|
||||
steps {
|
||||
sh './scripts/ci/test.sh'
|
||||
}
|
||||
}
|
||||
|
||||
stage('iOS Build (Unsigned)') {
|
||||
steps {
|
||||
sh './scripts/ci/build-ios-unsigned.sh'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts(
|
||||
artifacts: 'artifacts/**/*',
|
||||
allowEmptyArchive: true,
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Jenkinsfile.release
Normal file
58
Jenkinsfile.release
Normal file
@@ -0,0 +1,58 @@
|
||||
pipeline {
|
||||
agent { label 'um-trace' }
|
||||
|
||||
parameters {
|
||||
string(
|
||||
name: 'IOS_BUILD_NUMBER',
|
||||
defaultValue: '',
|
||||
description: 'Optional App Store build number override. Blank uses the Jenkins build number.'
|
||||
)
|
||||
}
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 60, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
environment {
|
||||
DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer'
|
||||
DOTNET_ROOT = '/Users/brent/.dotnet'
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT = '1'
|
||||
DOTNET_NOLOGO = '1'
|
||||
NUGET_XMLDOC_MODE = 'skip'
|
||||
PATH = "/Users/brent/.dotnet:/opt/homebrew/bin:${env.PATH}"
|
||||
UM_TRACE_CODESIGN_KEY = 'Apple Distribution'
|
||||
UM_TRACE_CODESIGN_PROVISION = 'UM Trace App Store'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Toolchain and Signing') {
|
||||
steps {
|
||||
sh './scripts/ci/verify-macos-agent.sh'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Tests') {
|
||||
steps {
|
||||
sh './scripts/ci/test.sh'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Signed iOS IPA') {
|
||||
steps {
|
||||
sh './scripts/ci/build-ios-signed.sh "${IOS_BUILD_NUMBER:-$BUILD_NUMBER}"'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts(
|
||||
artifacts: 'artifacts/ios-signed/**/*',
|
||||
allowEmptyArchive: true,
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
README.md
13
README.md
@@ -1,11 +1,9 @@
|
||||
# Field Logger
|
||||
# UM Trace
|
||||
|
||||
Cross-platform .NET MAUI app (Windows, macOS, iOS, Android) for logging utility locate
|
||||
points from an **Underground Magnetics locating receiver** paired with RTK GPS positions
|
||||
from a **Maglink (H11) RTK receiver**, both over BLE.
|
||||
|
||||
Maps API Key: AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI
|
||||
|
||||
## Solution layout
|
||||
|
||||
```
|
||||
@@ -40,8 +38,13 @@ doc/ Device protocol documentation
|
||||
|
||||
## Setup required before running
|
||||
|
||||
1. **Google Maps key (Android):** replace `YOUR_GOOGLE_MAPS_ANDROID_API_KEY` in
|
||||
`FieldLogger/Platforms/Android/AndroidManifest.xml` (Google Cloud Console → Maps SDK for Android).
|
||||
1. **Google Maps key (Android):** the manifest key is **injected at build time**, never
|
||||
committed (SEC-1). Provide it one of three ways: pass `-p:MapsApiKey=<key>` to
|
||||
`dotnet build` (how CI supplies it from a secret), set the `MAPS_API_KEY` environment
|
||||
variable, or copy `maps.key.props.example` to `maps.key.props` (gitignored) at the repo
|
||||
root and put your key there. Use a key restricted to the app package + release SHA-1
|
||||
(Google Cloud Console → Maps SDK for Android). Without a key, maps render blank but the
|
||||
app builds.
|
||||
2. **Google Maps key (Windows):** create a Maps JavaScript API key and paste it into the
|
||||
Settings page of the app.
|
||||
3. **Maglink GATT UUIDs:** the Maglink docs describe the serial protocol but not its GATT
|
||||
|
||||
88
doc/iOS_TestFlight.md
Normal file
88
doc/iOS_TestFlight.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# UM Trace TestFlight build
|
||||
|
||||
## Fixed application identity
|
||||
|
||||
- Display name: `UM Trace`
|
||||
- Bundle ID: `com.umagul.trace`
|
||||
- Apple team ID: `W2N8APPQ2C`
|
||||
- App Store Connect SKU: `UM-TRACE-IOS`
|
||||
- Distribution profile: `UM Trace App Store`
|
||||
|
||||
The provisioning profile and all Apple signing files are local-only and covered by
|
||||
`.gitignore`. Never commit a certificate, private key, App Store Connect API key, `.p12`, or
|
||||
provisioning profile.
|
||||
|
||||
## Required local toolchain
|
||||
|
||||
- Xcode 26 at `/Applications/Xcode.app`
|
||||
- .NET SDK 10.0.100 as the build host
|
||||
- Workload set 10.0.100 with `maui-ios` and its .NET 9/iOS 26 compatibility pack
|
||||
- An `Apple Distribution` identity, including its private key, in the login Keychain
|
||||
- The `UM Trace App Store` provisioning profile installed locally
|
||||
|
||||
This repository's local SDK is installed at `~/.dotnet`. Verify it with:
|
||||
|
||||
```sh
|
||||
~/.dotnet/dotnet --info
|
||||
~/.dotnet/dotnet workload list
|
||||
```
|
||||
|
||||
## Create an archive
|
||||
|
||||
Run the release helper from a normal macOS Terminal session. The build number must increase for
|
||||
every upload to App Store Connect:
|
||||
|
||||
```sh
|
||||
cd /Users/brent/ul-platform/app
|
||||
./scripts/publish-testflight.sh 1
|
||||
```
|
||||
|
||||
The app remains on its existing .NET 9 target. The .NET 10 SDK host supplies the supported .NET 9
|
||||
compatibility pack for Xcode 26, avoiding an unrelated Android/Mac Catalyst framework migration.
|
||||
The script deliberately supplies Xcode through `DEVELOPER_DIR`; it does not change the machine's
|
||||
global `xcode-select` setting. It restores only the iOS target, builds with the iOS 26 SDK, selects
|
||||
the installed `UM Trace App Store` profile, and prints the generated `.ipa` path.
|
||||
|
||||
## Create a signed IPA in Jenkins
|
||||
|
||||
The manually run `um-trace-ios-release` pipeline uses `Jenkinsfile.release`. By default it uses the
|
||||
monotonically increasing Jenkins build number as the App Store build number. Set
|
||||
`IOS_BUILD_NUMBER` only when an explicit higher override is required. The pipeline runs the tests,
|
||||
signs with the distribution identity and `UM Trace App Store` profile, verifies the resulting
|
||||
bundle signature and identity, and archives the IPA plus its SHA-256 file.
|
||||
|
||||
This pipeline only creates a signed artifact. It does not upload or submit anything to App Store
|
||||
Connect, so TestFlight release remains a separate, deliberate step.
|
||||
|
||||
Before uploading, verify that this command lists a valid distribution identity:
|
||||
|
||||
```sh
|
||||
security find-identity -v -p codesigning
|
||||
```
|
||||
|
||||
If the identity is absent, import a `.p12` containing the matching private key or create a new
|
||||
Apple Distribution certificate through Xcode and regenerate the provisioning profile against it.
|
||||
|
||||
## Deploy a development build to an iPhone
|
||||
|
||||
The local development profile is named `UM Trace Development`. It must include the target phone
|
||||
and match an `Apple Development` identity for team `W2N8APPQ2C`. Keep the phone unlocked and
|
||||
connected by USB for the first deployment, trust the Mac when prompted, and enable Developer Mode.
|
||||
|
||||
Save the phone UDID in the gitignored `.ios-device` file, then run from a normal macOS Terminal:
|
||||
|
||||
```sh
|
||||
cd /Users/brent/ul-platform/app
|
||||
./scripts/deploy-ios-device.sh
|
||||
```
|
||||
|
||||
You can instead pass a UDID as the first argument or set `UM_TRACE_DEVICE_UDID`. The helper uses
|
||||
Xcode 26, builds the `Debug` configuration for `ios-arm64`, signs with the development profile,
|
||||
installs the app, and launches it on the selected phone.
|
||||
|
||||
## Background and privacy declarations
|
||||
|
||||
The iOS bundle declares `bluetooth-central` and `location` background modes, Bluetooth and
|
||||
always/when-in-use location explanations, the MAUI `UserDefaults` required-reason API, and exempt
|
||||
standard TLS use. Physical-device testing remains required to prove the SRS eight-hour background
|
||||
BLE/location session; declaring a background mode does not itself guarantee continuous execution.
|
||||
13
maps.key.props.example
Normal file
13
maps.key.props.example
Normal file
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
SEC-1: local Google Maps key injection for Android builds.
|
||||
|
||||
Copy this file to `maps.key.props` (same directory) and paste your restricted key.
|
||||
`maps.key.props` is gitignored — never commit a real key. CI supplies the key instead
|
||||
via `-p:MapsApiKey=$SECRET` or the MAPS_API_KEY environment variable, so this file is
|
||||
only for local development.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<MapsApiKey>YOUR_GOOGLE_MAPS_ANDROID_API_KEY</MapsApiKey>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
75
scripts/ci/build-ios-signed.sh
Executable file
75
scripts/ci/build-ios-signed.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
build_number="${1:-}"
|
||||
codesign_key="${UM_TRACE_CODESIGN_KEY:-Apple Distribution}"
|
||||
codesign_profile="${UM_TRACE_CODESIGN_PROVISION:-UM Trace App Store}"
|
||||
profile_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
|
||||
artifacts_dir="${repo_dir}/artifacts/ios-signed"
|
||||
artifact_name="UMTrace-ios-build-${build_number}.ipa"
|
||||
|
||||
if [[ ! "${build_number}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "A positive numeric iOS build number is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! security find-identity -v -p codesigning | grep -Fq "${codesign_key}"; then
|
||||
echo "Signing identity is unavailable: ${codesign_key}" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
profile_found=false
|
||||
if [[ -d "${profile_dir}" ]]; then
|
||||
for profile in "${profile_dir}"/*.mobileprovision; do
|
||||
[[ -f "${profile}" ]] || continue
|
||||
installed_name="$(openssl smime -inform der -verify -noverify -in "${profile}" 2>/dev/null | plutil -extract Name raw -o - - 2>/dev/null || true)"
|
||||
if [[ "${installed_name}" == "${codesign_profile}" ]]; then
|
||||
profile_found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "${profile_found}" != true ]]; then
|
||||
echo "Provisioning profile is unavailable: ${codesign_profile}" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
UM_TRACE_CODESIGN_KEY="${codesign_key}" \
|
||||
UM_TRACE_CODESIGN_PROVISION="${codesign_profile}" \
|
||||
"${repo_dir}/scripts/publish-testflight.sh" "${build_number}"
|
||||
|
||||
source_ipa="$(find "${repo_dir}/FieldLogger/bin/Release/net9.0-ios/ios-arm64" -type f -name '*.ipa' -print -quit)"
|
||||
if [[ -z "${source_ipa}" || ! -f "${source_ipa}" ]]; then
|
||||
echo "The signed IPA was not created." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
mkdir -p "${artifacts_dir}"
|
||||
ditto "${source_ipa}" "${artifacts_dir}/${artifact_name}"
|
||||
|
||||
verification_dir="$(mktemp -d)"
|
||||
ditto -x -k "${artifacts_dir}/${artifact_name}" "${verification_dir}"
|
||||
app_bundle="$(find "${verification_dir}/Payload" -maxdepth 1 -type d -name '*.app' -print -quit)"
|
||||
|
||||
if [[ -z "${app_bundle}" ]]; then
|
||||
echo "The IPA does not contain an app bundle." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "${app_bundle}"
|
||||
|
||||
bundle_id="$(plutil -extract CFBundleIdentifier raw -o - "${app_bundle}/Info.plist")"
|
||||
signed_build_number="$(plutil -extract CFBundleVersion raw -o - "${app_bundle}/Info.plist")"
|
||||
if [[ "${bundle_id}" != "com.umagul.trace" || "${signed_build_number}" != "${build_number}" ]]; then
|
||||
echo "Signed bundle metadata is incorrect: ${bundle_id} (${signed_build_number})." >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${artifacts_dir}"
|
||||
shasum -a 256 "${artifact_name}" > "${artifact_name}.sha256"
|
||||
)
|
||||
|
||||
echo "Verified signed iOS IPA: ${artifacts_dir}/${artifact_name}"
|
||||
60
scripts/ci/build-ios-unsigned.sh
Executable file
60
scripts/ci/build-ios-unsigned.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
dotnet_bin="${DOTNET_BIN:-/Users/brent/.dotnet/dotnet}"
|
||||
developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
|
||||
project="${repo_dir}/FieldLogger/FieldLogger.csproj"
|
||||
runtime_identifier="${IOS_RUNTIME:-ios-arm64}"
|
||||
nuget_cache="${UM_TRACE_NUGET_CACHE:-${TMPDIR:-/tmp}/um-trace-ci-nuget}"
|
||||
app_bundle="${repo_dir}/FieldLogger/bin/Debug/net9.0-ios/${runtime_identifier}/FieldLogger.app"
|
||||
artifacts_dir="${repo_dir}/artifacts/ios"
|
||||
artifact_name="UMTrace-unsigned-${runtime_identifier}.app.zip"
|
||||
|
||||
export DEVELOPER_DIR="${developer_dir}"
|
||||
export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-${nuget_cache}/http-cache}"
|
||||
|
||||
mkdir -p "${NUGET_HTTP_CACHE_PATH}"
|
||||
|
||||
"${dotnet_bin}" restore "${project}" \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier="${runtime_identifier}" \
|
||||
-p:NuGetAudit=false
|
||||
|
||||
# A narrowed restore of the MAUI head can rewrite the referenced library's
|
||||
# assets file, so restore the headless sync library explicitly as well.
|
||||
"${dotnet_bin}" restore "${repo_dir}/src/FieldLogger.Sync/FieldLogger.Sync.csproj" \
|
||||
-p:NuGetAudit=false
|
||||
|
||||
"${dotnet_bin}" clean "${project}" \
|
||||
--framework net9.0-ios \
|
||||
--configuration Debug \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier="${runtime_identifier}"
|
||||
|
||||
"${dotnet_bin}" build "${project}" \
|
||||
--framework net9.0-ios \
|
||||
--configuration Debug \
|
||||
--runtime "${runtime_identifier}" \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:EnableCodeSigning=false \
|
||||
-p:CodesignKey= \
|
||||
-p:CodesignProvision= \
|
||||
--no-restore
|
||||
|
||||
if [[ ! -d "${app_bundle}" ]]; then
|
||||
echo "Unsigned app bundle was not created at ${app_bundle}." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "${artifacts_dir}"
|
||||
ditto -c -k --sequesterRsrc --keepParent \
|
||||
"${app_bundle}" \
|
||||
"${artifacts_dir}/${artifact_name}"
|
||||
|
||||
(
|
||||
cd "${artifacts_dir}"
|
||||
shasum -a 256 "${artifact_name}" > "${artifact_name}.sha256"
|
||||
)
|
||||
|
||||
echo "Archived unsigned iOS app: ${artifacts_dir}/${artifact_name}"
|
||||
28
scripts/ci/test.sh
Executable file
28
scripts/ci/test.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
dotnet_bin="${DOTNET_TEST_BIN:-/usr/local/share/dotnet/dotnet}"
|
||||
results_dir="${repo_dir}/artifacts/test-results"
|
||||
nuget_cache="${UM_TRACE_NUGET_CACHE:-${TMPDIR:-/tmp}/um-trace-ci-nuget}"
|
||||
|
||||
mkdir -p "${results_dir}" "${nuget_cache}/http-cache"
|
||||
|
||||
export NUGET_HTTP_CACHE_PATH="${NUGET_HTTP_CACHE_PATH:-${nuget_cache}/http-cache}"
|
||||
|
||||
test_projects=(
|
||||
"tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj"
|
||||
"tests/FieldLogger.Tests/FieldLogger.Tests.csproj"
|
||||
"tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj"
|
||||
)
|
||||
|
||||
for project in "${test_projects[@]}"; do
|
||||
project_name="$(basename "${project}" .csproj)"
|
||||
DOTNET_ROOT=/usr/local/share/dotnet "${dotnet_bin}" restore "${repo_dir}/${project}" \
|
||||
-p:NuGetAudit=false
|
||||
DOTNET_ROOT=/usr/local/share/dotnet "${dotnet_bin}" test "${repo_dir}/${project}" \
|
||||
--configuration Release \
|
||||
--no-restore \
|
||||
--logger "trx;LogFileName=${project_name}.trx" \
|
||||
--results-directory "${results_dir}"
|
||||
done
|
||||
33
scripts/ci/verify-macos-agent.sh
Executable file
33
scripts/ci/verify-macos-agent.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dotnet_bin="${DOTNET_BIN:-/Users/brent/.dotnet/dotnet}"
|
||||
test_dotnet_bin="${DOTNET_TEST_BIN:-/usr/local/share/dotnet/dotnet}"
|
||||
developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
|
||||
|
||||
if [[ ! -x "${dotnet_bin}" ]]; then
|
||||
echo "Missing Xcode 26 build SDK at ${dotnet_bin}." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -x "${test_dotnet_bin}" ]]; then
|
||||
echo "Missing .NET 9 test SDK at ${test_dotnet_bin}." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "${developer_dir}" ]]; then
|
||||
echo "Missing Xcode developer directory at ${developer_dir}." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="${developer_dir}"
|
||||
|
||||
java -version
|
||||
xcodebuild -version
|
||||
"${dotnet_bin}" --info
|
||||
"${dotnet_bin}" workload list
|
||||
DOTNET_ROOT=/usr/local/share/dotnet "${test_dotnet_bin}" --info
|
||||
|
||||
# Signing is not required for the unsigned compile build, but report what will be
|
||||
# available to later approval-gated device and TestFlight stages.
|
||||
security find-identity -v -p codesigning || true
|
||||
60
scripts/deploy-ios-device.sh
Executable file
60
scripts/deploy-ios-device.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
dotnet_bin="${DOTNET_BIN:-${HOME}/.dotnet/dotnet}"
|
||||
developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
|
||||
codesign_key="${UM_TRACE_DEVELOPMENT_KEY:-Apple Development: Brent Perteet (WZ56HVC6FD)}"
|
||||
codesign_profile="${UM_TRACE_DEVELOPMENT_PROVISION:-UM Trace Development}"
|
||||
device_udid="${1:-${UM_TRACE_DEVICE_UDID:-}}"
|
||||
|
||||
if [[ -z "${device_udid}" && -f "${repo_dir}/.ios-device" ]]; then
|
||||
IFS= read -r device_udid < "${repo_dir}/.ios-device"
|
||||
fi
|
||||
|
||||
if [[ -z "${device_udid}" ]]; then
|
||||
echo "Usage: $0 <device-udid>" >&2
|
||||
echo "Alternatively, set UM_TRACE_DEVICE_UDID or save the UDID in ${repo_dir}/.ios-device." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -x "${dotnet_bin}" ]]; then
|
||||
echo "Missing .NET SDK at ${dotnet_bin}. Set DOTNET_BIN or install the local SDK." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "${developer_dir}" ]]; then
|
||||
echo "Missing Xcode developer directory at ${developer_dir}. Set DEVELOPER_DIR." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="${developer_dir}"
|
||||
|
||||
"${dotnet_bin}" restore "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier=ios-arm64
|
||||
|
||||
# Restoring the MAUI head with a narrowed TargetFrameworks property can rewrite the
|
||||
# referenced headless library's assets file. Restore that library explicitly before building.
|
||||
"${dotnet_bin}" restore "${repo_dir}/src/FieldLogger.Sync/FieldLogger.Sync.csproj"
|
||||
|
||||
# A partial MSBuild target (for example, a compile-only IDE validation) can leave a
|
||||
# FieldLogger.dll whose generated XAML code is newer than its embedded MauiXaml resources.
|
||||
# Always clear the narrowed iOS output before the real device build so App.xaml, page XAML,
|
||||
# app icons, and splash assets are regenerated as one consistent build graph.
|
||||
"${dotnet_bin}" clean "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-f net9.0-ios \
|
||||
-c Debug \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier=ios-arm64
|
||||
|
||||
"${dotnet_bin}" build "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-t:Run \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-f net9.0-ios \
|
||||
-c Debug \
|
||||
-p:RuntimeIdentifier=ios-arm64 \
|
||||
-p:_DeviceName="${device_udid}" \
|
||||
-p:CodesignKey="${codesign_key}" \
|
||||
-p:CodesignProvision="${codesign_profile}" \
|
||||
--no-restore
|
||||
55
scripts/publish-testflight.sh
Executable file
55
scripts/publish-testflight.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
dotnet_bin="${DOTNET_BIN:-${HOME}/.dotnet/dotnet}"
|
||||
developer_dir="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}"
|
||||
build_number="${1:-}"
|
||||
codesign_key="${UM_TRACE_CODESIGN_KEY:-Apple Distribution}"
|
||||
codesign_profile="${UM_TRACE_CODESIGN_PROVISION:-UM Trace App Store}"
|
||||
|
||||
if [[ ! "${build_number}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Usage: $0 <numeric-build-number>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -x "${dotnet_bin}" ]]; then
|
||||
echo "Missing .NET 10 SDK at ${dotnet_bin}. Set DOTNET_BIN or install .NET 10." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "${developer_dir}" ]]; then
|
||||
echo "Missing Xcode developer directory at ${developer_dir}. Set DEVELOPER_DIR." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="${developer_dir}"
|
||||
|
||||
"${dotnet_bin}" restore "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier=ios-arm64
|
||||
|
||||
# Restoring the MAUI head with a narrowed TargetFrameworks property can rewrite the
|
||||
# referenced headless library's assets file. Restore that library explicitly before publish.
|
||||
"${dotnet_bin}" restore "${repo_dir}/src/FieldLogger.Sync/FieldLogger.Sync.csproj"
|
||||
|
||||
"${dotnet_bin}" clean "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-f net9.0-ios \
|
||||
-c Release \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-p:RuntimeIdentifier=ios-arm64
|
||||
|
||||
"${dotnet_bin}" publish "${repo_dir}/FieldLogger/FieldLogger.csproj" \
|
||||
-p:TargetFrameworks=net9.0-ios \
|
||||
-f net9.0-ios \
|
||||
-c Release \
|
||||
-p:RuntimeIdentifier=ios-arm64 \
|
||||
-p:ArchiveOnBuild=true \
|
||||
-p:BuildIpa=true \
|
||||
-p:CodesignKey="${codesign_key}" \
|
||||
-p:CodesignProvision="${codesign_profile}" \
|
||||
-p:ApplicationVersion="${build_number}" \
|
||||
--no-restore
|
||||
|
||||
find "${repo_dir}/FieldLogger/bin/Release/net9.0-ios/ios-arm64" \
|
||||
-type f -name '*.ipa' -print
|
||||
25
src/FieldLogger.Sync/BackoffPolicy.cs
Normal file
25
src/FieldLogger.Sync/BackoffPolicy.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
/// <summary>Exponential retry backoff with full jitter and a hard cap.</summary>
|
||||
public sealed class BackoffPolicy
|
||||
{
|
||||
private readonly TimeSpan _base;
|
||||
private readonly TimeSpan _cap;
|
||||
private readonly Random _random;
|
||||
|
||||
public BackoffPolicy(TimeSpan baseDelay, TimeSpan cap, Random? random = null)
|
||||
{
|
||||
if (baseDelay <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(baseDelay));
|
||||
if (cap < baseDelay) throw new ArgumentOutOfRangeException(nameof(cap));
|
||||
_base = baseDelay;
|
||||
_cap = cap;
|
||||
_random = random ?? Random.Shared;
|
||||
}
|
||||
|
||||
public TimeSpan NextDelay(int attempt)
|
||||
{
|
||||
int exponent = Math.Min(Math.Max(1, attempt) - 1, 30);
|
||||
double ceilingMs = Math.Min(_cap.TotalMilliseconds, _base.TotalMilliseconds * Math.Pow(2, exponent));
|
||||
return TimeSpan.FromMilliseconds(_random.NextDouble() * ceilingMs);
|
||||
}
|
||||
}
|
||||
27
src/FieldLogger.Sync/FieldLogger.Sync.csproj
Normal file
27
src/FieldLogger.Sync/FieldLogger.Sync.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Cloud-sync core for UM Trace: the durable outbound queue, the Point wire mapping,
|
||||
and the MQTTS publish/ack engine (SRS-SYN-2/5/7, §3.4.2, telemetry-schema.md).
|
||||
Deliberately a plain net9.0 library (NO MAUI head) so the queue-durability, ack-release,
|
||||
and exactly-once behaviour run headless in the QA gate without the Android/iOS workloads.
|
||||
The MAUI app references this and supplies a DB path + broker config; tests supply a fake
|
||||
transport and a temp-file store.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>FieldLogger.Sync</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Real broker transport. Tests do not touch this (they use a fake IMqttTransport). -->
|
||||
<PackageReference Include="MQTTnet" Version="4.3.7.1207" />
|
||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
56
src/FieldLogger.Sync/IMqttTransport.cs
Normal file
56
src/FieldLogger.Sync/IMqttTransport.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal MQTTS transport the sync engine drives. Abstracted so the engine's
|
||||
/// durability/ack/backoff behaviour is tested headless with a fake, while the app uses the
|
||||
/// MQTTnet-backed implementation against the real broker (§3.4.2).
|
||||
/// </summary>
|
||||
public interface IMqttTransport
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Connect (MQTTS/TLS) and subscribe to the ack topic. Throws on failure.</summary>
|
||||
Task ConnectAsync(string ackTopic, CancellationToken ct = default);
|
||||
|
||||
Task DisconnectAsync();
|
||||
|
||||
/// <summary>Publish a payload at QoS 1 (durable). Completes on broker PUBACK; throws on failure.</summary>
|
||||
Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Raised when an application-level ack payload arrives on the ack topic.</summary>
|
||||
event Func<AckBatch, Task>? AckReceived;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Application-level ack payload on ul/{orgId}/app/{clientId}/ack (SRS §3.4.2 "…/ack:
|
||||
/// UUIDs accepted/rejected + reason"). Distinct from the broker PUBACK — the queue releases a
|
||||
/// record only on the application ack referencing its pointId.
|
||||
/// </summary>
|
||||
public sealed record AckBatch
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")] public string SchemaVersion { get; init; } = "1";
|
||||
[JsonPropertyName("results")] public List<AckItem> Results { get; init; } = new();
|
||||
|
||||
private static readonly JsonSerializerOptions Opts = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public static AckBatch FromJsonUtf8(ReadOnlySpan<byte> utf8) =>
|
||||
JsonSerializer.Deserialize<AckBatch>(utf8, Opts) ?? throw new JsonException("null AckBatch");
|
||||
public byte[] ToJsonUtf8() => JsonSerializer.SerializeToUtf8Bytes(this, Opts);
|
||||
}
|
||||
|
||||
public enum AckOutcome { ACCEPTED, DUPLICATE, REJECTED }
|
||||
|
||||
public sealed record AckItem
|
||||
{
|
||||
[JsonPropertyName("pointId")] public required string PointId { get; init; }
|
||||
[JsonPropertyName("outcome")] public AckOutcome Outcome { get; init; }
|
||||
/// <summary>Machine code when REJECTED (api.yaml Error catalog); null on ACCEPTED.</summary>
|
||||
[JsonPropertyName("reasonCode")] public string? ReasonCode { get; init; }
|
||||
}
|
||||
134
src/FieldLogger.Sync/MqttSyncEngine.cs
Normal file
134
src/FieldLogger.Sync/MqttSyncEngine.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
/// <summary>Tunables for the publish/retry/ack loop.</summary>
|
||||
public sealed record SyncOptions
|
||||
{
|
||||
/// <summary>ul/{orgId}/app/{clientId} — the app's own namespace root. The engine only ever
|
||||
/// publishes/subscribes under this prefix (namespace confinement; SRS §3.4.2 / §10.2).</summary>
|
||||
public required string OrgId { get; init; }
|
||||
public required string ClientId { get; init; }
|
||||
|
||||
public string PointsTopic => $"ul/{OrgId}/app/{ClientId}/log/points";
|
||||
public string AckTopic => $"ul/{OrgId}/app/{ClientId}/ack";
|
||||
|
||||
/// <summary>How long to wait for the application-level ack before republishing (idempotent
|
||||
/// via UUID; cloud dedups). Not a failure — release still requires the ack.</summary>
|
||||
public TimeSpan AckWindow { get; init; } = TimeSpan.FromSeconds(10);
|
||||
/// <summary>Backoff on broker/publish failure: base, doubled per attempt, capped.</summary>
|
||||
public TimeSpan BackoffBase { get; init; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan BackoffCap { get; init; } = TimeSpan.FromSeconds(60);
|
||||
public int DrainBatch { get; init; } = 50;
|
||||
|
||||
/// <summary>Reason codes that make a REJECTED ack terminal (drop from queue + surface for
|
||||
/// LOG-7) rather than retryable. Everything else is retried with backoff. Default: schema /
|
||||
/// validation / authorization failures — resending won't help.</summary>
|
||||
public IReadOnlySet<string> TerminalRejectCodes { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"VALIDATION_ERROR", "POINT_ID_NOT_UUIDV7", "ENVELOPE_VALIDATION_ERROR",
|
||||
"UNKNOWN_JOB_OR_WRONG_ORG", "SCHEMA_INVALID", "FORBIDDEN", "UNAUTHENTICATED",
|
||||
"NOT_FOUND", "PAYLOAD_TOO_LARGE",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives the durable outbound queue to the broker and releases records only on the cloud's
|
||||
/// application-level ack (SRS-SYN-2/7, §3.4.2). Publish is idempotent via the UUIDv7 pointId,
|
||||
/// so an unacked record is safely republished after the ack window and the cloud de-duplicates.
|
||||
/// Broker loss never loses a capture — the record stays durably queued (SRS-SYN-1). Transport-
|
||||
/// and store-agnostic so it runs headless in tests.
|
||||
/// </summary>
|
||||
public sealed class MqttSyncEngine
|
||||
{
|
||||
private readonly IMqttTransport _transport;
|
||||
private readonly IOutboundStore _store;
|
||||
private readonly SyncOptions _opts;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
private readonly BackoffPolicy _backoff;
|
||||
|
||||
/// <summary>Raised when the cloud terminally rejects a record (LOG-7 surface: never silent).</summary>
|
||||
public event Func<OutboundMessage, Task>? PointRejected;
|
||||
/// <summary>Raised when a record is accepted and released from the queue.</summary>
|
||||
public event Func<string, Task>? PointAccepted;
|
||||
|
||||
public MqttSyncEngine(IMqttTransport transport, IOutboundStore store, SyncOptions opts,
|
||||
Func<DateTimeOffset>? now = null, BackoffPolicy? backoff = null)
|
||||
{
|
||||
_transport = transport;
|
||||
_store = store;
|
||||
_opts = opts;
|
||||
_now = now ?? (() => DateTimeOffset.UtcNow);
|
||||
_backoff = backoff ?? new BackoffPolicy(opts.BackoffBase, opts.BackoffCap);
|
||||
_transport.AckReceived += ApplyAckAsync;
|
||||
}
|
||||
|
||||
/// <summary>Connect + subscribe to the ack topic. Safe to call when a broker is reachable.</summary>
|
||||
public Task ConnectAsync(CancellationToken ct = default) => _transport.ConnectAsync(_opts.AckTopic, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Publish every ready record once. Returns the number published this pass. On publish
|
||||
/// failure (e.g. broker down) the record is rescheduled with backoff and kept — no capture
|
||||
/// is lost. Records published successfully stay queued until their app-level ack arrives.
|
||||
/// </summary>
|
||||
public async Task<int> DrainOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!_transport.IsConnected)
|
||||
return 0; // broker unreachable → leave everything durably queued (SRS-SYN-1)
|
||||
|
||||
var ready = await _store.DequeueReadyAsync(_now(), _opts.DrainBatch);
|
||||
int published = 0;
|
||||
foreach (var m in ready)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await _transport.PublishAsync(m.Topic, System.Text.Encoding.UTF8.GetBytes(m.PayloadJson),
|
||||
m.SchemaVersion, ct);
|
||||
// Published (broker PUBACK). Do NOT release — wait for the application ack.
|
||||
// Reschedule a republish after the ack window in case the ack is lost.
|
||||
await _store.TouchAwaitingAckAsync(m.PointId, _now() + _opts.AckWindow);
|
||||
published++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _store.RescheduleAsync(m.PointId, _now() + _backoff.NextDelay(m.AttemptCount + 1), ex.Message);
|
||||
}
|
||||
}
|
||||
return published;
|
||||
}
|
||||
|
||||
private async Task ApplyAckAsync(AckBatch batch)
|
||||
{
|
||||
foreach (var item in batch.Results)
|
||||
{
|
||||
if (item.Outcome is AckOutcome.ACCEPTED or AckOutcome.DUPLICATE)
|
||||
{
|
||||
await _store.ReleaseAckedAsync(item.PointId); // release only on ACCEPTED
|
||||
if (PointAccepted is { } acceptedHandlers)
|
||||
{
|
||||
foreach (Func<string, Task> handler in acceptedHandlers.GetInvocationList())
|
||||
await handler(item.PointId);
|
||||
}
|
||||
}
|
||||
else // REJECTED
|
||||
{
|
||||
var code = item.ReasonCode ?? "REJECTED";
|
||||
if (_opts.TerminalRejectCodes.Contains(code))
|
||||
{
|
||||
await _store.MarkRejectedAsync(item.PointId, code); // LOG-7: surface, never silent
|
||||
var rejected = (await _store.GetRejectedAsync()).FirstOrDefault(r => r.PointId == item.PointId);
|
||||
if (rejected is not null && PointRejected is { } rejectedHandlers)
|
||||
{
|
||||
foreach (Func<OutboundMessage, Task> handler in rejectedHandlers.GetInvocationList())
|
||||
await handler(rejected);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retryable rejection — reschedule immediately-ish with backoff.
|
||||
await _store.RescheduleAsync(item.PointId, _now() + _opts.BackoffBase, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
120
src/FieldLogger.Sync/MqttnetTransport.cs
Normal file
120
src/FieldLogger.Sync/MqttnetTransport.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using System.Text;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Client;
|
||||
using MQTTnet.Protocol;
|
||||
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
/// <summary>Interim app→broker connection config (SRS §3.4.2). MQTTS/TLS only; namespace-scoped
|
||||
/// credential. OIDC-derived tokens (SRS-SYN-3) are deferred to the auth sprint — see decisions.md.
|
||||
/// Reviewed by `security` (S2-sec).</summary>
|
||||
public sealed record MqttBrokerConfig
|
||||
{
|
||||
public required string Host { get; init; }
|
||||
public int Port { get; init; } = 443;
|
||||
/// <summary>When set, use MQTT over secure WebSockets (normally
|
||||
/// wss://dev.hub.umagul.net/mqtt) instead of raw MQTTS.</summary>
|
||||
public string? WebSocketUri { get; init; }
|
||||
public bool UseTls { get; init; } = true;
|
||||
public required string ClientId { get; init; }
|
||||
/// <summary>Interim credential (username = orgId, password = scoped per-org secret).
|
||||
/// Supplied at runtime from secure storage / config — never hard-coded.</summary>
|
||||
public string? Username { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public TimeSpan SessionExpiry { get; init; } = TimeSpan.FromHours(24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTTnet-backed <see cref="IMqttTransport"/> against the real UlHub broker. QoS 1 + persistent
|
||||
/// session so durable records survive reconnect; TLS mandatory. Ack payloads on the subscribed
|
||||
/// ack topic are surfaced via <see cref="AckReceived"/>. Not exercised by the headless tests
|
||||
/// (they use a fake transport) — this is the production path.
|
||||
/// </summary>
|
||||
public sealed class MqttnetTransport : IMqttTransport, IAsyncDisposable
|
||||
{
|
||||
private readonly MqttBrokerConfig _config;
|
||||
private readonly IMqttClient _client;
|
||||
private string? _ackTopic;
|
||||
|
||||
public event Func<AckBatch, Task>? AckReceived;
|
||||
public bool IsConnected => _client.IsConnected;
|
||||
|
||||
public MqttnetTransport(MqttBrokerConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_client = new MqttFactory().CreateMqttClient();
|
||||
_client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(string ackTopic, CancellationToken ct = default)
|
||||
{
|
||||
if (!_config.UseTls)
|
||||
throw new InvalidOperationException("The app MQTT transport requires TLS.");
|
||||
if (string.IsNullOrWhiteSpace(_config.Username) || string.IsNullOrWhiteSpace(_config.Password))
|
||||
throw new InvalidOperationException("A scoped app MQTT username and password are required.");
|
||||
|
||||
_ackTopic = ackTopic;
|
||||
|
||||
var builder = new MqttClientOptionsBuilder();
|
||||
if (string.IsNullOrWhiteSpace(_config.WebSocketUri))
|
||||
builder.WithTcpServer(_config.Host, _config.Port);
|
||||
else
|
||||
builder.WithWebSocketServer(options => options.WithUri(_config.WebSocketUri));
|
||||
|
||||
var options = builder
|
||||
.WithClientId(_config.ClientId)
|
||||
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
|
||||
.WithCleanSession(false) // persistent session (durable)
|
||||
.WithSessionExpiryInterval((uint)_config.SessionExpiry.TotalSeconds)
|
||||
.WithTlsOptions(o => o.UseTls(_config.UseTls))
|
||||
.WithCredentials(_config.Username, _config.Password)
|
||||
.Build();
|
||||
|
||||
await _client.ConnectAsync(options, ct);
|
||||
await _client.SubscribeAsync(ackTopic, MqttQualityOfServiceLevel.AtLeastOnce, ct);
|
||||
}
|
||||
|
||||
public Task DisconnectAsync() =>
|
||||
_client.IsConnected ? _client.DisconnectAsync() : Task.CompletedTask;
|
||||
|
||||
public async Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default)
|
||||
{
|
||||
var msg = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(payload)
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) // QoS 1 (durable)
|
||||
.WithContentType("application/json")
|
||||
.WithUserProperty("schemaVersion", schemaVersion)
|
||||
.Build();
|
||||
|
||||
var result = await _client.PublishAsync(msg, ct);
|
||||
if (!result.IsSuccess)
|
||||
throw new InvalidOperationException($"publish rejected: {result.ReasonCode}");
|
||||
}
|
||||
|
||||
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||
{
|
||||
if (e.ApplicationMessage.Topic == _ackTopic)
|
||||
{
|
||||
try
|
||||
{
|
||||
var batch = AckBatch.FromJsonUtf8(e.ApplicationMessage.PayloadSegment);
|
||||
if (AckReceived is { } handlers)
|
||||
{
|
||||
foreach (Func<AckBatch, Task> handler in handlers.GetInvocationList())
|
||||
await handler(batch);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed ack payload — ignore; the record stays queued and is republished.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
161
src/FieldLogger.Sync/OutboundStore.cs
Normal file
161
src/FieldLogger.Sync/OutboundStore.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using SQLite;
|
||||
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
/// <summary>Lifecycle of a queued outbound record.</summary>
|
||||
public enum OutboundStatus
|
||||
{
|
||||
/// <summary>Ready to publish.</summary>
|
||||
Pending = 0,
|
||||
/// <summary>Published and waiting for an application acknowledgement.</summary>
|
||||
InFlight = 1,
|
||||
/// <summary>Terminally rejected by the cloud (schema/validation/authz) — not retried; surfaced for LOG-7.</summary>
|
||||
RejectedTerminal = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A durably-queued outbound point. Survives app restart (persisted in SQLite) and is
|
||||
/// released only when the cloud application-level ack references its <see cref="PointId"/>
|
||||
/// (SRS-SYN-2/7). Broker PUBACK alone does NOT release it.
|
||||
/// </summary>
|
||||
[Table("outbound")]
|
||||
public sealed class OutboundMessage
|
||||
{
|
||||
[PrimaryKey, AutoIncrement] public int Id { get; set; }
|
||||
|
||||
/// <summary>UUIDv7 idempotency key. Unique — a re-enqueue of the same point is a no-op.</summary>
|
||||
[Indexed(Name = "ux_pointid", Order = 1, Unique = true)]
|
||||
public string PointId { get; set; } = "";
|
||||
|
||||
public string Topic { get; set; } = "";
|
||||
public string PayloadJson { get; set; } = "";
|
||||
public string SchemaVersion { get; set; } = "1";
|
||||
|
||||
public long EnqueuedUnixMs { get; set; }
|
||||
public int AttemptCount { get; set; }
|
||||
public long NextAttemptUnixMs { get; set; }
|
||||
public int Status { get; set; } = (int)OutboundStatus.Pending;
|
||||
public string? LastReason { get; set; }
|
||||
|
||||
public static OutboundMessage FromPoint(
|
||||
PointRecord point,
|
||||
string topic,
|
||||
DateTimeOffset enqueuedAt,
|
||||
string? jobId = null,
|
||||
string? ticket = null)
|
||||
{
|
||||
var payload = point.ToAppLogPayloadUtf8(jobId, ticket);
|
||||
return new OutboundMessage
|
||||
{
|
||||
PointId = point.PointId,
|
||||
Topic = topic,
|
||||
PayloadJson = System.Text.Encoding.UTF8.GetString(payload),
|
||||
SchemaVersion = "1",
|
||||
EnqueuedUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
|
||||
NextAttemptUnixMs = enqueuedAt.ToUnixTimeMilliseconds(),
|
||||
Status = (int)OutboundStatus.Pending,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Durable outbound-queue store. Implementations must survive process restart.</summary>
|
||||
public interface IOutboundStore
|
||||
{
|
||||
Task InitAsync();
|
||||
/// <summary>Enqueue idempotently; returns false if a row with this pointId already exists.</summary>
|
||||
Task<bool> EnqueueAsync(OutboundMessage message);
|
||||
/// <summary>Pending rows whose next-attempt time has arrived, oldest first.</summary>
|
||||
Task<IReadOnlyList<OutboundMessage>> DequeueReadyAsync(DateTimeOffset now, int max = 50);
|
||||
/// <summary>Release a record — accepted by the cloud. Removes it from the queue.</summary>
|
||||
Task ReleaseAckedAsync(string pointId);
|
||||
/// <summary>Record a retryable failure: bump attempt count, schedule the next attempt.</summary>
|
||||
Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason);
|
||||
/// <summary>Mark a record published-and-awaiting-ack: reschedule a republish (idempotent via
|
||||
/// UUID) after the ack window WITHOUT counting it as a failed attempt.</summary>
|
||||
Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt);
|
||||
/// <summary>Terminal rejection (LOG-7): keep the row for surfacing, mark it not-retryable.</summary>
|
||||
Task MarkRejectedAsync(string pointId, string reason);
|
||||
Task<int> PendingCountAsync();
|
||||
Task<IReadOnlyList<OutboundMessage>> GetRejectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed <see cref="IOutboundStore"/>. The DB path is injected (not taken from a
|
||||
/// platform API) so it is testable headless with a temp file; reopening the same path is a
|
||||
/// process restart. Idempotent enqueue relies on the unique index on pointId.
|
||||
/// </summary>
|
||||
public sealed class SqliteOutboundStore : IOutboundStore
|
||||
{
|
||||
private readonly SQLiteAsyncConnection _db;
|
||||
|
||||
public SqliteOutboundStore(string dbPath)
|
||||
{
|
||||
_db = new SQLiteAsyncConnection(dbPath,
|
||||
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
|
||||
}
|
||||
|
||||
public async Task InitAsync()
|
||||
{
|
||||
await _db.CreateTableAsync<OutboundMessage>();
|
||||
// A process crash can leave rows in-flight after the broker accepted them but before
|
||||
// the application ack was applied. Requeue them for immediate replay; pointId makes
|
||||
// the replay idempotent. A zero due-time also keeps recovery independent of whichever
|
||||
// clock implementation the sync engine uses.
|
||||
await _db.ExecuteAsync(
|
||||
"UPDATE outbound SET Status = ?, NextAttemptUnixMs = ? WHERE Status = ?",
|
||||
(int)OutboundStatus.Pending, 0,
|
||||
(int)OutboundStatus.InFlight);
|
||||
}
|
||||
|
||||
public async Task<bool> EnqueueAsync(OutboundMessage message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.InsertAsync(message);
|
||||
return true;
|
||||
}
|
||||
catch (SQLiteException)
|
||||
{
|
||||
// Unique-index violation on pointId → already queued/known. Idempotent no-op.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OutboundMessage>> DequeueReadyAsync(DateTimeOffset now, int max = 50)
|
||||
{
|
||||
long nowMs = now.ToUnixTimeMilliseconds();
|
||||
return await _db.Table<OutboundMessage>()
|
||||
.Where(m => (m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
|
||||
&& m.NextAttemptUnixMs <= nowMs)
|
||||
.OrderBy(m => m.EnqueuedUnixMs)
|
||||
.Take(max)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task ReleaseAckedAsync(string pointId) =>
|
||||
_db.ExecuteAsync("DELETE FROM outbound WHERE PointId = ?", pointId);
|
||||
|
||||
public Task RescheduleAsync(string pointId, DateTimeOffset nextAttempt, string reason) =>
|
||||
_db.ExecuteAsync(
|
||||
"UPDATE outbound SET AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ?, LastReason = ? WHERE PointId = ? AND Status = ?",
|
||||
nextAttempt.ToUnixTimeMilliseconds(), reason, pointId, (int)OutboundStatus.Pending);
|
||||
|
||||
public Task TouchAwaitingAckAsync(string pointId, DateTimeOffset nextAttempt) =>
|
||||
_db.ExecuteAsync(
|
||||
"UPDATE outbound SET Status = ?, AttemptCount = AttemptCount + 1, NextAttemptUnixMs = ? WHERE PointId = ? AND Status != ?",
|
||||
(int)OutboundStatus.InFlight, nextAttempt.ToUnixTimeMilliseconds(), pointId,
|
||||
(int)OutboundStatus.RejectedTerminal);
|
||||
|
||||
public Task MarkRejectedAsync(string pointId, string reason) =>
|
||||
_db.ExecuteAsync(
|
||||
"UPDATE outbound SET Status = ?, LastReason = ? WHERE PointId = ?",
|
||||
(int)OutboundStatus.RejectedTerminal, reason, pointId);
|
||||
|
||||
public async Task<int> PendingCountAsync() =>
|
||||
await _db.Table<OutboundMessage>()
|
||||
.Where(m => m.Status == (int)OutboundStatus.Pending || m.Status == (int)OutboundStatus.InFlight)
|
||||
.CountAsync();
|
||||
|
||||
public async Task<IReadOnlyList<OutboundMessage>> GetRejectedAsync() =>
|
||||
await _db.Table<OutboundMessage>().Where(m => m.Status == (int)OutboundStatus.RejectedTerminal).ToListAsync();
|
||||
}
|
||||
250
src/FieldLogger.Sync/PointRecord.cs
Normal file
250
src/FieldLogger.Sync/PointRecord.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FieldLogger.Sync;
|
||||
|
||||
// Wire shape of a Point (SRS §5 / telemetry-schema.md), as published to
|
||||
// ul/{orgId}/app/{clientId}/log/points. pointId (UUIDv7) is the idempotency key on every
|
||||
// path. This is the app→cloud contract payload; keep field names in sync with
|
||||
// telemetry-schema.md (breaking change → backend review).
|
||||
|
||||
/// <summary>Point origin (schema Identity group).</summary>
|
||||
public enum PointOrigin { APP, LOCATOR, MAGLINK }
|
||||
|
||||
/// <summary>How the record reached the cloud (schema Identity group).</summary>
|
||||
public enum UploadPath { APP_MQTT, DEVICE_MQTT, REST_BATCH }
|
||||
|
||||
/// <summary>What triggered the capture (schema Identity group).</summary>
|
||||
public enum CaptureTrigger { APP_UI, LOCATOR_BUTTON }
|
||||
|
||||
public sealed record PointRecord
|
||||
{
|
||||
/// <summary>Payload schema version — also carried as an MQTT5 user property.</summary>
|
||||
[JsonPropertyName("schemaVersion")] public string SchemaVersion { get; init; } = "1";
|
||||
|
||||
// ---- Identity & provenance ----
|
||||
[JsonPropertyName("pointId")] public required string PointId { get; init; } // UUIDv7
|
||||
[JsonPropertyName("ticketId")] public string? TicketId { get; init; }
|
||||
[JsonPropertyName("sessionId")] public string? SessionId { get; init; }
|
||||
[JsonPropertyName("pathId")] public string? PathId { get; init; }
|
||||
[JsonPropertyName("category")] public string Category { get; init; } = "LOCATE";
|
||||
[JsonPropertyName("origin")] public PointOrigin Origin { get; init; } = PointOrigin.APP;
|
||||
[JsonPropertyName("originClientId")] public string? OriginClientId { get; init; }
|
||||
[JsonPropertyName("uploadPath")] public UploadPath UploadPath { get; init; } = UploadPath.APP_MQTT;
|
||||
[JsonPropertyName("captureTrigger")] public CaptureTrigger CaptureTrigger { get; init; } = CaptureTrigger.LOCATOR_BUTTON;
|
||||
[JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; init; }
|
||||
[JsonPropertyName("author")] public string? Author { get; init; }
|
||||
[JsonPropertyName("appVersion")] public string? AppVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("position")] public PositionGroup? Position { get; init; }
|
||||
[JsonPropertyName("gnss")] public GnssGroup? Gnss { get; init; }
|
||||
[JsonPropertyName("locate")] public LocateGroup? Locate { get; init; }
|
||||
[JsonPropertyName("attributes")] public AttributesGroup? Attributes { get; init; }
|
||||
[JsonPropertyName("quality")] public QualityGroup? Quality { get; init; }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public byte[] ToJsonUtf8() => JsonSerializer.SerializeToUtf8Bytes(this, JsonOpts);
|
||||
public static PointRecord FromJsonUtf8(ReadOnlySpan<byte> utf8) =>
|
||||
JsonSerializer.Deserialize<PointRecord>(utf8, JsonOpts)
|
||||
?? throw new JsonException("null PointRecord");
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a singleton v1 app-log batch matching telemetry-schema.md's frozen MQTT
|
||||
/// profile. Throws a reason-coded exception instead of silently queuing an unusable point.
|
||||
/// </summary>
|
||||
public byte[] ToAppLogPayloadUtf8(string? jobId = null, string? ticket = null)
|
||||
{
|
||||
string? resolvedJobId = string.IsNullOrWhiteSpace(jobId) ? TicketId : jobId;
|
||||
bool hasJob = !string.IsNullOrWhiteSpace(resolvedJobId);
|
||||
bool hasTicket = !string.IsNullOrWhiteSpace(ticket);
|
||||
if (hasJob == hasTicket)
|
||||
throw new PointNotPublishableException("JOB_IDENTITY_INVALID", "Exactly one of jobId or ticket is required.");
|
||||
if (!IsUuidV7(PointId))
|
||||
throw new PointNotPublishableException("POINT_ID_NOT_UUIDV7", "pointId must be a UUIDv7 value.");
|
||||
if (CreatedAt == default)
|
||||
throw new PointNotPublishableException("CREATED_AT_MISSING", "createdAt is required.");
|
||||
if (Position?.Lat is not double lat || Position.Lon is not double lng)
|
||||
throw new PointNotPublishableException("POSITION_MISSING", "Latitude and longitude are required.");
|
||||
|
||||
var wirePoint = new AppLogPointWire
|
||||
{
|
||||
PointId = PointId,
|
||||
CreatedAt = CreatedAt,
|
||||
Origin = "APP",
|
||||
UploadPath = "APP_MQTT",
|
||||
Lat = lat,
|
||||
Lng = lng,
|
||||
Alt = Position.EllipsoidalHeight,
|
||||
Ts = Position.PositionEpoch ?? Locate?.TelemetryEpoch ?? CreatedAt,
|
||||
Fix = NormalizeFix(Gnss?.FixType),
|
||||
HAcc = Gnss?.Hrms,
|
||||
VAcc = Gnss?.Vrms,
|
||||
Sats = Gnss?.SatsUsed,
|
||||
Hdop = Gnss?.Hdop,
|
||||
Depth = Locate?.Depth,
|
||||
FreqHz = Locate?.Frequency is double frequency ? checked((int)Math.Round(frequency)) : null,
|
||||
CurrentMa = Locate?.SignalCurrent,
|
||||
SignalDb = Locate?.SignalStrength,
|
||||
GainDb = Locate?.Gain,
|
||||
Mode = Locate?.LocateMode,
|
||||
PhaseDeg = Locate?.PhaseDegrees,
|
||||
CompassDeg = Locate?.CompassDegrees,
|
||||
DistortionPct = Locate?.DistortionPercent,
|
||||
Utility = NormalizeUtility(Attributes?.UtilityType),
|
||||
QualityFlag = Quality?.QualityFlag ?? "IN_SPEC",
|
||||
};
|
||||
var envelope = new AppLogPointsEnvelope
|
||||
{
|
||||
SchemaVersion = "1",
|
||||
JobId = hasJob ? resolvedJobId : null,
|
||||
Ticket = hasTicket ? ticket : null,
|
||||
Points = [wirePoint],
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(envelope, AppLogJsonOptions);
|
||||
}
|
||||
|
||||
private static bool IsUuidV7(string value)
|
||||
{
|
||||
string text = value.ToLowerInvariant();
|
||||
return Guid.TryParseExact(text, "D", out _)
|
||||
&& text.Length == 36
|
||||
&& text[14] == '7'
|
||||
&& text[19] is '8' or '9' or 'a' or 'b';
|
||||
}
|
||||
|
||||
private static string? NormalizeFix(string? value) => value?.ToUpperInvariant() switch
|
||||
{
|
||||
null or "" => null,
|
||||
"AUTONOMOUS" => "AUTONOMOUS",
|
||||
"DGPS" => "DGPS",
|
||||
"FLOAT" or "RTK_FLOAT" or "FLOAT_RTK" => "FLOAT",
|
||||
"FIXED" or "RTK_FIXED" or "FIXED_RTK" => "FIXED",
|
||||
"NO_FIX" or "NONE" => "NO_FIX",
|
||||
_ => throw new PointNotPublishableException("FIX_TYPE_INVALID", $"Unsupported fix type '{value}'."),
|
||||
};
|
||||
|
||||
private static string? NormalizeUtility(string? value) => value?.ToUpperInvariant() switch
|
||||
{
|
||||
null or "" => null,
|
||||
"ELECTRIC" or "GAS" or "WATER" or "SEWER" or "TELECOM" or "CATV" or "FIBER" or "STEAM" or "UNKNOWN"
|
||||
=> value.ToUpperInvariant(),
|
||||
_ => throw new PointNotPublishableException("UTILITY_TYPE_INVALID", $"Unsupported utility type '{value}'."),
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions AppLogJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class PointNotPublishableException : InvalidOperationException
|
||||
{
|
||||
public string ReasonCode { get; }
|
||||
|
||||
public PointNotPublishableException(string reasonCode, string message) : base(message) =>
|
||||
ReasonCode = reasonCode;
|
||||
}
|
||||
|
||||
internal sealed record AppLogPointsEnvelope
|
||||
{
|
||||
public string SchemaVersion { get; init; } = "1";
|
||||
public string? JobId { get; init; }
|
||||
public string? Ticket { get; init; }
|
||||
public List<AppLogPointWire> Points { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record AppLogPointWire
|
||||
{
|
||||
public required string PointId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string Origin { get; init; } = "APP";
|
||||
public string UploadPath { get; init; } = "APP_MQTT";
|
||||
public required double Lat { get; init; }
|
||||
public required double Lng { get; init; }
|
||||
public double? Alt { get; init; }
|
||||
public required DateTimeOffset Ts { get; init; }
|
||||
public string? Fix { get; init; }
|
||||
public double? HAcc { get; init; }
|
||||
public double? VAcc { get; init; }
|
||||
public int? Sats { get; init; }
|
||||
public double? Hdop { get; init; }
|
||||
public double? Depth { get; init; }
|
||||
public int? FreqHz { get; init; }
|
||||
public double? CurrentMa { get; init; }
|
||||
public double? SignalDb { get; init; }
|
||||
public double? GainDb { get; init; }
|
||||
public string? Mode { get; init; }
|
||||
public double? PhaseDeg { get; init; }
|
||||
public double? CompassDeg { get; init; }
|
||||
public double? DistortionPct { get; init; }
|
||||
public string? Utility { get; init; }
|
||||
public string QualityFlag { get; init; } = "IN_SPEC";
|
||||
}
|
||||
|
||||
public sealed record PositionGroup
|
||||
{
|
||||
[JsonPropertyName("lat")] public double? Lat { get; init; }
|
||||
[JsonPropertyName("lon")] public double? Lon { get; init; }
|
||||
[JsonPropertyName("crsEpsg")] public int? CrsEpsg { get; init; }
|
||||
[JsonPropertyName("ellipsoidalHeight")] public double? EllipsoidalHeight { get; init; }
|
||||
[JsonPropertyName("orthometricHeight")] public double? OrthometricHeight { get; init; }
|
||||
[JsonPropertyName("geoidModel")] public string? GeoidModel { get; init; }
|
||||
[JsonPropertyName("antennaHeight")] public double? AntennaHeight { get; init; }
|
||||
[JsonPropertyName("positionEpoch")] public DateTimeOffset? PositionEpoch { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GnssGroup
|
||||
{
|
||||
[JsonPropertyName("fixType")] public string? FixType { get; init; }
|
||||
[JsonPropertyName("satsUsed")] public int? SatsUsed { get; init; }
|
||||
[JsonPropertyName("hdop")] public double? Hdop { get; init; }
|
||||
[JsonPropertyName("hrms")] public double? Hrms { get; init; }
|
||||
[JsonPropertyName("vrms")] public double? Vrms { get; init; }
|
||||
[JsonPropertyName("correctionAge")] public double? CorrectionAge { get; init; }
|
||||
[JsonPropertyName("receiverModel")] public string? ReceiverModel { get; init; }
|
||||
[JsonPropertyName("receiverSerial")] public string? ReceiverSerial { get; init; }
|
||||
[JsonPropertyName("source")] public string? Source { get; init; } // MAGLINK | PHONE
|
||||
[JsonPropertyName("tiltAngle")] public double? TiltAngle { get; init; }
|
||||
[JsonPropertyName("clockSource")] public string? ClockSource { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LocateGroup
|
||||
{
|
||||
[JsonPropertyName("depth")] public double? Depth { get; init; }
|
||||
[JsonPropertyName("depthUnits")] public string? DepthUnits { get; init; }
|
||||
[JsonPropertyName("signalCurrent")] public double? SignalCurrent { get; init; }
|
||||
[JsonPropertyName("signalStrength")] public double? SignalStrength { get; init; }
|
||||
[JsonPropertyName("frequency")] public double? Frequency { get; init; }
|
||||
[JsonPropertyName("locateMode")] public string? LocateMode { get; init; }
|
||||
[JsonPropertyName("gain")] public double? Gain { get; init; }
|
||||
[JsonPropertyName("signalDirection")] public double? SignalDirection { get; init; }
|
||||
[JsonPropertyName("phaseDegrees")] public double? PhaseDegrees { get; init; }
|
||||
[JsonPropertyName("compassDegrees")] public double? CompassDegrees { get; init; }
|
||||
[JsonPropertyName("distortionPercent")] public double? DistortionPercent { get; init; }
|
||||
[JsonPropertyName("warningFlags")] public int? WarningFlags { get; init; }
|
||||
[JsonPropertyName("locatorModel")] public string? LocatorModel { get; init; }
|
||||
[JsonPropertyName("locatorSerial")] public string? LocatorSerial { get; init; }
|
||||
[JsonPropertyName("telemetryEpoch")] public DateTimeOffset? TelemetryEpoch { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AttributesGroup
|
||||
{
|
||||
[JsonPropertyName("utilityType")] public string? UtilityType { get; init; }
|
||||
[JsonPropertyName("owner")] public string? Owner { get; init; }
|
||||
[JsonPropertyName("markerColor")] public string? MarkerColor { get; init; }
|
||||
[JsonPropertyName("surfaceType")] public string? SurfaceType { get; init; }
|
||||
[JsonPropertyName("notes")] public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record QualityGroup
|
||||
{
|
||||
[JsonPropertyName("qualityFlag")] public string? QualityFlag { get; init; }
|
||||
[JsonPropertyName("gatePolicyId")] public string? GatePolicyId { get; init; }
|
||||
[JsonPropertyName("waiverId")] public string? WaiverId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": "1",
|
||||
"jobId": "job_1",
|
||||
"points": [
|
||||
{
|
||||
"pointId": "018f1a00-0000-7000-8000-000000000001",
|
||||
"createdAt": "2026-08-21T10:00:00+00:00",
|
||||
"origin": "APP",
|
||||
"uploadPath": "APP_MQTT",
|
||||
"lat": 40.1,
|
||||
"lng": -80.2,
|
||||
"ts": "2026-08-21T09:59:59+00:00",
|
||||
"fix": "FIXED",
|
||||
"hAcc": 0.02,
|
||||
"vAcc": 0.04,
|
||||
"sats": 18,
|
||||
"utility": "WATER",
|
||||
"qualityFlag": "IN_SPEC"
|
||||
}
|
||||
]
|
||||
}
|
||||
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
61
tests/FieldLogger.Sync.Tests/FakeMqttTransport.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using FieldLogger.Sync;
|
||||
|
||||
namespace FieldLogger.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory stand-in for the broker. Records published payloads, lets a test toggle
|
||||
/// connectivity, force publish failures, and inject application-level acks — so the engine's
|
||||
/// durability/ack/backoff behaviour is exercised without MQTTnet or a live broker.
|
||||
/// </summary>
|
||||
public sealed class FakeMqttTransport : IMqttTransport
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public bool FailNextPublish { get; set; }
|
||||
public List<(string Topic, byte[] Payload)> Published { get; } = new();
|
||||
|
||||
public event Func<AckBatch, Task>? AckReceived;
|
||||
|
||||
public Task ConnectAsync(string ackTopic, CancellationToken ct = default)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishAsync(string topic, byte[] payload, string schemaVersion, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected) throw new InvalidOperationException("not connected");
|
||||
if (FailNextPublish)
|
||||
{
|
||||
FailNextPublish = false;
|
||||
throw new InvalidOperationException("simulated broker publish failure");
|
||||
}
|
||||
Published.Add((topic, payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Simulate the cloud emitting an application-level ack for these pointIds.</summary>
|
||||
public async Task InjectAckAsync(params AckItem[] items)
|
||||
{
|
||||
if (AckReceived is { } handlers)
|
||||
{
|
||||
var batch = new AckBatch { Results = items.ToList() };
|
||||
foreach (Func<AckBatch, Task> handler in handlers.GetInvocationList())
|
||||
await handler(batch);
|
||||
}
|
||||
}
|
||||
|
||||
public Task InjectAcceptAsync(string pointId) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.ACCEPTED });
|
||||
|
||||
public Task InjectDuplicateAsync(string pointId) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.DUPLICATE });
|
||||
|
||||
public Task InjectRejectAsync(string pointId, string reasonCode) =>
|
||||
InjectAckAsync(new AckItem { PointId = pointId, Outcome = AckOutcome.REJECTED, ReasonCode = reasonCode });
|
||||
}
|
||||
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
35
tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Headless tests for the cloud-sync core (S2-b/S2-c): durable-queue restart survival,
|
||||
ack-release, exactly-once, backoff, broker-loss-safety, and LOG-7 no-silent-discard.
|
||||
Plain net9.0 + xUnit so it runs in the QA gate (auto-discovered *.Tests.csproj) without
|
||||
MAUI workloads or a live broker (uses a fake IMqttTransport).
|
||||
Run: dotnet test tests/FieldLogger.Sync.Tests/FieldLogger.Sync.Tests.csproj
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\FieldLogger.Sync\FieldLogger.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="ContractFixtures\app-log-*.json"
|
||||
Link="ContractFixtures\%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
173
tests/FieldLogger.Sync.Tests/SyncEngineTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FieldLogger.Sync;
|
||||
using Xunit;
|
||||
|
||||
namespace FieldLogger.Sync.Tests;
|
||||
|
||||
public sealed class SyncEngineTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-08-21T10:00:00Z");
|
||||
|
||||
static SyncEngineTests() => SQLitePCL.Batteries_V2.Init();
|
||||
|
||||
[Fact]
|
||||
public void Point_serializes_to_the_frozen_singleton_batch()
|
||||
{
|
||||
var point = MakePoint("018f1a00-0000-7000-8000-000000000001");
|
||||
using var json = JsonDocument.Parse(point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
|
||||
var root = json.RootElement;
|
||||
Assert.Equal("1", root.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal("job_1", root.GetProperty("jobId").GetString());
|
||||
var wire = Assert.Single(root.GetProperty("points").EnumerateArray());
|
||||
Assert.Equal(point.PointId, wire.GetProperty("pointId").GetString());
|
||||
Assert.Equal("APP", wire.GetProperty("origin").GetString());
|
||||
Assert.Equal("APP_MQTT", wire.GetProperty("uploadPath").GetString());
|
||||
Assert.Equal("FIXED", wire.GetProperty("fix").GetString());
|
||||
Assert.Equal(-80.2, wire.GetProperty("lng").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void App_serializer_matches_the_shared_contract_fixture()
|
||||
{
|
||||
JsonNode? actual = JsonNode.Parse(MakePoint("018f1a00-0000-7000-8000-000000000001")
|
||||
.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
string path = Path.Combine(AppContext.BaseDirectory, "ContractFixtures", "app-log-points-v1.json");
|
||||
JsonNode? expected = JsonNode.Parse(File.ReadAllText(path));
|
||||
|
||||
Assert.True(JsonNode.DeepEquals(expected, actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_capture_is_refused_with_a_reason_code()
|
||||
{
|
||||
var point = MakePoint("018f1a00-0000-7000-8000-000000000002") with { Position = null };
|
||||
var error = Assert.Throws<PointNotPublishableException>(() => point.ToAppLogPayloadUtf8(jobId: "job_1"));
|
||||
Assert.Equal("POSITION_MISSING", error.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_survives_restart_and_releases_only_after_acceptance()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var firstStore = new SqliteOutboundStore(db);
|
||||
await firstStore.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000003"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
Assert.True(await firstStore.EnqueueAsync(message));
|
||||
|
||||
var firstTransport = new FakeMqttTransport();
|
||||
var firstEngine = Engine(firstTransport, firstStore);
|
||||
Assert.Equal(1, await firstEngine.DrainOnceAsync());
|
||||
Assert.Equal(1, await firstStore.PendingCountAsync()); // broker PUBACK is not enough
|
||||
|
||||
var restartedStore = new SqliteOutboundStore(db);
|
||||
await restartedStore.InitAsync(); // resets interrupted in-flight work to pending
|
||||
var restartedTransport = new FakeMqttTransport();
|
||||
var restartedEngine = Engine(restartedTransport, restartedStore);
|
||||
Assert.Equal(1, await restartedEngine.DrainOnceAsync());
|
||||
await restartedTransport.InjectAcceptAsync(message.PointId);
|
||||
|
||||
Assert.Equal(0, await restartedStore.PendingCountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_ack_is_safe_to_release_and_publish_failure_is_not()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var store = new SqliteOutboundStore(db);
|
||||
await store.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000004"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
Assert.True(await store.EnqueueAsync(message));
|
||||
|
||||
var transport = new FakeMqttTransport { FailNextPublish = true };
|
||||
var engine = Engine(transport, store);
|
||||
Assert.Equal(0, await engine.DrainOnceAsync());
|
||||
Assert.Equal(1, await store.PendingCountAsync());
|
||||
|
||||
await transport.InjectDuplicateAsync(message.PointId);
|
||||
Assert.Equal(0, await store.PendingCountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_rejection_is_retained_and_surfaced()
|
||||
{
|
||||
string db = NewDbPath();
|
||||
try
|
||||
{
|
||||
var store = new SqliteOutboundStore(db);
|
||||
await store.InitAsync();
|
||||
var message = OutboundMessage.FromPoint(
|
||||
MakePoint("018f1a00-0000-7000-8000-000000000005"),
|
||||
"ul/org_alpha/app/client_1/log/points", Now, jobId: "job_1");
|
||||
await store.EnqueueAsync(message);
|
||||
var transport = new FakeMqttTransport();
|
||||
var engine = Engine(transport, store);
|
||||
OutboundMessage? surfaced = null;
|
||||
engine.PointRejected += value =>
|
||||
{
|
||||
surfaced = value;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await transport.InjectRejectAsync(message.PointId, "VALIDATION_ERROR");
|
||||
|
||||
Assert.Equal(0, await store.PendingCountAsync());
|
||||
Assert.Equal(message.PointId, surfaced?.PointId);
|
||||
Assert.Equal("VALIDATION_ERROR", Assert.Single(await store.GetRejectedAsync()).LastReason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDb(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ack_parser_uses_results_and_outcome()
|
||||
{
|
||||
var ack = AckBatch.FromJsonUtf8(
|
||||
"""{"schemaVersion":"1","results":[{"pointId":"018f1a00-0000-7000-8000-000000000006","outcome":"DUPLICATE"}]}"""u8);
|
||||
Assert.Equal(AckOutcome.DUPLICATE, Assert.Single(ack.Results).Outcome);
|
||||
}
|
||||
|
||||
private static MqttSyncEngine Engine(FakeMqttTransport transport, IOutboundStore store) =>
|
||||
new(transport, store, new SyncOptions { OrgId = "org_alpha", ClientId = "client_1" },
|
||||
() => Now, new BackoffPolicy(TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1), new Random(1)));
|
||||
|
||||
private static PointRecord MakePoint(string id) => new()
|
||||
{
|
||||
PointId = id,
|
||||
CreatedAt = Now,
|
||||
Position = new PositionGroup { Lat = 40.1, Lon = -80.2, PositionEpoch = Now.AddSeconds(-1) },
|
||||
Gnss = new GnssGroup { FixType = "RTK_FIXED", SatsUsed = 18, Hrms = 0.02, Vrms = 0.04 },
|
||||
Attributes = new AttributesGroup { UtilityType = "WATER" },
|
||||
};
|
||||
|
||||
private static string NewDbPath() => Path.Combine(Path.GetTempPath(), $"fieldlogger-sync-{Guid.NewGuid():N}.db3");
|
||||
|
||||
private static void DeleteDb(string db)
|
||||
{
|
||||
foreach (string path in new[] { db, $"{db}-shm", $"{db}-wal" })
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
tests/FieldLogger.Tests/FieldLogger.Tests.csproj
Normal file
28
tests/FieldLogger.Tests/FieldLogger.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
QA smoke/regression harness for the UM Trace app (S1-f, trace: SRS §6 gate / NFR-8).
|
||||
|
||||
Targets plain net9.0 (NOT the MAUI platform TFMs) so `dotnet test` runs on any dev/CI
|
||||
machine without the MAUI workload, keeping the QA gate fast. It deliberately does NOT
|
||||
reference FieldLogger.csproj yet, because that project pulls in MAUI/platform deps.
|
||||
|
||||
When app logic that needs testing (e.g. the S1-b IF-LOC codec / locator simulator) is
|
||||
factored into a plain .NET class library, add a ProjectReference to it here and the
|
||||
round-trip tests move in. Coordinated with app-owner.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
24
tests/FieldLogger.Tests/SmokeTests.cs
Normal file
24
tests/FieldLogger.Tests/SmokeTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Xunit;
|
||||
|
||||
namespace FieldLogger.Tests;
|
||||
|
||||
// Smoke test for the QA gate (S1-f, trace: SRS §6 gate / NFR-8).
|
||||
// Proves the .NET test toolchain (dotnet test + xUnit) is wired and green in the app repo.
|
||||
// Substantive coverage (e.g. the S1-b IF-LOC codec round-trip) is added once the app's
|
||||
// pure logic is factored into a workload-free library this project can reference.
|
||||
public class SmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Harness_Runs()
|
||||
{
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1, 2)]
|
||||
[InlineData(2, 3, 5)]
|
||||
public void Arithmetic_Sanity(int a, int b, int expected)
|
||||
{
|
||||
Assert.Equal(expected, a + b);
|
||||
}
|
||||
}
|
||||
206
tests/FieldLogger.Tests/Sprint3UiContractTests.cs
Normal file
206
tests/FieldLogger.Tests/Sprint3UiContractTests.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace FieldLogger.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless guardrails for the Sprint 3 MAUI screen integration. These deliberately inspect
|
||||
/// XAML source so the QA gate can verify token use and accessibility basics without a MAUI
|
||||
/// simulator or device workload.
|
||||
/// </summary>
|
||||
public sealed class Sprint3UiContractTests
|
||||
{
|
||||
private static readonly string[] MigratedScreens =
|
||||
[
|
||||
"FieldLogger/Views/HomePage.xaml",
|
||||
"FieldLogger/Views/JobsPage.xaml",
|
||||
"FieldLogger/Views/JobDetailPage.xaml",
|
||||
"FieldLogger/Views/MapPage.xaml",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Migrated_screens_use_only_semantic_color_resources()
|
||||
{
|
||||
var hardCodedColor = new Regex(@"#[0-9a-fA-F]{3,8}\b", RegexOptions.CultureInvariant);
|
||||
foreach (var path in MigratedScreens)
|
||||
{
|
||||
var text = File.ReadAllText(FromRoot(path));
|
||||
Assert.False(hardCodedColor.IsMatch(text), $"Page-level color literal found in {path}");
|
||||
Assert.DoesNotContain("TextColor=\"Gray\"", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("DodgerBlue", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("IndianRed", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_referenced_Survey_Teal_token_exists_in_generated_resources()
|
||||
{
|
||||
var resourceText = string.Concat(
|
||||
File.ReadAllText(FromRoot("FieldLogger/Resources/Styles/GeneratedTokens.xaml")),
|
||||
File.ReadAllText(FromRoot("FieldLogger/Resources/Styles/Styles.xaml")));
|
||||
var defined = Regex.Matches(resourceText, "x:Key=\\\"(?<key>Ul[^\\\"]+)\\\"")
|
||||
.Select(match => match.Groups["key"].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var resourceReference = new Regex(@"\{(?:Static|Dynamic)Resource (?<key>Ul[^}\s]+)\}");
|
||||
|
||||
foreach (var path in MigratedScreens.Append("FieldLogger/AppShell.xaml"))
|
||||
{
|
||||
foreach (Match match in resourceReference.Matches(File.ReadAllText(FromRoot(path))))
|
||||
{
|
||||
var key = match.Groups["key"].Value;
|
||||
Assert.Contains(key, defined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void App_loads_generated_tokens_before_shared_component_styles()
|
||||
{
|
||||
var app = File.ReadAllText(FromRoot("FieldLogger/App.xaml"));
|
||||
var generatedIndex = app.IndexOf("GeneratedTokens.xaml", StringComparison.Ordinal);
|
||||
var stylesIndex = app.IndexOf("Styles.xaml", StringComparison.Ordinal);
|
||||
Assert.True(generatedIndex >= 0, "App.xaml must merge GeneratedTokens.xaml.");
|
||||
Assert.True(stylesIndex > generatedIndex, "Generated tokens must load before component styles.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Migrated_screens_consume_shared_component_styles()
|
||||
{
|
||||
var migrated = string.Concat(MigratedScreens.Select(path => File.ReadAllText(FromRoot(path))));
|
||||
Assert.Contains("UlCard", migrated, StringComparison.Ordinal);
|
||||
Assert.Contains("UlJobRow", migrated, StringComparison.Ordinal);
|
||||
Assert.Contains("UlStatusBadge", migrated, StringComparison.Ordinal);
|
||||
Assert.Contains("UlButtonSecondary", migrated, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_page_button_declares_at_least_the_minimum_touch_target()
|
||||
{
|
||||
var accepted = new[]
|
||||
{
|
||||
"{StaticResource UlTouchMinimum}",
|
||||
"{StaticResource UlTouchComfortable}",
|
||||
"{StaticResource UlTouchPrimary}",
|
||||
};
|
||||
|
||||
foreach (var path in MigratedScreens)
|
||||
{
|
||||
var document = XDocument.Load(FromRoot(path));
|
||||
foreach (var button in document.Descendants().Where(e => e.Name.LocalName == "Button"))
|
||||
{
|
||||
var target = button.Attribute("MinimumHeightRequest")?.Value;
|
||||
Assert.Contains(target, accepted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_shell_does_not_expose_Debug_as_a_primary_tab()
|
||||
{
|
||||
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
|
||||
Assert.DoesNotContain("Title=\"Debug\"", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("views:DebugPage", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("<TabBar", shell, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Title=\"Home\"", shell, StringComparison.Ordinal);
|
||||
Assert.True(shell.IndexOf("Title=\"Jobs\"", StringComparison.Ordinal) <
|
||||
shell.IndexOf("Title=\"Map\"", StringComparison.Ordinal),
|
||||
"Jobs must be the first primary route so it is selected at startup.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Home_explains_real_receiver_capture_without_inventing_an_app_command()
|
||||
{
|
||||
var home = File.ReadAllText(FromRoot("FieldLogger/Views/HomePage.xaml"));
|
||||
var state = File.ReadAllText(FromRoot("FieldLogger/ViewModels/HomeViewModel.cs"));
|
||||
Assert.Contains("CAPTURE FROM RECEIVER", home, StringComparison.Ordinal);
|
||||
Assert.Contains("Press LOG on the locating receiver", state, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("CaptureCommand", home, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Primary_pages_use_status_chrome_and_floating_menu()
|
||||
{
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"FieldLogger/Views/JobsPage.xaml",
|
||||
"FieldLogger/Views/MapPage.xaml",
|
||||
"FieldLogger/Views/SettingsPage.xaml",
|
||||
})
|
||||
{
|
||||
var page = File.ReadAllText(FromRoot(path));
|
||||
Assert.Contains("controls:AppStatusBar", page, StringComparison.Ordinal);
|
||||
Assert.Contains("controls:FloatingMenuButton", page, StringComparison.Ordinal);
|
||||
Assert.Contains("Shell.NavBarIsVisible=\"False\"", page, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_hides_manual_map_and_broker_configuration()
|
||||
{
|
||||
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
|
||||
Assert.DoesNotContain("Google Maps API Key", settings, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Mqtt", settings, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("Durable cloud sync", settings, StringComparison.Ordinal);
|
||||
Assert.Contains("OpenDebugCommand", settings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_has_no_heading_panel_and_fills_its_root_grid()
|
||||
{
|
||||
var map = File.ReadAllText(FromRoot("FieldLogger/Views/MapPage.xaml"));
|
||||
Assert.DoesNotContain("LIVE MAP", map, StringComparison.Ordinal);
|
||||
Assert.Contains("<ContentView x:Name=\"MapHost\"", map, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_facing_app_name_is_UM_Trace()
|
||||
{
|
||||
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
|
||||
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
|
||||
Assert.Contains("Title=\"UM Trace\"", shell, StringComparison.Ordinal);
|
||||
Assert.Contains("UM Trace v", settings, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Jobs_uses_a_stable_full_height_refresh_layout()
|
||||
{
|
||||
var page = File.ReadAllText(FromRoot("FieldLogger/Views/JobsPage.xaml"));
|
||||
var viewModel = File.ReadAllText(FromRoot("FieldLogger/ViewModels/JobsViewModel.cs"));
|
||||
|
||||
Assert.Contains("IsRefreshing=\"{Binding IsRefreshing, Mode=OneWay}\"", page, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("IsRefreshing=\"{Binding IsBusy}\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ItemsUpdatingScrollMode=\"KeepItemsInView\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("HorizontalOptions=\"Fill\" VerticalOptions=\"Fill\"", page, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Padding=\"16,16,16,88\"", page, StringComparison.Ordinal);
|
||||
Assert.Contains("ApplySnapshot(items)", viewModel, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Jobs.Clear()", viewModel, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_deploy_rebuilds_Xaml_and_brand_assets_from_a_clean_iOS_output()
|
||||
{
|
||||
var script = File.ReadAllText(FromRoot("scripts/deploy-ios-device.sh"));
|
||||
var cleanIndex = script.IndexOf("\"${dotnet_bin}\" clean", StringComparison.Ordinal);
|
||||
var runIndex = script.IndexOf("\"${dotnet_bin}\" build", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(cleanIndex >= 0, "Device deployment must clean stale partial MAUI outputs.");
|
||||
Assert.True(runIndex > cleanIndex, "The clean must run before the device build.");
|
||||
Assert.Contains("-p:RuntimeIdentifier=ios-arm64", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FromRoot(string relativePath) => Path.Combine(FindRepositoryRoot(), relativePath);
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
directory is not null;
|
||||
directory = directory.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "FieldLogger.sln")))
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find the FieldLogger repository root.");
|
||||
}
|
||||
}
|
||||
158
tests/IfLoc.Sim.Tests/BoundaryTests.cs
Normal file
158
tests/IfLoc.Sim.Tests/BoundaryTests.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using IfLoc.Sim;
|
||||
using Xunit;
|
||||
|
||||
namespace IfLoc.Sim.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QA-owned boundary/edge-case vectors for the frozen IF-LOC v1.0 codec (S1-b, SRS §3.1).
|
||||
/// Complements <see cref="RoundTripTests"/> (nominal fidelity + capture round-trip) by
|
||||
/// pinning the min/max/resolution limits of each wire field via the pure
|
||||
/// EncodePayload()/EncodeFrame()/DecodeFrame() seam. Values are chosen to stay clear of the
|
||||
/// no-value sentinels; the exact sentinel-collision boundaries are characterised at the
|
||||
/// bottom so the contract's documented ranges are enforceable, not just implied.
|
||||
/// </summary>
|
||||
public class BoundaryTests
|
||||
{
|
||||
// --- Telemetry: depth (int16 centimetres, 0.01 m resolution, sentinel 0x8000) --------
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.01)] // one resolution step
|
||||
[InlineData(-0.01)]
|
||||
[InlineData(12.34)]
|
||||
[InlineData(327.67)] // max representable magnitude (32767 cm)
|
||||
[InlineData(-327.67)] // min valid magnitude (-32767 cm); -327.68 would alias the sentinel
|
||||
public void Depth_roundtrips_at_range_and_resolution_limits(double meters)
|
||||
{
|
||||
var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = meters }.EncodePayload());
|
||||
Assert.NotNull(back.DepthMeters);
|
||||
Assert.Equal(meters, back.DepthMeters!.Value, 2);
|
||||
}
|
||||
|
||||
// --- Telemetry: signal current (uint16 mA, sentinel 0xFFFF) ---------------------------
|
||||
[Theory]
|
||||
[InlineData((ushort)0)]
|
||||
[InlineData((ushort)1)]
|
||||
[InlineData((ushort)65534)] // max valid; 65535 == CurrentInvalid sentinel
|
||||
public void SignalCurrent_roundtrips_to_max_valid(ushort mA)
|
||||
{
|
||||
var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = mA }.EncodePayload());
|
||||
Assert.Equal(mA, back.SignalCurrentMa);
|
||||
}
|
||||
|
||||
// --- Telemetry: frequency (uint32 Hz, sentinel 0xFFFFFFFF) ----------------------------
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(82_500u)]
|
||||
[InlineData(4_294_967_294u)] // 0xFFFFFFFE — max valid; 0xFFFFFFFF is n/a sentinel
|
||||
public void Frequency_roundtrips_to_max_valid(uint hz)
|
||||
{
|
||||
var back = Telemetry.DecodePayload(new Telemetry { FrequencyHz = hz }.EncodePayload());
|
||||
Assert.Equal(hz, back.FrequencyHz);
|
||||
}
|
||||
|
||||
// --- Telemetry: guidance offset (int16, documented range -1000..+1000; sentinel 0x8000) --
|
||||
[Theory]
|
||||
[InlineData((short)-1000)]
|
||||
[InlineData((short)-1)]
|
||||
[InlineData((short)0)]
|
||||
[InlineData((short)1)]
|
||||
[InlineData((short)1000)]
|
||||
[InlineData((short)-32767)] // wire minimum (just above the sentinel)
|
||||
[InlineData((short)32767)] // wire maximum
|
||||
public void GuidanceOffset_roundtrips_across_range(short offset)
|
||||
{
|
||||
var back = Telemetry.DecodePayload(new Telemetry { GuidanceOffset = offset }.EncodePayload());
|
||||
Assert.Equal(offset, back.GuidanceOffset);
|
||||
}
|
||||
|
||||
// --- Telemetry: single-byte fields at 0 and 0xFF -------------------------------------
|
||||
[Theory]
|
||||
[InlineData((byte)0)]
|
||||
[InlineData((byte)100)]
|
||||
[InlineData((byte)255)]
|
||||
public void Byte_fields_roundtrip_at_extremes(byte v)
|
||||
{
|
||||
var t = new Telemetry
|
||||
{
|
||||
GainDb = v, SignalLevel = v, DistortionQualityPct = v,
|
||||
SignalDirection = v, CompassAngleDeg = v, BatteryPercent = v,
|
||||
};
|
||||
var back = Telemetry.DecodePayload(t.EncodePayload());
|
||||
Assert.Equal(v, back.GainDb);
|
||||
Assert.Equal(v, back.SignalLevel);
|
||||
Assert.Equal(v, back.DistortionQualityPct);
|
||||
Assert.Equal(v, back.SignalDirection);
|
||||
Assert.Equal(v, back.CompassAngleDeg);
|
||||
Assert.Equal(v, back.BatteryPercent);
|
||||
}
|
||||
|
||||
// --- Telemetry: full bitfields set ---------------------------------------------------
|
||||
[Fact]
|
||||
public void All_warning_and_status_flags_roundtrip()
|
||||
{
|
||||
var allWarn = WarningFlags.Shallow | WarningFlags.Overload | WarningFlags.SwingTilt
|
||||
| WarningFlags.DepthInvalid | WarningFlags.CurrentInvalid | WarningFlags.OutOfRange
|
||||
| WarningFlags.DistortionHigh | WarningFlags.LowBattery;
|
||||
var allStatus = StatusFlags.Locating | StatusFlags.MenuActive
|
||||
| StatusFlags.TimeSynced | StatusFlags.DepthModeAuto;
|
||||
var back = Telemetry.DecodePayload(
|
||||
new Telemetry { Warnings = allWarn, Status = allStatus }.EncodePayload());
|
||||
Assert.Equal(allWarn, back.Warnings);
|
||||
Assert.Equal(allStatus, back.Status);
|
||||
}
|
||||
|
||||
// --- CaptureResult: WGS84 position extremes (int32 * 1e7) -----------------------------
|
||||
[Theory]
|
||||
[InlineData(90.0, 180.0)]
|
||||
[InlineData(-90.0, -180.0)]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(45.7649321, 4.8354792)]
|
||||
public void CaptureResult_position_roundtrips_at_wgs84_extremes(double lat, double lon)
|
||||
{
|
||||
var r = new CaptureResult
|
||||
{
|
||||
Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed,
|
||||
Lat = lat, Lon = lon, OrthometricHeightM = 0.0,
|
||||
};
|
||||
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
|
||||
Assert.Equal(lat, back.Lat!.Value, 7);
|
||||
Assert.Equal(lon, back.Lon!.Value, 7);
|
||||
}
|
||||
|
||||
// --- CaptureResult: RMS accuracy (uint16 mm, sentinel 0xFFFF) -------------------------
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.001)] // 1 mm resolution step
|
||||
[InlineData(65.534)] // 65534 mm — max valid; 65535 == RmsUnknown sentinel
|
||||
public void CaptureResult_rms_roundtrips_to_max_valid(double meters)
|
||||
{
|
||||
var r = new CaptureResult
|
||||
{
|
||||
Outcome = CaptureOutcome.Stored, Reason = ReasonCode.Ok, FixType = FixType.RtkFixed,
|
||||
Lat = 0, Lon = 0, OrthometricHeightM = 0, HrmsM = meters, VrmsM = meters,
|
||||
};
|
||||
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
|
||||
Assert.Equal(meters, back.HrmsM!.Value, 3);
|
||||
Assert.Equal(meters, back.VrmsM!.Value, 3);
|
||||
}
|
||||
|
||||
// --- Characterisation of the sentinel-collision boundaries ---------------------------
|
||||
// These pin the *edges* of the representable ranges so the frozen contract documents
|
||||
// them explicitly. A magnitude one step beyond the max valid value aliases the field's
|
||||
// no-value sentinel and therefore decodes to null — i.e. it is NOT representable. Flagged
|
||||
// to app-owner for the range notes in ble-device-interface.md (IF-LOC v1.0).
|
||||
[Fact]
|
||||
public void Depth_negative_full_scale_aliases_the_no_value_sentinel()
|
||||
{
|
||||
// -327.68 m -> -32768 cm == DepthNoValue (0x8000): not a representable depth.
|
||||
var back = Telemetry.DecodePayload(new Telemetry { DepthMeters = -327.68 }.EncodePayload());
|
||||
Assert.Null(back.DepthMeters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalCurrent_0xFFFF_aliases_the_invalid_sentinel()
|
||||
{
|
||||
var back = Telemetry.DecodePayload(new Telemetry { SignalCurrentMa = 0xFFFF }.EncodePayload());
|
||||
Assert.Null(back.SignalCurrentMa);
|
||||
}
|
||||
}
|
||||
27
tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
Normal file
27
tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Round-trip test for the frozen IF-LOC v1.0 contract — the mapped test for S1-b.
|
||||
Plain net9.0 + xUnit so `dotnet test` runs headless in CI without MAUI workloads.
|
||||
Run: dotnet test tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IfLoc.Sim\IfLoc.Sim.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal file
228
tests/IfLoc.Sim.Tests/RoundTripTests.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using IfLoc.Sim;
|
||||
using Xunit;
|
||||
|
||||
namespace IfLoc.Sim.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Mapped test for S1-b (Risk R-3, SRS §3.1). Verifies (1) every telemetry dictionary
|
||||
/// field survives a byte-level encode/decode with correct units and sentinels, and
|
||||
/// (2) the capture round-trip: every locator trigger yields exactly one correctly
|
||||
/// correlated capture-result and no capture is silently dropped (SRS-LOG-7).
|
||||
/// </summary>
|
||||
public class RoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
public void Telemetry_frame_is_exactly_32_bytes()
|
||||
{
|
||||
var frame = LocatorSimulator.BuildTelemetry(0, 0).EncodeFrame(0);
|
||||
Assert.Equal(IfLoc.TelemetryFrameLen, frame.Length);
|
||||
Assert.Equal(IfLoc.FrameVersion, frame[0]);
|
||||
Assert.Equal((byte)MessageType.Telemetry, frame[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Telemetry_roundtrips_every_field_with_units()
|
||||
{
|
||||
var t = new Telemetry
|
||||
{
|
||||
LocatorUptimeMs = 1_234_567,
|
||||
DepthMeters = 1.23, // 0.01 m resolution → 123 cm on wire
|
||||
SignalCurrentMa = 742,
|
||||
FrequencyHz = 82_500,
|
||||
Mode = LocateMode.TwinSweep,
|
||||
SignalType = SignalType.Sonde,
|
||||
GainDb = 137,
|
||||
SignalLevel = 88,
|
||||
DistortionQualityPct = 91,
|
||||
SignalDirection = 2,
|
||||
CompassAngleDeg = 174,
|
||||
GuidanceOffset = -375, // negative = left
|
||||
Warnings = WarningFlags.Shallow | WarningFlags.DistortionHigh,
|
||||
Utility = UtilityType.Gas,
|
||||
BatteryPercent = 64,
|
||||
Status = StatusFlags.Locating | StatusFlags.TimeSynced,
|
||||
};
|
||||
|
||||
var back = Telemetry.DecodePayload(t.EncodePayload());
|
||||
|
||||
Assert.Equal(t.LocatorUptimeMs, back.LocatorUptimeMs);
|
||||
Assert.Equal(1.23, back.DepthMeters!.Value, 3);
|
||||
Assert.Equal(t.SignalCurrentMa, back.SignalCurrentMa);
|
||||
Assert.Equal(t.FrequencyHz, back.FrequencyHz);
|
||||
Assert.Equal(t.Mode, back.Mode);
|
||||
Assert.Equal(t.SignalType, back.SignalType);
|
||||
Assert.Equal(t.GainDb, back.GainDb);
|
||||
Assert.Equal(t.SignalLevel, back.SignalLevel);
|
||||
Assert.Equal(t.DistortionQualityPct, back.DistortionQualityPct);
|
||||
Assert.Equal(t.SignalDirection, back.SignalDirection);
|
||||
Assert.Equal(t.CompassAngleDeg, back.CompassAngleDeg);
|
||||
Assert.Equal(t.GuidanceOffset, back.GuidanceOffset);
|
||||
Assert.Equal(t.Warnings, back.Warnings);
|
||||
Assert.Equal(t.Utility, back.Utility);
|
||||
Assert.Equal(t.BatteryPercent, back.BatteryPercent);
|
||||
Assert.Equal(t.Status, back.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Telemetry_sentinels_decode_to_null()
|
||||
{
|
||||
var t = new Telemetry
|
||||
{
|
||||
DepthMeters = null, // no depth
|
||||
SignalCurrentMa = null, // invalid current
|
||||
FrequencyHz = null, // n/a
|
||||
GuidanceOffset = null, // n/a
|
||||
};
|
||||
var back = Telemetry.DecodePayload(t.EncodePayload());
|
||||
Assert.Null(back.DepthMeters);
|
||||
Assert.Null(back.SignalCurrentMa);
|
||||
Assert.Null(back.FrequencyHz);
|
||||
Assert.Null(back.GuidanceOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Depth_is_little_endian_centimetres()
|
||||
{
|
||||
// 1.23 m → 123 cm → 0x007B, little-endian at frame offset 8..9 = 7B 00
|
||||
var frame = new Telemetry { DepthMeters = 1.23 }.EncodeFrame(0);
|
||||
Assert.Equal(0x7B, frame[8]);
|
||||
Assert.Equal(0x00, frame[9]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureResult_pointId_is_rfc4122_big_endian()
|
||||
{
|
||||
var id = Guid.Parse("018f5b2c-1a2b-7c3d-9e4f-a0b1c2d3e4f5"); // UUIDv7 shape
|
||||
var frame = new CaptureResult { PointId = id }.EncodeFrame(0);
|
||||
// §5.3: pointId at offset 36, most-significant byte first.
|
||||
Assert.Equal(0x01, frame[36]);
|
||||
Assert.Equal(0x8f, frame[37]);
|
||||
Assert.Equal(0xf5, frame[51]);
|
||||
Assert.Equal(id, CaptureResult.DecodeFrame(frame).PointId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaptureResult_roundtrips_position_and_accuracy()
|
||||
{
|
||||
var r = new CaptureResult
|
||||
{
|
||||
CaptureSeq = 7,
|
||||
Outcome = CaptureOutcome.Stored,
|
||||
Reason = ReasonCode.Ok,
|
||||
FixType = FixType.RtkFixed,
|
||||
Lat = 45.7649321,
|
||||
Lon = 4.8354792,
|
||||
OrthometricHeightM = 172.418,
|
||||
HrmsM = 0.008,
|
||||
VrmsM = 0.015,
|
||||
Utc = DateTimeOffset.FromUnixTimeMilliseconds(1_760_000_000_000),
|
||||
PointId = Guid.NewGuid(),
|
||||
};
|
||||
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
|
||||
Assert.Equal(IfLoc.CaptureResultFrameLen, r.EncodeFrame(0).Length);
|
||||
Assert.Equal(r.CaptureSeq, back.CaptureSeq);
|
||||
Assert.Equal(r.Outcome, back.Outcome);
|
||||
Assert.Equal(r.FixType, back.FixType);
|
||||
Assert.Equal(45.7649321, back.Lat!.Value, 7);
|
||||
Assert.Equal(4.8354792, back.Lon!.Value, 7);
|
||||
Assert.Equal(172.418, back.OrthometricHeightM!.Value, 3);
|
||||
Assert.Equal(0.008, back.HrmsM!.Value, 3);
|
||||
Assert.Equal(0.015, back.VrmsM!.Value, 3);
|
||||
Assert.Equal(r.Utc, back.Utc);
|
||||
Assert.Equal(r.PointId, back.PointId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_result_carries_reason_and_no_position()
|
||||
{
|
||||
var r = new CaptureResult
|
||||
{
|
||||
CaptureSeq = 3,
|
||||
Outcome = CaptureOutcome.Rejected,
|
||||
Reason = ReasonCode.HrmsExceeded,
|
||||
FixType = FixType.RtkFloat,
|
||||
Lat = null, Lon = null, OrthometricHeightM = null,
|
||||
};
|
||||
var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
|
||||
Assert.Equal(CaptureOutcome.Rejected, back.Outcome);
|
||||
Assert.Equal(ReasonCode.HrmsExceeded, back.Reason);
|
||||
Assert.Null(back.Lat);
|
||||
Assert.Null(back.Lon);
|
||||
Assert.Equal(Guid.Empty, back.PointId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end capture round-trip over the loopback link: the simulator replays a
|
||||
/// session and raises capture-triggers; a stand-in App decodes each trigger and writes
|
||||
/// back a capture-result gated on accuracy. Asserts every trigger is answered exactly
|
||||
/// once (no silent-failure) and correlation holds by captureSeq.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Capture_round_trip_answers_every_trigger_exactly_once()
|
||||
{
|
||||
var link = new LoopbackLink();
|
||||
var sim = new LocatorSimulator(link);
|
||||
|
||||
int telemetrySeen = 0;
|
||||
int triggersSeen = 0;
|
||||
|
||||
// Stand-in App: consume telemetry, and on each trigger apply a simple accuracy gate
|
||||
// and write back a capture-result. This is what UM Trace's Locator Driver does.
|
||||
link.ToApp += frame =>
|
||||
{
|
||||
switch (Frames.PeekType(frame))
|
||||
{
|
||||
case MessageType.Telemetry:
|
||||
_ = Telemetry.DecodePayload(frame.AsSpan(IfLoc.HeaderLen, IfLoc.TelemetryPayloadLen));
|
||||
telemetrySeen++;
|
||||
break;
|
||||
|
||||
case MessageType.CaptureTrigger:
|
||||
triggersSeen++;
|
||||
var trig = CaptureTrigger.DecodeFrame(frame);
|
||||
// Alternate a good fix and an out-of-spec fix to exercise both outcomes.
|
||||
bool good = trig.CaptureSeq % 2 == 1;
|
||||
var result = good
|
||||
? new CaptureResult
|
||||
{
|
||||
CaptureSeq = trig.CaptureSeq,
|
||||
Outcome = CaptureOutcome.Stored,
|
||||
Reason = ReasonCode.Ok,
|
||||
FixType = FixType.RtkFixed,
|
||||
Lat = 45.76 + trig.CaptureSeq * 1e-5,
|
||||
Lon = 4.83,
|
||||
OrthometricHeightM = 170 + trig.Snapshot.DepthMeters ?? 170,
|
||||
HrmsM = 0.009,
|
||||
VrmsM = 0.014,
|
||||
Utc = DateTimeOffset.UtcNow,
|
||||
PointId = Guid.NewGuid(),
|
||||
}
|
||||
: new CaptureResult
|
||||
{
|
||||
CaptureSeq = trig.CaptureSeq,
|
||||
Outcome = CaptureOutcome.Rejected,
|
||||
Reason = ReasonCode.HrmsExceeded,
|
||||
FixType = FixType.RtkFloat,
|
||||
};
|
||||
link.WriteToLocator(result.EncodeFrame((ushort)(1000 + trig.CaptureSeq)));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
var captureAt = new HashSet<int> { 5, 17, 42, 88 };
|
||||
var report = await sim.ReplayAsync(frames: 100, captureAtFrames: captureAt, realTime: false);
|
||||
|
||||
Assert.Equal(100, telemetrySeen);
|
||||
Assert.Equal(captureAt.Count, triggersSeen);
|
||||
Assert.Equal(captureAt.Count, report.CaptureTriggersEmitted);
|
||||
Assert.Equal(captureAt.Count, report.CaptureResultsReceived);
|
||||
Assert.Empty(report.PendingCaptures); // SRS-LOG-7: no silent drop
|
||||
|
||||
// Every result correlates to a distinct trigger, and both outcomes occurred.
|
||||
Assert.Equal(captureAt.Count, report.Results.Select(r => r.CaptureSeq).Distinct().Count());
|
||||
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Stored);
|
||||
Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Rejected);
|
||||
foreach (var r in report.Results.Where(r => r.Outcome == CaptureOutcome.Stored))
|
||||
Assert.NotEqual(Guid.Empty, r.PointId);
|
||||
}
|
||||
}
|
||||
216
tests/IfLoc.Sim/Frames.cs
Normal file
216
tests/IfLoc.Sim/Frames.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace IfLoc.Sim;
|
||||
|
||||
// Byte-accurate codec for the frozen IF-LOC v1.0 frames.
|
||||
// All multi-byte integers little-endian except pointId (RFC 4122 big-endian, §5.3).
|
||||
|
||||
/// <summary>§4 telemetry payload — the locate data dictionary.</summary>
|
||||
public sealed record Telemetry
|
||||
{
|
||||
public uint LocatorUptimeMs { get; init; }
|
||||
/// <summary>Depth in metres (resolution 0.01 m). Null = no depth (sentinel on wire).</summary>
|
||||
public double? DepthMeters { get; init; }
|
||||
/// <summary>Signal current in mA. Null = invalid.</summary>
|
||||
public ushort? SignalCurrentMa { get; init; }
|
||||
/// <summary>Frequency in Hz. Null = n/a.</summary>
|
||||
public uint? FrequencyHz { get; init; }
|
||||
public LocateMode Mode { get; init; } = LocateMode.Single;
|
||||
public SignalType SignalType { get; init; } = SignalType.Active;
|
||||
public byte GainDb { get; init; }
|
||||
public byte SignalLevel { get; init; }
|
||||
public byte DistortionQualityPct { get; init; }
|
||||
public byte SignalDirection { get; init; }
|
||||
public byte CompassAngleDeg { get; init; }
|
||||
/// <summary>Guidance offset −1000..+1000 (negative = left). Null = n/a.</summary>
|
||||
public short? GuidanceOffset { get; init; }
|
||||
public WarningFlags Warnings { get; init; }
|
||||
public UtilityType Utility { get; init; } = UtilityType.None;
|
||||
public byte BatteryPercent { get; init; }
|
||||
public StatusFlags Status { get; init; }
|
||||
|
||||
/// <summary>Encodes the 28-byte payload (frame offsets 4..31).</summary>
|
||||
public byte[] EncodePayload()
|
||||
{
|
||||
var p = new byte[IfLoc.TelemetryPayloadLen];
|
||||
var s = p.AsSpan();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(s[0..], LocatorUptimeMs); // 4
|
||||
BinaryPrimitives.WriteInt16LittleEndian(s[4..], DepthMeters is { } d ? (short)Math.Round(d * 100) : IfLoc.DepthNoValue); // 8
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[6..], SignalCurrentMa ?? IfLoc.CurrentInvalid); // 10
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(s[8..], FrequencyHz ?? IfLoc.FrequencyNa); // 12
|
||||
s[12] = (byte)Mode; // 16
|
||||
s[13] = (byte)SignalType; // 17
|
||||
s[14] = GainDb; // 18
|
||||
s[15] = SignalLevel; // 19
|
||||
s[16] = DistortionQualityPct; // 20
|
||||
s[17] = SignalDirection; // 21
|
||||
s[18] = CompassAngleDeg; // 22
|
||||
s[19] = 0; // 23 reserved
|
||||
BinaryPrimitives.WriteInt16LittleEndian(s[20..], GuidanceOffset ?? IfLoc.GuidanceNa); // 24
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[22..], (ushort)Warnings); // 26
|
||||
s[24] = (byte)Utility; // 28
|
||||
s[25] = BatteryPercent; // 29
|
||||
s[26] = (byte)Status; // 30
|
||||
s[27] = 0; // 31 reserved
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Decodes a 28-byte payload (frame offsets 4..31).</summary>
|
||||
public static Telemetry DecodePayload(ReadOnlySpan<byte> p)
|
||||
{
|
||||
if (p.Length < IfLoc.TelemetryPayloadLen)
|
||||
throw new ArgumentException($"telemetry payload must be {IfLoc.TelemetryPayloadLen} bytes");
|
||||
short depth = BinaryPrimitives.ReadInt16LittleEndian(p[4..]);
|
||||
ushort cur = BinaryPrimitives.ReadUInt16LittleEndian(p[6..]);
|
||||
uint freq = BinaryPrimitives.ReadUInt32LittleEndian(p[8..]);
|
||||
short guid = BinaryPrimitives.ReadInt16LittleEndian(p[20..]);
|
||||
return new Telemetry
|
||||
{
|
||||
LocatorUptimeMs = BinaryPrimitives.ReadUInt32LittleEndian(p[0..]),
|
||||
DepthMeters = depth == IfLoc.DepthNoValue ? null : depth / 100.0,
|
||||
SignalCurrentMa = cur == IfLoc.CurrentInvalid ? null : cur,
|
||||
FrequencyHz = freq == IfLoc.FrequencyNa ? null : freq,
|
||||
Mode = (LocateMode)p[12],
|
||||
SignalType = (SignalType)p[13],
|
||||
GainDb = p[14],
|
||||
SignalLevel = p[15],
|
||||
DistortionQualityPct = p[16],
|
||||
SignalDirection = p[17],
|
||||
CompassAngleDeg = p[18],
|
||||
GuidanceOffset = guid == IfLoc.GuidanceNa ? null : guid,
|
||||
Warnings = (WarningFlags)BinaryPrimitives.ReadUInt16LittleEndian(p[22..]),
|
||||
Utility = (UtilityType)p[24],
|
||||
BatteryPercent = p[25],
|
||||
Status = (StatusFlags)p[26],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Encodes a full 32-byte TELEMETRY frame (header + payload).</summary>
|
||||
public byte[] EncodeFrame(ushort seq)
|
||||
{
|
||||
var f = new byte[IfLoc.TelemetryFrameLen];
|
||||
Frames.WriteHeader(f, MessageType.Telemetry, seq);
|
||||
EncodePayload().CopyTo(f.AsSpan(IfLoc.HeaderLen));
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>§5.1 capture-trigger frame (locator → app).</summary>
|
||||
public sealed record CaptureTrigger
|
||||
{
|
||||
public ushort CaptureSeq { get; init; }
|
||||
public TriggerType TriggerType { get; init; }
|
||||
public required Telemetry Snapshot { get; init; }
|
||||
|
||||
public byte[] EncodeFrame(ushort seq)
|
||||
{
|
||||
var f = new byte[IfLoc.CaptureTriggerFrameLen];
|
||||
Frames.WriteHeader(f, MessageType.CaptureTrigger, seq);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(f.AsSpan(4), CaptureSeq); // 4
|
||||
f[6] = (byte)TriggerType; // 6
|
||||
f[7] = 0; // 7 reserved
|
||||
Snapshot.EncodePayload().CopyTo(f.AsSpan(8)); // 8..35
|
||||
return f;
|
||||
}
|
||||
|
||||
public static CaptureTrigger DecodeFrame(ReadOnlySpan<byte> f)
|
||||
{
|
||||
Frames.Expect(f, MessageType.CaptureTrigger, IfLoc.CaptureTriggerFrameLen);
|
||||
return new CaptureTrigger
|
||||
{
|
||||
CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
|
||||
TriggerType = (TriggerType)f[6],
|
||||
Snapshot = Telemetry.DecodePayload(f.Slice(8, IfLoc.TelemetryPayloadLen)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>§5.2 capture-result frame (app → locator).</summary>
|
||||
public sealed record CaptureResult
|
||||
{
|
||||
public ushort CaptureSeq { get; init; }
|
||||
public CaptureOutcome Outcome { get; init; }
|
||||
public ReasonCode Reason { get; init; }
|
||||
public FixType FixType { get; init; }
|
||||
/// <summary>WGS84 latitude in degrees. Null = no position.</summary>
|
||||
public double? Lat { get; init; }
|
||||
public double? Lon { get; init; }
|
||||
/// <summary>Orthometric height in metres. Null = no position.</summary>
|
||||
public double? OrthometricHeightM { get; init; }
|
||||
/// <summary>Horizontal RMS (1σ) in metres. Null = unknown.</summary>
|
||||
public double? HrmsM { get; init; }
|
||||
public double? VrmsM { get; init; }
|
||||
/// <summary>Position epoch, UTC.</summary>
|
||||
public DateTimeOffset? Utc { get; init; }
|
||||
/// <summary>Stored point UUIDv7. All-zero on REJECTED.</summary>
|
||||
public Guid PointId { get; init; }
|
||||
|
||||
public byte[] EncodeFrame(ushort seq)
|
||||
{
|
||||
var f = new byte[IfLoc.CaptureResultFrameLen];
|
||||
var s = f.AsSpan();
|
||||
Frames.WriteHeader(f, MessageType.CaptureResult, seq);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[4..], CaptureSeq);
|
||||
s[6] = (byte)Outcome;
|
||||
s[7] = (byte)Reason;
|
||||
s[8] = (byte)FixType;
|
||||
s[9] = 0;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(s[10..], Lat is { } la ? (int)Math.Round(la * 1e7) : IfLoc.PositionNoValue);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(s[14..], Lon is { } lo ? (int)Math.Round(lo * 1e7) : IfLoc.PositionNoValue);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(s[18..], OrthometricHeightM is { } h ? (int)Math.Round(h * 1000) : IfLoc.PositionNoValue);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[22..], HrmsM is { } hr ? (ushort)Math.Round(hr * 1000) : IfLoc.RmsUnknown);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[24..], VrmsM is { } vr ? (ushort)Math.Round(vr * 1000) : IfLoc.RmsUnknown);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(s[26..], 0); // reserved
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(s[28..], Utc is { } t ? (ulong)t.ToUnixTimeMilliseconds() : 0);
|
||||
PointId.TryWriteBytes(s.Slice(36, 16), bigEndian: true, out _); // §5.3 RFC 4122 network order
|
||||
return f;
|
||||
}
|
||||
|
||||
public static CaptureResult DecodeFrame(ReadOnlySpan<byte> f)
|
||||
{
|
||||
Frames.Expect(f, MessageType.CaptureResult, IfLoc.CaptureResultFrameLen);
|
||||
int lat = BinaryPrimitives.ReadInt32LittleEndian(f[10..]);
|
||||
int lon = BinaryPrimitives.ReadInt32LittleEndian(f[14..]);
|
||||
int h = BinaryPrimitives.ReadInt32LittleEndian(f[18..]);
|
||||
ushort hr = BinaryPrimitives.ReadUInt16LittleEndian(f[22..]);
|
||||
ushort vr = BinaryPrimitives.ReadUInt16LittleEndian(f[24..]);
|
||||
ulong ms = BinaryPrimitives.ReadUInt64LittleEndian(f[28..]);
|
||||
return new CaptureResult
|
||||
{
|
||||
CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
|
||||
Outcome = (CaptureOutcome)f[6],
|
||||
Reason = (ReasonCode)f[7],
|
||||
FixType = (FixType)f[8],
|
||||
Lat = lat == IfLoc.PositionNoValue ? null : lat / 1e7,
|
||||
Lon = lon == IfLoc.PositionNoValue ? null : lon / 1e7,
|
||||
OrthometricHeightM = h == IfLoc.PositionNoValue ? null : h / 1000.0,
|
||||
HrmsM = hr == IfLoc.RmsUnknown ? null : hr / 1000.0,
|
||||
VrmsM = vr == IfLoc.RmsUnknown ? null : vr / 1000.0,
|
||||
Utc = ms == 0 ? null : DateTimeOffset.FromUnixTimeMilliseconds((long)ms),
|
||||
PointId = new Guid(f.Slice(36, 16), bigEndian: true),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared header helpers.</summary>
|
||||
public static class Frames
|
||||
{
|
||||
public static void WriteHeader(Span<byte> f, MessageType type, ushort seq)
|
||||
{
|
||||
f[0] = IfLoc.FrameVersion;
|
||||
f[1] = (byte)type;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(f[2..], seq);
|
||||
}
|
||||
|
||||
public static MessageType PeekType(ReadOnlySpan<byte> f) => (MessageType)f[1];
|
||||
|
||||
public static void Expect(ReadOnlySpan<byte> f, MessageType type, int fixedLen)
|
||||
{
|
||||
if (f.Length < fixedLen)
|
||||
throw new ArgumentException($"{type} frame must be ≥ {fixedLen} bytes, got {f.Length}");
|
||||
if (f[0] != IfLoc.FrameVersion)
|
||||
throw new ArgumentException($"unsupported frameVersion 0x{f[0]:X2}");
|
||||
if ((MessageType)f[1] != type)
|
||||
throw new ArgumentException($"expected {type}, got 0x{f[1]:X2}");
|
||||
}
|
||||
}
|
||||
19
tests/IfLoc.Sim/IfLoc.Sim.csproj
Normal file
19
tests/IfLoc.Sim/IfLoc.Sim.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
IF-LOC locator simulator + wire codec.
|
||||
Deliberately a plain net9.0 library (NOT a MAUI target head) so it builds and runs
|
||||
headless in CI without the Android/iOS workloads. It is the reference implementation
|
||||
of the frozen IF-LOC v1.0 contract (meta/contracts/ble-device-interface.md) and exists
|
||||
to unblock App development without firmware (Risk R-3, SRS §3.1).
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>IfLoc.Sim</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
145
tests/IfLoc.Sim/LocatorSimulator.cs
Normal file
145
tests/IfLoc.Sim/LocatorSimulator.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
namespace IfLoc.Sim;
|
||||
|
||||
/// <summary>
|
||||
/// A bidirectional in-memory link modelling the bonded BLE GATT session between the
|
||||
/// locator (peripheral) and the App (central). Frames pushed toward the App surface on
|
||||
/// <see cref="ToApp"/>; frames the App writes back surface on <see cref="ToLocator"/>.
|
||||
/// A transport-agnostic stand-in for the real GATT characteristics — a TCP or real-BLE
|
||||
/// implementation can replace it without touching the simulator or codec.
|
||||
/// </summary>
|
||||
public sealed class LoopbackLink
|
||||
{
|
||||
/// <summary>Locator → App (Telemetry / Event characteristics).</summary>
|
||||
public event Action<byte[]>? ToApp;
|
||||
/// <summary>App → Locator (Command / Capture Result characteristics).</summary>
|
||||
public event Action<byte[]>? ToLocator;
|
||||
|
||||
public void PublishToApp(byte[] frame) => ToApp?.Invoke(frame);
|
||||
public void WriteToLocator(byte[] frame) => ToLocator?.Invoke(frame);
|
||||
}
|
||||
|
||||
/// <summary>Result of a replayed session, for test assertions.</summary>
|
||||
public sealed record SessionReport
|
||||
{
|
||||
public int TelemetryFramesEmitted { get; init; }
|
||||
public int CaptureTriggersEmitted { get; init; }
|
||||
public int CaptureResultsReceived { get; init; }
|
||||
public IReadOnlyList<CaptureResult> Results { get; init; } = Array.Empty<CaptureResult>();
|
||||
/// <summary>Trigger captureSeqs still awaiting a result — must be empty (no silent-failure, SRS-LOG-7).</summary>
|
||||
public IReadOnlyList<ushort> PendingCaptures { get; init; } = Array.Empty<ushort>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a canned locate session over the IF-LOC contract: emits telemetry at the
|
||||
/// configured rate, raises capture-triggers at scripted frames, and consumes the App's
|
||||
/// capture-results — correlating each result to its trigger by captureSeq. Built purely
|
||||
/// from the frozen data dictionary so App development is unblocked without firmware
|
||||
/// (Risk R-3).
|
||||
/// </summary>
|
||||
public sealed class LocatorSimulator
|
||||
{
|
||||
private readonly LoopbackLink _link;
|
||||
private ushort _seq;
|
||||
private ushort _captureSeq;
|
||||
private readonly HashSet<ushort> _pending = new();
|
||||
private readonly List<CaptureResult> _results = new();
|
||||
|
||||
public LocatorSimulator(LoopbackLink link)
|
||||
{
|
||||
_link = link;
|
||||
_link.ToLocator += OnAppFrame;
|
||||
}
|
||||
|
||||
private void OnAppFrame(byte[] frame)
|
||||
{
|
||||
if (frame.Length < IfLoc.HeaderLen) return; // §14 ignore malformed
|
||||
if (Frames.PeekType(frame) != MessageType.CaptureResult) return;
|
||||
var result = CaptureResult.DecodeFrame(frame);
|
||||
_results.Add(result);
|
||||
_pending.Remove(result.CaptureSeq);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits <paramref name="frames"/> telemetry frames, raising a capture-trigger at each
|
||||
/// index in <paramref name="captureAtFrames"/>. When <paramref name="realTime"/> is true,
|
||||
/// paces at <paramref name="rateHz"/> for a live demo; otherwise emits as fast as possible
|
||||
/// for deterministic tests. Returns once all frames are emitted (results may still be
|
||||
/// arriving synchronously on the loopback).
|
||||
/// </summary>
|
||||
public async Task<SessionReport> ReplayAsync(
|
||||
int frames,
|
||||
IReadOnlySet<int> captureAtFrames,
|
||||
double rateHz = 5.0,
|
||||
bool realTime = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
int triggers = 0;
|
||||
uint uptime = 0;
|
||||
int stepMs = (int)Math.Round(1000.0 / rateHz);
|
||||
|
||||
for (int i = 0; i < frames; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var tele = BuildTelemetry(i, uptime);
|
||||
_link.PublishToApp(tele.EncodeFrame(_seq++));
|
||||
|
||||
if (captureAtFrames.Contains(i))
|
||||
{
|
||||
var trigger = new CaptureTrigger
|
||||
{
|
||||
CaptureSeq = ++_captureSeq, // locator-initiated: high bit clear (§5.1)
|
||||
TriggerType = TriggerType.ButtonSingle,
|
||||
Snapshot = tele,
|
||||
};
|
||||
_pending.Add(trigger.CaptureSeq);
|
||||
triggers++;
|
||||
_link.PublishToApp(trigger.EncodeFrame(_seq++));
|
||||
}
|
||||
|
||||
uptime += (uint)stepMs;
|
||||
if (realTime) await Task.Delay(stepMs, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SessionReport
|
||||
{
|
||||
TelemetryFramesEmitted = frames,
|
||||
CaptureTriggersEmitted = triggers,
|
||||
CaptureResultsReceived = _results.Count,
|
||||
Results = _results.ToArray(),
|
||||
PendingCaptures = _pending.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deterministic, physically-plausible canned telemetry frame for step <paramref name="i"/>.
|
||||
/// Sweeps depth, current, and signal across their ranges and exercises a warning flag
|
||||
/// and a no-depth sentinel so consumers see the full dictionary.
|
||||
/// </summary>
|
||||
public static Telemetry BuildTelemetry(int i, uint uptimeMs)
|
||||
{
|
||||
bool noDepth = i % 20 == 19; // periodically drop depth (sentinel path)
|
||||
var warn = WarningFlags.None;
|
||||
if (i % 25 == 12) warn |= WarningFlags.Shallow;
|
||||
if (i % 40 == 30) warn |= WarningFlags.Overload;
|
||||
|
||||
return new Telemetry
|
||||
{
|
||||
LocatorUptimeMs = uptimeMs,
|
||||
DepthMeters = noDepth ? null : 0.50 + 0.01 * (i % 200), // 0.50 → 2.49 m
|
||||
SignalCurrentMa = (ushort)(50 + (i % 300)),
|
||||
FrequencyHz = 32768,
|
||||
Mode = LocateMode.Twin,
|
||||
SignalType = SignalType.Active,
|
||||
GainDb = (byte)(40 + (i % 60)),
|
||||
SignalLevel = (byte)(60 + (i % 40)),
|
||||
DistortionQualityPct = (byte)(100 - (i % 15)),
|
||||
SignalDirection = 1,
|
||||
CompassAngleDeg = (byte)(i % 181),
|
||||
GuidanceOffset = (short)(((i % 41) - 20) * 25), // −500 → +500, negative = left
|
||||
Warnings = warn,
|
||||
Utility = UtilityType.Water,
|
||||
BatteryPercent = (byte)Math.Max(5, 100 - i / 10),
|
||||
Status = StatusFlags.Locating,
|
||||
};
|
||||
}
|
||||
}
|
||||
150
tests/IfLoc.Sim/Protocol.cs
Normal file
150
tests/IfLoc.Sim/Protocol.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
namespace IfLoc.Sim;
|
||||
|
||||
// Reference constants for the frozen IF-LOC v1.0 contract.
|
||||
// Source of truth: meta/contracts/ble-device-interface.md. Keep the two in lockstep;
|
||||
// enum values and message-type ids are append-only once frozen.
|
||||
|
||||
/// <summary>Wire message-type ids (frame header byte 1).</summary>
|
||||
public enum MessageType : byte
|
||||
{
|
||||
Telemetry = 0x01,
|
||||
CaptureTrigger = 0x02,
|
||||
CaptureResult = 0x03,
|
||||
Alert = 0x04,
|
||||
Ack = 0x05,
|
||||
|
||||
CmdRequestSnapshot = 0x10,
|
||||
CmdSetFrequency = 0x11,
|
||||
CmdSetMode = 0x12,
|
||||
CmdSetTelemetryRate = 0x13,
|
||||
CmdTimeSync = 0x14,
|
||||
CmdSetUtility = 0x15,
|
||||
|
||||
OtaBegin = 0x20,
|
||||
OtaData = 0x21,
|
||||
OtaEnd = 0x22,
|
||||
|
||||
UsageLogRequest = 0x30,
|
||||
UsageLogRecord = 0x31,
|
||||
UsageLogEnd = 0x32,
|
||||
|
||||
ClaimChallenge = 0x40,
|
||||
ClaimAssertion = 0x41,
|
||||
ClaimCertReq = 0x42,
|
||||
ClaimCert = 0x43,
|
||||
}
|
||||
|
||||
/// <summary>§4.1 locateMode.</summary>
|
||||
public enum LocateMode : byte
|
||||
{
|
||||
Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6,
|
||||
NotAvailable = 0xFF,
|
||||
}
|
||||
|
||||
/// <summary>§4.2 signalType.</summary>
|
||||
public enum SignalType : byte
|
||||
{
|
||||
Active = 0, LineDropActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4,
|
||||
Sonde = 5, Radio = 6, FaultFind = 7, NotAvailable = 0xFF,
|
||||
}
|
||||
|
||||
/// <summary>§4.4 utilityType (APWA-aligned).</summary>
|
||||
public enum UtilityType : byte
|
||||
{
|
||||
None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6,
|
||||
Other = 7, NotAvailable = 0xFF,
|
||||
}
|
||||
|
||||
/// <summary>§4.3 warningFlags bitfield.</summary>
|
||||
[Flags]
|
||||
public enum WarningFlags : ushort
|
||||
{
|
||||
None = 0,
|
||||
Shallow = 1 << 0,
|
||||
Overload = 1 << 1,
|
||||
SwingTilt = 1 << 2,
|
||||
DepthInvalid = 1 << 3,
|
||||
CurrentInvalid = 1 << 4,
|
||||
OutOfRange = 1 << 5,
|
||||
DistortionHigh = 1 << 6,
|
||||
LowBattery = 1 << 7,
|
||||
}
|
||||
|
||||
/// <summary>§4.5 statusFlags bitfield.</summary>
|
||||
[Flags]
|
||||
public enum StatusFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
Locating = 1 << 0,
|
||||
MenuActive = 1 << 1,
|
||||
TimeSynced = 1 << 2,
|
||||
DepthModeAuto = 1 << 3,
|
||||
}
|
||||
|
||||
/// <summary>§5.1 triggerType.</summary>
|
||||
public enum TriggerType : byte
|
||||
{
|
||||
ButtonSingle = 0, ButtonHold = 1, OffsetRequest = 2, AppInitiated = 3,
|
||||
}
|
||||
|
||||
/// <summary>§5.2 outcome — the deterministic capture outcome (SRS-LOG-7).</summary>
|
||||
public enum CaptureOutcome : byte
|
||||
{
|
||||
Stored = 0, StoredFlagged = 1, Rejected = 2,
|
||||
}
|
||||
|
||||
/// <summary>§5.4 reasonCode / gateStatus.</summary>
|
||||
public enum ReasonCode : byte
|
||||
{
|
||||
Ok = 0,
|
||||
FixTypeTooLow = 1,
|
||||
HrmsExceeded = 2,
|
||||
VrmsExceeded = 3,
|
||||
CorrectionAgeExceeded = 4,
|
||||
NoActiveTicket = 5,
|
||||
ImuCalibrationInvalid = 6,
|
||||
HeadingConfidenceLow = 7,
|
||||
BufferFull = 8,
|
||||
NoPosition = 9,
|
||||
WaiverRequired = 10,
|
||||
Other = 255,
|
||||
}
|
||||
|
||||
/// <summary>§5.2 fixType (GGA-quality mapping; 3 reserved).</summary>
|
||||
public enum FixType : byte
|
||||
{
|
||||
NoFix = 0, Autonomous = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5,
|
||||
}
|
||||
|
||||
/// <summary>Frozen wire constants and sentinels.</summary>
|
||||
public static class IfLoc
|
||||
{
|
||||
public const byte FrameVersion = 0x01;
|
||||
|
||||
// Fixed frame lengths (Appendix A).
|
||||
public const int HeaderLen = 4;
|
||||
public const int TelemetryFrameLen = 32;
|
||||
public const int TelemetryPayloadLen = 28; // offsets 4..31
|
||||
public const int CaptureTriggerFrameLen = 36;
|
||||
public const int CaptureResultFrameLen = 52;
|
||||
|
||||
// Sentinels (§1).
|
||||
public const short DepthNoValue = unchecked((short)0x8000);
|
||||
public const ushort CurrentInvalid = 0xFFFF;
|
||||
public const uint FrequencyNa = 0xFFFFFFFF;
|
||||
public const short GuidanceNa = unchecked((short)0x8000);
|
||||
public const int PositionNoValue = unchecked((int)0x80000000);
|
||||
public const ushort RmsUnknown = 0xFFFF;
|
||||
|
||||
// GATT (§2) — recommended UUIDs the App builds against.
|
||||
public const string BaseUuidFormat = "A9E1{0:X4}-1B4C-4F9A-9B7E-2D6F0C3A5E11";
|
||||
public static string LocateServiceUuid => string.Format(BaseUuidFormat, 0x0001);
|
||||
public static string ProtocolInfoUuid => string.Format(BaseUuidFormat, 0x0002);
|
||||
public static string TelemetryUuid => string.Format(BaseUuidFormat, 0x0003);
|
||||
public static string EventUuid => string.Format(BaseUuidFormat, 0x0004);
|
||||
public static string CommandUuid => string.Format(BaseUuidFormat, 0x0005);
|
||||
public static string CaptureResultUuid => string.Format(BaseUuidFormat, 0x0006);
|
||||
public static string LinkStateUuid => string.Format(BaseUuidFormat, 0x0007);
|
||||
public static string BulkOtaUuid => string.Format(BaseUuidFormat, 0x0008);
|
||||
public static string ClaimUuid => string.Format(BaseUuidFormat, 0x0009);
|
||||
}
|
||||
Reference in New Issue
Block a user