Initial commit: FieldLogger MAUI app with Maglink BLE support

Added Maglink RTK GNSS receiver integration with correct BLE UUIDs and device name filtering (ML-* prefix).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-07-06 13:46:50 -05:00
commit b602b762c9
79 changed files with 5807 additions and 0 deletions

21
FieldLogger/App.xaml Normal file
View File

@@ -0,0 +1,21 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:FieldLogger"
xmlns:conv="clr-namespace:FieldLogger.Converters"
x:Class="FieldLogger.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<conv:ConnectionStateToColorConverter x:Key="StateColor" />
<conv:ConnectionStateToTextConverter x:Key="StateText" />
<conv:UtilityToNameConverter x:Key="UtilityName" />
<conv:FixStatusToNameConverter x:Key="FixStatusName" />
<conv:InvertBoolConverter x:Key="InvertBool" />
</ResourceDictionary>
</Application.Resources>
</Application>

14
FieldLogger/App.xaml.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace FieldLogger;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
}

16
FieldLogger/AppShell.xaml Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="FieldLogger.AppShell"
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">
<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="Settings" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
</TabBar>
</Shell>

View File

@@ -0,0 +1,14 @@
using FieldLogger.Views;
namespace FieldLogger;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("devicescan", typeof(DeviceScanPage));
Routing.RegisterRoute("jobdetail", typeof(JobDetailPage));
}
}

View File

@@ -0,0 +1,68 @@
using System.Globalization;
using FieldLogger.Models;
using FieldLogger.Services;
namespace FieldLogger.Converters;
public sealed class ConnectionStateToColorConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value switch
{
ConnectionState.Connected => Colors.LimeGreen,
ConnectionState.Connecting => Colors.Orange,
_ => Colors.IndianRed,
};
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class ConnectionStateToTextConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value switch
{
ConnectionState.Connected => "Connected",
ConnectionState.Connecting => "Connecting…",
_ => "Disconnected",
};
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class UtilityToNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is int i ? ((UmUtility)i).ToString() : "";
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class InvertBoolConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && !b;
}
public sealed class FixStatusToNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is int i ? (GnssFixStatus)i switch
{
GnssFixStatus.NoFix => "No Fix",
GnssFixStatus.Single => "Single",
GnssFixStatus.Dgps => "DGPS",
GnssFixStatus.RtkFixed => "RTK Fixed",
GnssFixStatus.RtkFloat => "RTK Float",
_ => "?",
} : "—";
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,82 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>FieldLogger</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- MVVMTK0045: [ObservableProperty] on fields is not WinRT-AOT compatible. This app is not
published with native AOT on Windows; revisit if that changes (needs LangVersion=preview
partial properties). -->
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>
<!-- Display name -->
<ApplicationTitle>Field Logger</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.undergroundmagnetics.fieldlogger</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Plugin.BLE" Version="3.1.0" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
</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'">
<PackageReference Include="Microsoft.Maui.Controls.Maps" Version="$(MauiVersion)" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/maui/global", "FieldLogger")]
[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/maui/global", "FieldLogger.Pages")]

View File

@@ -0,0 +1,64 @@
using FieldLogger.Services;
using FieldLogger.Services.Ble;
using FieldLogger.Services.Data;
using FieldLogger.Services.Sync;
using FieldLogger.ViewModels;
using FieldLogger.Views;
using Microsoft.Extensions.Logging;
namespace FieldLogger;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
#if !WINDOWS
.UseMauiMaps()
#endif
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
// Core services
builder.Services.AddSingleton<SettingsService>();
builder.Services.AddSingleton<AppDatabase>();
builder.Services.AddSingleton<BleScanner>();
builder.Services.AddSingleton<UmReceiverService>();
builder.Services.AddSingleton<MaglinkService>();
builder.Services.AddSingleton<DeviceConnectionManager>();
builder.Services.AddSingleton<PointLogger>();
builder.Services.AddSingleton<IMqttSyncService, NullMqttSyncService>();
// View models
builder.Services.AddSingleton<HomeViewModel>();
builder.Services.AddSingleton<JobsViewModel>();
builder.Services.AddSingleton<MapViewModel>();
builder.Services.AddSingleton<SettingsViewModel>();
builder.Services.AddTransient<DeviceScanViewModel>();
builder.Services.AddTransient<JobDetailViewModel>();
// Pages
builder.Services.AddSingleton<HomePage>();
builder.Services.AddSingleton<JobsPage>();
builder.Services.AddSingleton<MapPage>();
builder.Services.AddSingleton<SettingsPage>();
builder.Services.AddTransient<DeviceScanPage>();
builder.Services.AddTransient<JobDetailPage>();
var app = builder.Build();
// Instantiate the point logger so it listens for receiver packets from startup.
_ = app.Services.GetRequiredService<PointLogger>();
return app;
}
}

View File

@@ -0,0 +1,11 @@
namespace FieldLogger.Models;
/// <summary>The two kinds of BLE devices the app pairs with.</summary>
public enum DeviceKind
{
/// <summary>Underground Magnetics utility locating receiver (UMRX_* / DT100_*).</summary>
Locator,
/// <summary>Maglink RTK GNSS receiver (H11).</summary>
RtkGps,
}

View File

@@ -0,0 +1,155 @@
using System.Globalization;
namespace FieldLogger.Models;
public enum GnssFixStatus { NoFix = 0, Single = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5 }
/// <summary>
/// A parsed $GNPOS sentence from the Maglink (H11) RTK receiver.
/// </summary>
public sealed class GnssFix
{
public double Latitude { get; init; }
public double Longitude { get; init; }
public double Altitude { get; init; }
public double AltitudeCorrected { get; init; }
public GnssFixStatus Status { get; init; }
public double Hdop { get; init; }
public double Hrms { get; init; }
public double Vrms { get; init; }
public int SatellitesUsed { get; init; }
public int SatellitesVisible { get; init; }
public double SpeedKmh { get; init; }
public double Heading { get; init; }
public double BatteryVoltage { get; init; }
public int BatteryPercent { get; init; }
public bool NtripConnected { get; init; }
public int RtcmSize { get; init; }
public double CorrectionAgeSeconds { get; init; }
public long UnixTimestamp { get; init; }
public double TiltAngle { get; init; }
public string RawSentence { get; init; } = "";
/// <summary>Local UTC time this fix was received by the app.</summary>
public DateTime ReceivedUtc { get; init; } = DateTime.UtcNow;
public bool HasPosition => Status != GnssFixStatus.NoFix;
public string StatusLabel => Status switch
{
GnssFixStatus.NoFix => "No Fix",
GnssFixStatus.Single => "Single",
GnssFixStatus.Dgps => "DGPS",
GnssFixStatus.RtkFixed => "RTK Fixed",
GnssFixStatus.RtkFloat => "RTK Float",
_ => Status.ToString(),
};
/// <summary>Parses a "$GNPOS,..." sentence (checksum must already be validated).</summary>
public static GnssFix? TryParse(string sentence)
{
var body = NmeaSentence.Body(sentence);
var f = body.Split(',');
if (f.Length < 20 || f[0] != "GNPOS")
return null;
try
{
return new GnssFix
{
Latitude = D(f[1]),
Longitude = D(f[2]),
Altitude = D(f[3]),
AltitudeCorrected = D(f[4]),
Status = (GnssFixStatus)I(f[5]),
Hdop = D(f[6]),
Hrms = D(f[7]),
Vrms = D(f[8]),
SatellitesUsed = I(f[9]),
SatellitesVisible = I(f[10]),
SpeedKmh = D(f[11]),
Heading = D(f[12]),
BatteryVoltage = D(f[13]),
BatteryPercent = I(f[14]),
NtripConnected = I(f[15]) != 0,
RtcmSize = I(f[16]),
CorrectionAgeSeconds = D(f[17]),
UnixTimestamp = long.Parse(f[18], CultureInfo.InvariantCulture),
TiltAngle = D(f[19]),
RawSentence = sentence.Trim(),
};
}
catch (FormatException)
{
return null;
}
}
private static double D(string s) => double.Parse(s, NumberStyles.Float, CultureInfo.InvariantCulture);
private static int I(string s) => int.Parse(s, CultureInfo.InvariantCulture);
}
/// <summary>
/// A parsed $GNDEV device-information sentence from the Maglink receiver.
/// </summary>
public sealed class GnssDeviceInfo
{
public string SerialNumber { get; init; } = "";
public string PcbVersion { get; init; } = "";
public string FirmwareVersion { get; init; } = "";
public string Imei { get; init; } = "";
public string Imsi { get; init; } = "";
public string Iccid { get; init; } = "";
public static GnssDeviceInfo? TryParse(string sentence)
{
var f = NmeaSentence.Body(sentence).Split(',');
if (f.Length < 7 || f[0] != "GNDEV")
return null;
return new GnssDeviceInfo
{
SerialNumber = f[1],
PcbVersion = f[2],
FirmwareVersion = f[3],
Imei = f[4],
Imsi = f[5],
Iccid = f[6],
};
}
}
/// <summary>NMEA framing helpers ($...*hh checksum).</summary>
public static class NmeaSentence
{
/// <summary>Returns the sentence body between '$' and '*' (or end of string).</summary>
public static string Body(string sentence)
{
var s = sentence.Trim().TrimStart('$');
var star = s.IndexOf('*');
return star >= 0 ? s[..star] : s;
}
public static byte Checksum(string sentence)
{
byte checksum = 0;
foreach (var c in Body(sentence))
checksum ^= (byte)c;
return checksum;
}
/// <summary>Validates the trailing *hh checksum. Sentences without a checksum fail validation.</summary>
public static bool VerifyChecksum(string sentence)
{
var star = sentence.LastIndexOf('*');
if (star < 0 || star + 3 > sentence.TrimEnd().Length)
return false;
var hex = sentence.Substring(star + 1, 2);
if (!byte.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var expected))
return false;
return Checksum(sentence) == expected;
}
}

23
FieldLogger/Models/Job.cs Normal file
View File

@@ -0,0 +1,23 @@
using SQLite;
namespace FieldLogger.Models;
[Table("jobs")]
public sealed class Job
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[NotNull]
public string Name { get; set; } = "";
public string Notes { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
/// <summary>Reserved for MQTT sync: server-side job identifier once provisioned remotely.</summary>
public string? RemoteId { get; set; }
/// <summary>Reserved for MQTT sync: true once all points have been pushed to the server.</summary>
public bool Synced { get; set; }
}

View File

@@ -0,0 +1,145 @@
using SQLite;
namespace FieldLogger.Models;
/// <summary>
/// One logged locate point: the full UM receiver PBDL packet joined with the
/// GNSS fix that was current when the operator pressed the log button.
/// </summary>
[Table("points")]
public sealed class LoggedPoint
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[Indexed]
public int JobId { get; set; }
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
// ---- UM locating receiver (PBDL schema 1) ----
public int Mode { get; set; }
public int Frequency { get; set; }
public int FreqType { get; set; }
public int Signal { get; set; }
public int GainDb { get; set; }
public string DepthRaw { get; set; } = "";
public double? DepthMeters { get; set; }
public string CurrentRaw { get; set; } = "";
public double? CurrentMilliamps { get; set; }
public int CompassAngle { get; set; }
public int GuidanceArrows { get; set; }
public int LdPhase { get; set; }
public bool Clipping { get; set; }
public int DepthCurrentSetting { get; set; }
public int LrArrowStyle { get; set; }
public int AudioVolume { get; set; }
public int AudioModulation { get; set; }
public int AudioSound { get; set; }
public double LocatorBatteryVoltage { get; set; }
public int LocatorBatteryType { get; set; }
public int Backlight { get; set; }
public int Utility { get; set; }
public bool MenuInUse { get; set; }
public string LocatorRawPacket { get; set; } = "";
public string LocatorSerialNumber { get; set; } = "";
public string LocatorModel { get; set; } = "";
// ---- GNSS fix (Maglink $GNPOS) ----
/// <summary>False when no fresh GNSS fix was available at log time; position fields are then unset.</summary>
public bool GpsValid { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public double Altitude { get; set; }
public double AltitudeCorrected { get; set; }
public int FixStatus { get; set; }
public double Hdop { get; set; }
public double Hrms { get; set; }
public double Vrms { get; set; }
public int SatellitesUsed { get; set; }
public int SatellitesVisible { get; set; }
public double SpeedKmh { get; set; }
public double HeadingDegrees { get; set; }
public bool NtripConnected { get; set; }
public double CorrectionAgeSeconds { get; set; }
public long GpsUnixTimestamp { get; set; }
public double TiltAngle { get; set; }
public double GpsBatteryVoltage { get; set; }
public int GpsBatteryPercent { get; set; }
public string GpsRawSentence { get; set; } = "";
public string GpsSerialNumber { get; set; } = "";
/// <summary>Reserved for MQTT sync.</summary>
public bool Synced { get; set; }
[Ignore]
public GnssFixStatus FixStatusEnum => (GnssFixStatus)FixStatus;
[Ignore]
public UmUtility UtilityEnum => (UmUtility)Utility;
public static LoggedPoint From(int jobId, UmLogPacket packet, UmDeviceInfo? locatorInfo,
GnssFix? fix, GnssDeviceInfo? gpsInfo)
{
var p = new LoggedPoint
{
JobId = jobId,
TimestampUtc = DateTime.UtcNow,
Mode = (int)packet.Mode,
Frequency = packet.Frequency,
FreqType = (int)packet.FreqType,
Signal = packet.Signal,
GainDb = packet.GainDb,
DepthRaw = packet.DepthRaw,
DepthMeters = packet.DepthMeters,
CurrentRaw = packet.CurrentRaw,
CurrentMilliamps = packet.CurrentMilliamps,
CompassAngle = packet.CompassAngle,
GuidanceArrows = packet.GuidanceArrows,
LdPhase = packet.LdPhase,
Clipping = packet.Clipping,
DepthCurrentSetting = packet.DepthCurrentSetting,
LrArrowStyle = packet.LrArrowStyle,
AudioVolume = packet.AudioVolume,
AudioModulation = packet.AudioModulation,
AudioSound = packet.AudioSound,
LocatorBatteryVoltage = packet.BatteryVoltage,
LocatorBatteryType = (int)packet.BatteryType,
Backlight = packet.Backlight,
Utility = (int)packet.Utility,
MenuInUse = packet.MenuInUse,
LocatorRawPacket = packet.RawText,
LocatorSerialNumber = locatorInfo?.SerialNumber ?? "",
LocatorModel = locatorInfo?.ModelName ?? "",
GpsSerialNumber = gpsInfo?.SerialNumber ?? "",
};
if (fix is not null)
{
p.GpsValid = fix.HasPosition;
p.Latitude = fix.Latitude;
p.Longitude = fix.Longitude;
p.Altitude = fix.Altitude;
p.AltitudeCorrected = fix.AltitudeCorrected;
p.FixStatus = (int)fix.Status;
p.Hdop = fix.Hdop;
p.Hrms = fix.Hrms;
p.Vrms = fix.Vrms;
p.SatellitesUsed = fix.SatellitesUsed;
p.SatellitesVisible = fix.SatellitesVisible;
p.SpeedKmh = fix.SpeedKmh;
p.HeadingDegrees = fix.Heading;
p.NtripConnected = fix.NtripConnected;
p.CorrectionAgeSeconds = fix.CorrectionAgeSeconds;
p.GpsUnixTimestamp = fix.UnixTimestamp;
p.TiltAngle = fix.TiltAngle;
p.GpsBatteryVoltage = fix.BatteryVoltage;
p.GpsBatteryPercent = fix.BatteryPercent;
p.GpsRawSentence = fix.RawSentence;
}
return p;
}
}

View File

@@ -0,0 +1,51 @@
namespace FieldLogger.Models;
/// <summary>
/// Info string the UM receiver sends the first time push-button data logging is enabled.
/// </summary>
public sealed class UmDeviceInfo
{
public string Manufacturer { get; init; } = "";
public string ModelName { get; init; } = "";
public string SerialNumber { get; init; } = "";
public string BootloaderVersion { get; init; } = "";
public string SoftwareVersion { get; init; } = "";
public string ManufactureDate { get; init; } = "";
public string CalibrationDate { get; init; } = "";
public double HourCount { get; init; }
public int PbdlSchema { get; init; }
public string RawText { get; init; } = "";
/// <summary>
/// Parses the 10-field info string (9 parameters followed by "OK").
/// Returns null if the line does not match.
/// </summary>
public static UmDeviceInfo? TryParse(string line)
{
var fields = line.Trim().Split(',');
if (fields.Length < 10 || !fields[^1].Trim().Equals("OK", StringComparison.OrdinalIgnoreCase))
return null;
// Serial number is 9 digits; use it to distinguish from other OK-terminated responses.
if (fields[2].Trim().Length < 6)
return null;
double.TryParse(fields[7], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var hours);
int.TryParse(fields[8], out var schema);
return new UmDeviceInfo
{
Manufacturer = fields[0].Trim(),
ModelName = fields[1].Trim(),
SerialNumber = fields[2].Trim(),
BootloaderVersion = fields[3].Trim(),
SoftwareVersion = fields[4].Trim(),
ManufactureDate = fields[5].Trim(),
CalibrationDate = fields[6].Trim(),
HourCount = hours,
PbdlSchema = schema,
RawText = line.Trim(),
};
}
}

View File

@@ -0,0 +1,141 @@
using System.Globalization;
namespace FieldLogger.Models;
public enum UmMode { Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6 }
public enum UmFreqType { Active = 0, LdActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4, Sonde = 5, Radio = 6, FaultFind = 7 }
public enum UmUtility { None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6, Other = 7 }
public enum UmBatteryType { NotMeasured = 0, Alkaline = 1, AlkalineError = 2, Lithium = 3 }
/// <summary>
/// A single push-button data-log packet from the UM locating receiver
/// (PBDL schema 1, 21 comma-delimited fields).
/// </summary>
public sealed class UmLogPacket
{
public UmMode Mode { get; init; }
public int Frequency { get; init; }
public UmFreqType FreqType { get; init; }
public int Signal { get; init; } // 0 - 99
public int GainDb { get; init; } // 0 - 145 dB
public string DepthRaw { get; init; } = ""; // "X' Y\"" imperial, "X.Yym" metric, "- - -" no depth
public string CurrentRaw { get; init; } = ""; // 0 - 999mA
public int CompassAngle { get; init; } // 0 - 180 degrees (direction ambiguous)
public int GuidanceArrows { get; init; } // -300 to 300, negative = left
public int LdPhase { get; init; }
public bool Clipping { get; init; }
public int DepthCurrentSetting { get; init; } // 0 ON, 1 AUTO
public int LrArrowStyle { get; init; } // 0 OFF, 1 Style 1, 2 Style 2
public int AudioVolume { get; init; } // 0 - 3
public int AudioModulation { get; init; } // 0 AM, 1 FM
public int AudioSound { get; init; } // 0 Smooth, 1 Rough
public double BatteryVoltage { get; init; }
public UmBatteryType BatteryType { get; init; }
public int Backlight { get; init; } // 0 Low, 1 Medium, 2 High
public UmUtility Utility { get; init; }
public bool MenuInUse { get; init; }
public string RawText { get; init; } = "";
public const int FieldCount = 21;
/// <summary>Depth converted to meters, when the raw string can be interpreted; otherwise null.</summary>
public double? DepthMeters => TryParseDepthMeters(DepthRaw);
/// <summary>Current in mA parsed from the raw string, when possible.</summary>
public double? CurrentMilliamps
{
get
{
var s = CurrentRaw.Trim().TrimEnd('A', 'a').TrimEnd('m', 'M');
return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
}
}
public static double? TryParseDepthMeters(string raw)
{
raw = raw.Trim();
if (raw.Length == 0 || raw.Contains("- -"))
return null;
// Metric: "X.Yym" e.g. "1.23m"
if (raw.EndsWith("m", StringComparison.OrdinalIgnoreCase))
{
var s = raw[..^1].Trim();
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var meters))
return meters;
return null;
}
// Imperial: feet' inches" e.g. "3' 7"" or "3' 7'"
if (raw.Contains('\''))
{
var parts = raw.Split('\'', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 1 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var feet))
{
double inches = 0;
if (parts.Length >= 2)
{
var inchStr = parts[1].TrimEnd('"');
double.TryParse(inchStr, NumberStyles.Float, CultureInfo.InvariantCulture, out inches);
}
return (feet + inches / 12.0) * 0.3048;
}
}
return null;
}
/// <summary>
/// Parses a 21-field PBDL schema 1 packet. Returns null if the line does not look like a data packet.
/// </summary>
public static UmLogPacket? TryParse(string line)
{
var fields = line.Trim().Split(',');
if (fields.Length < FieldCount)
return null;
// First field must be a small integer (Mode 0-6) to qualify as a data packet.
if (!int.TryParse(fields[0], out var mode) || mode is < 0 or > 6)
return null;
try
{
return new UmLogPacket
{
Mode = (UmMode)mode,
Frequency = ParseInt(fields[1]),
FreqType = (UmFreqType)ParseInt(fields[2]),
Signal = ParseInt(fields[3]),
GainDb = ParseInt(fields[4]),
DepthRaw = fields[5].Trim(),
CurrentRaw = fields[6].Trim(),
CompassAngle = ParseInt(fields[7]),
GuidanceArrows = ParseInt(fields[8]),
LdPhase = ParseInt(fields[9]),
Clipping = ParseInt(fields[10]) != 0,
DepthCurrentSetting = ParseInt(fields[11]),
LrArrowStyle = ParseInt(fields[12]),
AudioVolume = ParseInt(fields[13]),
AudioModulation = ParseInt(fields[14]),
AudioSound = ParseInt(fields[15]),
BatteryVoltage = ParseDouble(fields[16]),
BatteryType = (UmBatteryType)ParseInt(fields[17]),
Backlight = ParseInt(fields[18]),
Utility = (UmUtility)ParseInt(fields[19]),
MenuInUse = ParseInt(fields[20]) != 0,
RawText = line.Trim(),
};
}
catch (FormatException)
{
return null;
}
}
private static int ParseInt(string s) => int.Parse(s.Trim(), CultureInfo.InvariantCulture);
private static double ParseDouble(string s) => double.Parse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,18 @@
<?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="YOUR_GOOGLE_MAPS_ANDROID_API_KEY" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- BLE: Android 12+ (API 31) runtime permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- BLE: Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
</manifest>

View File

@@ -0,0 +1,10 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace FieldLogger;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}

View File

@@ -0,0 +1,15 @@
using Android.App;
using Android.Runtime;
namespace FieldLogger;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace FieldLogger;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Required for CoreBluetooth access to the UM receiver and Maglink RTK devices. -->
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<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>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Field Logger uses your location to display logged points on the map.</string>
</dict>
</plist>

View File

@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace FieldLogger;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@@ -0,0 +1,16 @@
using System;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace FieldLogger;
class Program : MauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
static void Main(string[] args)
{
var app = new Program();
app.Run(args);
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="9" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="FieldLogger.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="FieldLogger.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:FieldLogger.WinUI">
</maui:MauiWinUIApplication>

View File

@@ -0,0 +1,24 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace FieldLogger.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="0E66E150-3452-4D9B-BA88-43820FA51A97" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<DeviceCapability Name="bluetooth" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="FieldLogger.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,9 @@
using Foundation;
namespace FieldLogger;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<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>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Field Logger uses your location to display logged points on the map.</string>
</dict>
</plist>

View File

@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace FieldLogger;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<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>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@@ -0,0 +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" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?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>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body, #map { height: 100%; margin: 0; padding: 0; }
</style>
</head>
<body>
<div id="map"></div>
<script>
let map;
let markers = [];
let infoWindow;
function initMap() {
map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 39.5, lng: -98.35 },
zoom: 4,
mapTypeId: 'hybrid'
});
infoWindow = new google.maps.InfoWindow();
}
// Called from the app with an array of point objects.
function setPoints(points) {
markers.forEach(m => m.setMap(null));
markers = [];
if (!map || !points || points.length === 0) return;
const bounds = new google.maps.LatLngBounds();
points.forEach(p => {
const pos = { lat: p.lat, lng: p.lng };
const marker = new google.maps.Marker({
position: pos,
map: map,
title: '#' + p.id + ' ' + p.utility
});
marker.addListener('click', () => {
infoWindow.setContent(
'<b>#' + p.id + ' &middot; ' + p.utility + '</b><br/>' +
'Depth: ' + p.depth + ' &middot; Current: ' + p.current + '<br/>' +
'Frequency: ' + p.frequency + ' Hz<br/>' +
'Fix: ' + p.fix + ' (&plusmn;' + p.hrms.toFixed(3) + ' m)<br/>' +
p.time);
infoWindow.open(map, marker);
});
markers.push(marker);
bounds.extend(pos);
});
if (points.length === 1) {
map.setCenter(bounds.getCenter());
map.setZoom(19);
} else {
map.fitBounds(bounds, 60);
}
}
</script>
<script async
src="https://maps.googleapis.com/maps/api/js?key=%%GOOGLE_MAPS_API_KEY%%&callback=initMap"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<?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>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,45 @@
<?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}"/>
</ResourceDictionary>

View File

@@ -0,0 +1,444 @@
<?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">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</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}}" />
</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="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</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}}" />
</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>
<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>
<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>
<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="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>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<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="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="Span">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<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" />
<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>
<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>
</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>
<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>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<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>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</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.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}}" />
</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}}" />
</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}}" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,81 @@
using Plugin.BLE;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
namespace FieldLogger.Services.Ble;
/// <summary>A device found during a BLE scan.</summary>
public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
/// <summary>Thin scan wrapper over Plugin.BLE with per-platform permission handling.</summary>
public sealed class BleScanner
{
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
public event EventHandler<DiscoveredDevice>? DeviceDiscovered;
public bool IsScanning => _adapter.IsScanning;
/// <summary>
/// Scans for BLE devices for the given duration, raising DeviceDiscovered as devices appear.
/// Only named devices are reported.
/// </summary>
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
{
await EnsurePermissionsAsync();
if (!CrossBluetoothLE.Current.IsOn)
throw new InvalidOperationException("Bluetooth is turned off.");
void OnDiscovered(object? sender, DeviceEventArgs e)
{
if (!string.IsNullOrWhiteSpace(e.Device.Name))
DeviceDiscovered?.Invoke(this, new DiscoveredDevice(e.Device.Id, e.Device.Name, e.Device.Rssi));
}
_adapter.DeviceDiscovered += OnDiscovered;
_adapter.DeviceAdvertised += OnDiscovered;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(duration);
_adapter.ScanTimeout = (int)duration.TotalMilliseconds;
try
{
await _adapter.StartScanningForDevicesAsync(cancellationToken: timeout.Token);
}
catch (OperationCanceledException)
{
// Scan window elapsed or caller cancelled - both are normal.
}
}
finally
{
_adapter.DeviceDiscovered -= OnDiscovered;
_adapter.DeviceAdvertised -= OnDiscovered;
if (_adapter.IsScanning)
await _adapter.StopScanningForDevicesAsync();
}
}
/// <summary>Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).</summary>
public static async Task EnsurePermissionsAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(31))
{
var status = await Permissions.RequestAsync<Permissions.Bluetooth>();
if (status != PermissionStatus.Granted)
throw new PermissionException("Bluetooth permission was denied.");
}
else
{
var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
throw new PermissionException("Location permission (required for BLE scanning) was denied.");
}
#else
await Task.CompletedTask;
#endif
}
}

View File

@@ -0,0 +1,165 @@
using System.Text;
using Plugin.BLE;
using Plugin.BLE.Abstractions;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
namespace FieldLogger.Services.Ble;
/// <summary>
/// Line-oriented serial client over a BLE GATT "UART" style service
/// (one notify characteristic for RX, one write characteristic for TX).
/// Both the UM receiver and the Maglink RTK receiver expose this pattern.
/// </summary>
public sealed class BleSerialClient : IAsyncDisposable
{
private readonly IAdapter _adapter;
private readonly StringBuilder _rxBuffer = new();
private IDevice? _device;
private ICharacteristic? _notifyChar;
private ICharacteristic? _writeChar;
public event EventHandler<string>? LineReceived;
public event EventHandler? Disconnected;
public bool IsConnected => _device?.State == DeviceState.Connected;
public string? DeviceName => _device?.Name;
public Guid? DeviceId => _device?.Id;
public BleSerialClient()
{
_adapter = CrossBluetoothLE.Current.Adapter;
_adapter.DeviceDisconnected += OnDeviceDisconnected;
_adapter.DeviceConnectionLost += OnDeviceConnectionLost;
}
/// <summary>
/// Connects to a known device by id, discovers the given service, and
/// enables notifications. Throws on failure.
/// </summary>
public async Task ConnectAsync(Guid deviceId, Guid serviceUuid, Guid notifyUuid, Guid writeUuid,
CancellationToken cancellationToken = default)
{
await DisconnectAsync().ConfigureAwait(false);
var device = await _adapter.ConnectToKnownDeviceAsync(
deviceId,
new ConnectParameters(autoConnect: false, forceBleTransport: true),
cancellationToken).ConfigureAwait(false);
try
{
// UM receivers request MTU 247; only has an effect on Android, harmless elsewhere.
try { await device.RequestMtuAsync(247).ConfigureAwait(false); }
catch { /* not supported on this platform */ }
var service = await device.GetServiceAsync(serviceUuid, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException($"Service {serviceUuid} not found on {device.Name}.");
var notifyChar = await service.GetCharacteristicAsync(notifyUuid).ConfigureAwait(false)
?? throw new InvalidOperationException($"Notify characteristic {notifyUuid} not found.");
var writeChar = await service.GetCharacteristicAsync(writeUuid).ConfigureAwait(false)
?? throw new InvalidOperationException($"Write characteristic {writeUuid} not found.");
writeChar.WriteType =
writeChar.Properties.HasFlag(CharacteristicPropertyType.WriteWithoutResponse)
? CharacteristicWriteType.WithoutResponse
: CharacteristicWriteType.WithResponse;
notifyChar.ValueUpdated += OnValueUpdated;
await notifyChar.StartUpdatesAsync(cancellationToken).ConfigureAwait(false);
_device = device;
_notifyChar = notifyChar;
_writeChar = writeChar;
}
catch
{
try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { }
throw;
}
}
/// <summary>Writes an ASCII string followed by CR+LF to the device.</summary>
public Task WriteLineAsync(string text, CancellationToken cancellationToken = default)
=> WriteAsync(text + "\r\n", cancellationToken);
public async Task WriteAsync(string text, CancellationToken cancellationToken = default)
{
var writeChar = _writeChar ?? throw new InvalidOperationException("Not connected.");
await writeChar.WriteAsync(Encoding.ASCII.GetBytes(text), cancellationToken).ConfigureAwait(false);
}
public async Task DisconnectAsync()
{
var device = _device;
var notifyChar = _notifyChar;
_device = null;
_notifyChar = null;
_writeChar = null;
if (notifyChar is not null)
{
notifyChar.ValueUpdated -= OnValueUpdated;
try { await notifyChar.StopUpdatesAsync().ConfigureAwait(false); } catch { }
}
if (device is not null)
{
try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { }
device.Dispose();
}
lock (_rxBuffer)
_rxBuffer.Clear();
}
private void OnValueUpdated(object? sender, CharacteristicUpdatedEventArgs e)
{
var bytes = e.Characteristic.Value;
if (bytes is null || bytes.Length == 0)
return;
List<string> lines = new();
lock (_rxBuffer)
{
_rxBuffer.Append(Encoding.ASCII.GetString(bytes));
var buffered = _rxBuffer.ToString();
int newline;
while ((newline = buffered.IndexOf('\n')) >= 0)
{
var line = buffered[..newline].TrimEnd('\r');
buffered = buffered[(newline + 1)..];
if (line.Length > 0)
lines.Add(line);
}
_rxBuffer.Clear();
_rxBuffer.Append(buffered);
}
foreach (var line in lines)
LineReceived?.Invoke(this, line);
}
private void OnDeviceDisconnected(object? sender, DeviceEventArgs e) => HandleDisconnect(e.Device);
private void OnDeviceConnectionLost(object? sender, DeviceErrorEventArgs e) => HandleDisconnect(e.Device);
private void HandleDisconnect(IDevice device)
{
if (_device is null || device.Id != _device.Id)
return;
_device = null;
_notifyChar = null;
_writeChar = null;
Disconnected?.Invoke(this, EventArgs.Empty);
}
public async ValueTask DisposeAsync()
{
_adapter.DeviceDisconnected -= OnDeviceDisconnected;
_adapter.DeviceConnectionLost -= OnDeviceConnectionLost;
await DisconnectAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,90 @@
using FieldLogger.Models;
using SQLite;
namespace FieldLogger.Services.Data;
/// <summary>Local SQLite store for jobs and logged points.</summary>
public sealed class AppDatabase
{
private readonly Lazy<Task<SQLiteAsyncConnection>> _connection;
public AppDatabase()
{
_connection = new Lazy<Task<SQLiteAsyncConnection>>(async () =>
{
var path = Path.Combine(FileSystem.AppDataDirectory, "fieldlogger.db3");
var db = new SQLiteAsyncConnection(path,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
await db.CreateTableAsync<Job>();
await db.CreateTableAsync<LoggedPoint>();
return db;
});
}
private Task<SQLiteAsyncConnection> Db => _connection.Value;
// ---- Jobs ----
public async Task<List<Job>> GetJobsAsync()
{
var db = await Db;
return await db.Table<Job>().OrderByDescending(j => j.CreatedUtc).ToListAsync();
}
public async Task<Job?> GetJobAsync(int id)
{
var db = await Db;
return await db.Table<Job>().Where(j => j.Id == id).FirstOrDefaultAsync();
}
public async Task<Job> CreateJobAsync(string name, string notes = "")
{
var db = await Db;
var job = new Job { Name = name, Notes = notes };
await db.InsertAsync(job);
return job;
}
public async Task UpdateJobAsync(Job job)
{
var db = await Db;
await db.UpdateAsync(job);
}
public async Task DeleteJobAsync(int jobId)
{
var db = await Db;
await db.Table<LoggedPoint>().DeleteAsync(p => p.JobId == jobId);
await db.DeleteAsync<Job>(jobId);
}
// ---- Points ----
public async Task<int> AddPointAsync(LoggedPoint point)
{
var db = await Db;
await db.InsertAsync(point);
return point.Id;
}
public async Task<List<LoggedPoint>> GetPointsAsync(int jobId)
{
var db = await Db;
return await db.Table<LoggedPoint>()
.Where(p => p.JobId == jobId)
.OrderBy(p => p.TimestampUtc)
.ToListAsync();
}
public async Task<int> GetPointCountAsync(int jobId)
{
var db = await Db;
return await db.Table<LoggedPoint>().Where(p => p.JobId == jobId).CountAsync();
}
public async Task DeletePointAsync(int pointId)
{
var db = await Db;
await db.DeleteAsync<LoggedPoint>(pointId);
}
}

View File

@@ -0,0 +1,175 @@
using CommunityToolkit.Mvvm.ComponentModel;
using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
public enum ConnectionState { Disconnected, Connecting, Connected }
/// <summary>
/// Owns the lifetime of both device connections: auto-connects to saved devices
/// at startup, reconnects with backoff when a connection drops, and exposes
/// bindable state for the UI.
/// </summary>
public sealed partial class DeviceConnectionManager : ObservableObject
{
private static readonly TimeSpan[] RetryDelays =
[TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)];
private readonly UmReceiverService _locator;
private readonly MaglinkService _gps;
private readonly SettingsService _settings;
private readonly ILogger<DeviceConnectionManager> _logger;
private CancellationTokenSource? _locatorCts;
private CancellationTokenSource? _gpsCts;
[ObservableProperty]
private ConnectionState _locatorState;
[ObservableProperty]
private ConnectionState _gpsState;
[ObservableProperty]
private string? _locatorName;
[ObservableProperty]
private string? _gpsName;
public UmReceiverService Locator => _locator;
public MaglinkService Gps => _gps;
public DeviceConnectionManager(UmReceiverService locator, MaglinkService gps,
SettingsService settings, ILogger<DeviceConnectionManager> logger)
{
_locator = locator;
_gps = gps;
_settings = settings;
_logger = logger;
_locator.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.Locator);
_gps.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.RtkGps);
LocatorName = settings.GetSavedDeviceName(DeviceKind.Locator);
GpsName = settings.GetSavedDeviceName(DeviceKind.RtkGps);
}
/// <summary>True when a device of this kind has been selected at least once.</summary>
public bool HasSavedDevice(DeviceKind kind) => _settings.GetSavedDeviceId(kind) is not null;
/// <summary>Kicks off auto-connect attempts for every saved device. Safe to call repeatedly.</summary>
public void Start()
{
if (HasSavedDevice(DeviceKind.Locator) && LocatorState == ConnectionState.Disconnected)
_ = ConnectLoopAsync(DeviceKind.Locator);
if (HasSavedDevice(DeviceKind.RtkGps) && GpsState == ConnectionState.Disconnected)
_ = ConnectLoopAsync(DeviceKind.RtkGps);
}
/// <summary>Saves a newly selected device and connects to it, replacing any previous device.</summary>
public async Task UseDeviceAsync(DeviceKind kind, Guid id, string name)
{
CancelLoop(kind);
await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync());
_settings.SaveDevice(kind, id, name);
if (kind == DeviceKind.Locator)
LocatorName = name;
else
GpsName = name;
_ = ConnectLoopAsync(kind);
}
public async Task ForgetDeviceAsync(DeviceKind kind)
{
CancelLoop(kind);
await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync());
_settings.ClearDevice(kind);
if (kind == DeviceKind.Locator)
{
LocatorName = null;
LocatorState = ConnectionState.Disconnected;
}
else
{
GpsName = null;
GpsState = ConnectionState.Disconnected;
}
}
private void OnDeviceDropped(DeviceKind kind)
{
_logger.LogInformation("{Kind} connection lost; scheduling reconnect", kind);
SetState(kind, ConnectionState.Disconnected);
_ = ConnectLoopAsync(kind);
}
private async Task ConnectLoopAsync(DeviceKind kind)
{
var id = _settings.GetSavedDeviceId(kind);
if (id is null)
return;
CancelLoop(kind);
var cts = new CancellationTokenSource();
if (kind == DeviceKind.Locator)
_locatorCts = cts;
else
_gpsCts = cts;
SetState(kind, ConnectionState.Connecting);
for (var attempt = 0; !cts.Token.IsCancellationRequested; attempt++)
{
try
{
await BleScanner.EnsurePermissionsAsync();
if (kind == DeviceKind.Locator)
await _locator.ConnectAsync(id.Value, cts.Token);
else
await _gps.ConnectAsync(id.Value, cts.Token);
SetState(kind, ConnectionState.Connected);
_logger.LogInformation("{Kind} connected", kind);
return;
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed", kind, attempt + 1);
var delay = RetryDelays[Math.Min(attempt, RetryDelays.Length - 1)];
try { await Task.Delay(delay, cts.Token); }
catch (OperationCanceledException) { return; }
}
}
}
private void CancelLoop(DeviceKind kind)
{
var cts = kind == DeviceKind.Locator ? _locatorCts : _gpsCts;
cts?.Cancel();
cts?.Dispose();
if (kind == DeviceKind.Locator)
_locatorCts = null;
else
_gpsCts = null;
}
private void SetState(DeviceKind kind, ConnectionState state)
{
// Property change notifications must fire on the UI thread for bindings.
MainThread.BeginInvokeOnMainThread(() =>
{
if (kind == DeviceKind.Locator)
LocatorState = state;
else
GpsState = state;
});
}
}

View File

@@ -0,0 +1,98 @@
using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Manages the connection to the Maglink (H11) RTK GNSS receiver: parses the
/// $GNPOS / $GNDEV custom NMEA stream and sends AT configuration commands.
/// </summary>
public sealed class MaglinkService : IAsyncDisposable
{
public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb");
public static readonly Guid NotifyUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb");
public static readonly Guid WriteUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb");
private readonly BleSerialClient _client = new();
private readonly ILogger<MaglinkService> _logger;
public GnssFix? LatestFix { get; private set; }
public GnssDeviceInfo? DeviceInfo { get; private set; }
public bool IsConnected => _client.IsConnected;
public string? DeviceName => _client.DeviceName;
public event EventHandler<GnssFix>? FixReceived;
public event EventHandler<GnssDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
/// <summary>Device-name filter for scan results (ML-*).</summary>
public static bool IsMaglinkName(string? name) =>
name is not null && name.StartsWith("ML-", StringComparison.OrdinalIgnoreCase);
public MaglinkService(ILogger<MaglinkService> logger)
{
_logger = logger;
_client.LineReceived += OnLineReceived;
_client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty);
}
public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default)
{
await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken);
// Custom mode with GNPOS + GNDEV only - everything the app needs, minimal traffic.
await SendCommandAsync("AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0", cancellationToken);
}
/// <summary>Sends an AT command (terminator appended automatically).</summary>
public Task SendCommandAsync(string command, CancellationToken cancellationToken = default)
=> _client.WriteLineAsync(command, cancellationToken);
public Task DisconnectAsync() => _client.DisconnectAsync();
/// <summary>A fix received within the last few seconds, or null if the stream has gone stale.</summary>
public GnssFix? FreshFix(TimeSpan? maxAge = null)
{
var fix = LatestFix;
if (fix is null)
return null;
return DateTime.UtcNow - fix.ReceivedUtc <= (maxAge ?? TimeSpan.FromSeconds(5)) ? fix : null;
}
private void OnLineReceived(object? sender, string line)
{
if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
{
if (!NmeaSentence.VerifyChecksum(line))
{
_logger.LogWarning("GNPOS checksum failed: {Line}", line);
return;
}
var fix = GnssFix.TryParse(line);
if (fix is not null)
{
LatestFix = fix;
FixReceived?.Invoke(this, fix);
}
}
else if (line.StartsWith("$GNDEV,", StringComparison.Ordinal))
{
if (!NmeaSentence.VerifyChecksum(line))
return;
var info = GnssDeviceInfo.TryParse(line);
if (info is not null)
{
DeviceInfo = info;
DeviceInfoReceived?.Invoke(this, info);
}
}
else
{
// AT command responses and anything else.
_logger.LogDebug("Maglink rx: {Line}", line);
}
}
public ValueTask DisposeAsync() => _client.DisposeAsync();
}

View File

@@ -0,0 +1,67 @@
using FieldLogger.Models;
using FieldLogger.Services.Data;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Joins UM receiver log packets with the current GNSS fix and persists them
/// under the active job. Runs for the lifetime of the app.
/// </summary>
public sealed class PointLogger
{
private readonly UmReceiverService _locator;
private readonly MaglinkService _gps;
private readonly AppDatabase _database;
private readonly SettingsService _settings;
private readonly ILogger<PointLogger> _logger;
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
public event EventHandler<LoggedPoint>? PointSaved;
/// <summary>Raised when a packet arrives but no active job is selected, so the point was dropped.</summary>
public event EventHandler? PacketIgnoredNoJob;
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
SettingsService settings, ILogger<PointLogger> logger)
{
_locator = locator;
_gps = gps;
_database = database;
_settings = settings;
_logger = logger;
_locator.PacketReceived += OnPacketReceived;
}
private void OnPacketReceived(object? sender, UmLogPacket packet)
{
_ = HandlePacketAsync(packet);
}
private async Task HandlePacketAsync(UmLogPacket packet)
{
try
{
var jobId = _settings.ActiveJobId;
if (jobId is null)
{
_logger.LogWarning("Log packet received but no active job; point dropped");
MainThread.BeginInvokeOnMainThread(() => PacketIgnoredNoJob?.Invoke(this, EventArgs.Empty));
return;
}
var fix = _gps.FreshFix();
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
await _database.AddPointAsync(point);
_logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})",
point.Id, jobId, point.GpsValid);
MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save logged point");
}
}
}

View File

@@ -0,0 +1,58 @@
using FieldLogger.Models;
namespace FieldLogger.Services;
/// <summary>Persisted app settings: paired device identities, active job, map key.</summary>
public sealed class SettingsService
{
private const string LocatorIdKey = "device.locator.id";
private const string LocatorNameKey = "device.locator.name";
private const string RtkIdKey = "device.rtk.id";
private const string RtkNameKey = "device.rtk.name";
private const string ActiveJobKey = "job.active.id";
private const string MapsApiKeyKey = "maps.apikey";
public Guid? GetSavedDeviceId(DeviceKind kind)
{
var raw = Preferences.Default.Get(IdKey(kind), string.Empty);
return Guid.TryParse(raw, out var id) ? id : null;
}
public string? GetSavedDeviceName(DeviceKind kind)
{
var name = Preferences.Default.Get(NameKey(kind), string.Empty);
return string.IsNullOrEmpty(name) ? null : name;
}
public void SaveDevice(DeviceKind kind, Guid id, string name)
{
Preferences.Default.Set(IdKey(kind), id.ToString());
Preferences.Default.Set(NameKey(kind), name);
}
public void ClearDevice(DeviceKind kind)
{
Preferences.Default.Remove(IdKey(kind));
Preferences.Default.Remove(NameKey(kind));
}
public int? ActiveJobId
{
get
{
var id = Preferences.Default.Get(ActiveJobKey, 0);
return id > 0 ? id : null;
}
set => Preferences.Default.Set(ActiveJobKey, value ?? 0);
}
/// <summary>Google Maps JavaScript API key used by the Windows WebView map.</summary>
public string GoogleMapsApiKey
{
get => Preferences.Default.Get(MapsApiKeyKey, string.Empty);
set => Preferences.Default.Set(MapsApiKeyKey, value);
}
private static string IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey;
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
}

View File

@@ -0,0 +1,27 @@
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.
/// </summary>
public interface IMqttSyncService
{
bool IsConnected { get; }
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 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;
}

View File

@@ -0,0 +1,88 @@
using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Manages the connection to an Underground Magnetics locating receiver and
/// its push-button data-logging (PBDL) protocol.
/// </summary>
public sealed class UmReceiverService : IAsyncDisposable
{
// UM Receiver BLE External Logging API v1.2:
// serial port service with notify (RX) and write-no-response (TX) characteristics.
public static readonly Guid ServiceUuid = Guid.Parse("554d0000-261f-677e-a6f1-54c57aa996d4");
public static readonly Guid NotifyUuid = Guid.Parse("554d0001-261f-677e-a6f1-54c57aa996d4");
public static readonly Guid WriteUuid = Guid.Parse("554d0002-261f-677e-a6f1-54c57aa996d4");
private readonly BleSerialClient _client = new();
private readonly ILogger<UmReceiverService> _logger;
public UmDeviceInfo? DeviceInfo { get; private set; }
public bool IsConnected => _client.IsConnected;
public string? DeviceName => _client.DeviceName;
/// <summary>Raised when the operator presses the log button on the receiver.</summary>
public event EventHandler<UmLogPacket>? PacketReceived;
public event EventHandler<UmDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
public UmReceiverService(ILogger<UmReceiverService> logger)
{
_logger = logger;
_client.LineReceived += OnLineReceived;
_client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty);
}
/// <summary>Device-name filter for scan results (UMRX_* for most brands, DT100_* for Leica).</summary>
public static bool IsUmReceiverName(string? name) =>
name is not null &&
(name.StartsWith("UMRX", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("DT100", StringComparison.OrdinalIgnoreCase));
public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default)
{
await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken);
await EnableLoggingAsync(cancellationToken);
}
/// <summary>Enables push-button data logging. The first enable triggers the info string.</summary>
public Task EnableLoggingAsync(CancellationToken cancellationToken = default)
=> _client.WriteLineAsync("$UMPBDL,1", cancellationToken);
public Task DisableLoggingAsync(CancellationToken cancellationToken = default)
=> _client.WriteLineAsync("$UMPBDL,0", cancellationToken);
public Task DisconnectAsync() => _client.DisconnectAsync();
private void OnLineReceived(object? sender, string line)
{
_logger.LogDebug("UM rx: {Line}", line);
// Bare command acknowledgements.
var trimmed = line.Trim();
if (trimmed.Equals("OK", StringComparison.OrdinalIgnoreCase) ||
trimmed.Equals("ERROR", StringComparison.OrdinalIgnoreCase))
return;
var packet = UmLogPacket.TryParse(line);
if (packet is not null)
{
PacketReceived?.Invoke(this, packet);
return;
}
var info = UmDeviceInfo.TryParse(line);
if (info is not null)
{
DeviceInfo = info;
DeviceInfoReceived?.Invoke(this, info);
return;
}
_logger.LogWarning("UM receiver sent unrecognized line: {Line}", line);
}
public ValueTask DisposeAsync() => _client.DisposeAsync();
}

View File

@@ -0,0 +1,107 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Ble;
namespace FieldLogger.ViewModels;
[QueryProperty(nameof(KindName), "kind")]
public sealed partial class DeviceScanViewModel : ObservableObject
{
private readonly BleScanner _scanner;
private readonly DeviceConnectionManager _manager;
private CancellationTokenSource? _scanCts;
[ObservableProperty]
private string _kindName = nameof(DeviceKind.Locator);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ScanButtonText))]
private bool _isScanning;
[ObservableProperty]
private string _title = "Select Device";
[ObservableProperty]
private string _hint = "";
public ObservableCollection<DiscoveredDevice> Devices { get; } = new();
public DeviceKind Kind => Enum.TryParse<DeviceKind>(KindName, out var k) ? k : DeviceKind.Locator;
public string ScanButtonText => IsScanning ? "Scanning…" : "Scan Again";
public DeviceScanViewModel(BleScanner scanner, DeviceConnectionManager manager)
{
_scanner = scanner;
_manager = manager;
_scanner.DeviceDiscovered += OnDeviceDiscovered;
}
partial void OnKindNameChanged(string value)
{
Title = Kind == DeviceKind.Locator ? "Select Locating Receiver" : "Select RTK GPS Receiver";
Hint = Kind == DeviceKind.Locator
? "Looking for UM receivers (UMRX / DT100). Make sure Bluetooth is enabled on the receiver."
: "Looking for Maglink RTK receivers. Make sure the receiver is powered on.";
}
[RelayCommand]
public async Task ScanAsync()
{
if (IsScanning)
return;
Devices.Clear();
IsScanning = true;
_scanCts = new CancellationTokenSource();
try
{
await _scanner.ScanAsync(TimeSpan.FromSeconds(12), _scanCts.Token);
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Scan Failed", ex.Message, "OK");
}
finally
{
IsScanning = false;
_scanCts?.Dispose();
_scanCts = null;
}
}
public void StopScan() => _scanCts?.Cancel();
[RelayCommand]
private async Task SelectAsync(DiscoveredDevice device)
{
StopScan();
await _manager.UseDeviceAsync(Kind, device.Id, device.Name);
await Shell.Current.GoToAsync("..");
}
private void OnDeviceDiscovered(object? sender, DiscoveredDevice device)
{
// Filter by device name prefix based on device type.
if (Kind == DeviceKind.Locator && !UmReceiverService.IsUmReceiverName(device.Name))
return;
if (Kind == DeviceKind.Maglink && !MaglinkService.IsMaglinkName(device.Name))
return;
MainThread.BeginInvokeOnMainThread(() =>
{
var existing = Devices.FirstOrDefault(d => d.Id == device.Id);
if (existing is not null)
Devices.Remove(existing);
// Keep the list sorted by signal strength.
var index = 0;
while (index < Devices.Count && Devices[index].Rssi >= device.Rssi)
index++;
Devices.Insert(index, device);
});
}
}

View File

@@ -0,0 +1,149 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Data;
namespace FieldLogger.ViewModels;
public sealed partial class HomeViewModel : ObservableObject
{
private readonly DeviceConnectionManager _manager;
private readonly MaglinkService _gps;
private readonly PointLogger _pointLogger;
private readonly AppDatabase _database;
private readonly SettingsService _settings;
public DeviceConnectionManager Manager => _manager;
[ObservableProperty]
private string _activeJobName = "No active job";
[ObservableProperty]
private bool _hasActiveJob;
[ObservableProperty]
private int _pointCount;
[ObservableProperty]
private string _fixSummary = "Waiting for GPS…";
[ObservableProperty]
private string _fixStatusLabel = "—";
[ObservableProperty]
private string _fixAccuracy = "";
public ObservableCollection<LoggedPoint> RecentPoints { get; } = new();
public HomeViewModel(DeviceConnectionManager manager, MaglinkService gps,
PointLogger pointLogger, AppDatabase database, SettingsService settings)
{
_manager = manager;
_gps = gps;
_pointLogger = pointLogger;
_database = database;
_settings = settings;
_gps.FixReceived += OnFixReceived;
_pointLogger.PointSaved += OnPointSaved;
_pointLogger.PacketIgnoredNoJob += OnPacketIgnoredNoJob;
}
/// <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();
}
public async Task RefreshJobAsync()
{
var jobId = _settings.ActiveJobId;
if (jobId is null)
{
HasActiveJob = false;
ActiveJobName = "No active job";
PointCount = 0;
RecentPoints.Clear();
return;
}
var job = await _database.GetJobAsync(jobId.Value);
if (job is null)
{
_settings.ActiveJobId = null;
await RefreshJobAsync();
return;
}
HasActiveJob = true;
ActiveJobName = job.Name;
PointCount = await _database.GetPointCountAsync(job.Id);
var points = await _database.GetPointsAsync(job.Id);
RecentPoints.Clear();
foreach (var p in points.OrderByDescending(p => p.TimestampUtc).Take(10))
RecentPoints.Add(p);
}
[RelayCommand]
private async Task NewJobAsync()
{
var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:",
placeholder: $"Job {DateTime.Now:yyyy-MM-dd}");
if (string.IsNullOrWhiteSpace(name))
return;
var job = await _database.CreateJobAsync(name.Trim());
_settings.ActiveJobId = job.Id;
await RefreshJobAsync();
}
[RelayCommand]
private Task SelectJobAsync() => Shell.Current.GoToAsync("//jobs");
[RelayCommand]
private Task SelectLocatorAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.Locator}");
[RelayCommand]
private Task SelectGpsAsync() => Shell.Current.GoToAsync($"devicescan?kind={DeviceKind.RtkGps}");
[RelayCommand]
private Task ViewMapAsync() => Shell.Current.GoToAsync("//map");
private void OnFixReceived(object? sender, GnssFix fix)
{
MainThread.BeginInvokeOnMainThread(() =>
{
FixStatusLabel = fix.StatusLabel;
FixSummary = fix.HasPosition
? $"{fix.Latitude:F8}, {fix.Longitude:F8} · {fix.AltitudeCorrected:F2} m"
: "No position";
FixAccuracy = $"H ±{fix.Hrms:F3} m · V ±{fix.Vrms:F3} m · {fix.SatellitesUsed} sats · batt {fix.BatteryPercent}%";
});
}
private void OnPointSaved(object? sender, LoggedPoint point)
{
RecentPoints.Insert(0, point);
while (RecentPoints.Count > 10)
RecentPoints.RemoveAt(RecentPoints.Count - 1);
PointCount++;
}
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");
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services.Data;
namespace FieldLogger.ViewModels;
[QueryProperty(nameof(JobId), "jobId")]
public sealed partial class JobDetailViewModel : ObservableObject
{
private readonly AppDatabase _database;
[ObservableProperty]
private int _jobId;
[ObservableProperty]
private string _jobName = "";
[ObservableProperty]
private int _pointCount;
public ObservableCollection<LoggedPoint> Points { get; } = new();
public JobDetailViewModel(AppDatabase database)
{
_database = database;
}
public async Task RefreshAsync()
{
var job = await _database.GetJobAsync(JobId);
if (job is null)
return;
JobName = job.Name;
var points = await _database.GetPointsAsync(JobId);
PointCount = points.Count;
Points.Clear();
foreach (var p in points)
Points.Add(p);
}
[RelayCommand]
private Task ViewMapAsync() => Shell.Current.GoToAsync($"//map?jobId={JobId}");
[RelayCommand]
private async Task DeletePointAsync(LoggedPoint point)
{
var confirmed = await Shell.Current.DisplayAlert("Delete Point",
$"Delete point #{point.Id} logged {point.TimestampUtc.ToLocalTime():g}?", "Delete", "Cancel");
if (!confirmed)
return;
await _database.DeletePointAsync(point.Id);
Points.Remove(point);
PointCount = Points.Count;
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Data;
namespace FieldLogger.ViewModels;
public sealed partial class JobListItem : ObservableObject
{
public required Job Job { get; init; }
[ObservableProperty]
private int _pointCount;
[ObservableProperty]
private bool _isActive;
public string Name => Job.Name;
public string CreatedLabel => Job.CreatedUtc.ToLocalTime().ToString("g");
}
public sealed partial class JobsViewModel : ObservableObject
{
private readonly AppDatabase _database;
private readonly SettingsService _settings;
public ObservableCollection<JobListItem> Jobs { get; } = new();
[ObservableProperty]
private bool _isBusy;
public JobsViewModel(AppDatabase database, SettingsService settings)
{
_database = database;
_settings = settings;
}
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var jobs = await _database.GetJobsAsync();
var activeId = _settings.ActiveJobId;
Jobs.Clear();
foreach (var job in jobs)
{
Jobs.Add(new JobListItem
{
Job = job,
PointCount = await _database.GetPointCountAsync(job.Id),
IsActive = job.Id == activeId,
});
}
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task NewJobAsync()
{
var name = await Shell.Current.DisplayPromptAsync("New Job", "Job name:",
placeholder: $"Job {DateTime.Now:yyyy-MM-dd}");
if (string.IsNullOrWhiteSpace(name))
return;
var job = await _database.CreateJobAsync(name.Trim());
_settings.ActiveJobId = job.Id;
await RefreshAsync();
}
[RelayCommand]
private async Task SetActiveAsync(JobListItem item)
{
_settings.ActiveJobId = item.Job.Id;
foreach (var j in Jobs)
j.IsActive = j.Job.Id == item.Job.Id;
await Task.CompletedTask;
}
[RelayCommand]
private Task OpenAsync(JobListItem item)
=> Shell.Current.GoToAsync($"jobdetail?jobId={item.Job.Id}");
[RelayCommand]
private async Task DeleteAsync(JobListItem item)
{
var confirmed = await Shell.Current.DisplayAlert("Delete Job",
$"Delete \"{item.Job.Name}\" and its {item.PointCount} logged points?", "Delete", "Cancel");
if (!confirmed)
return;
await _database.DeleteJobAsync(item.Job.Id);
if (_settings.ActiveJobId == item.Job.Id)
_settings.ActiveJobId = null;
await RefreshAsync();
}
}

View File

@@ -0,0 +1,53 @@
using CommunityToolkit.Mvvm.ComponentModel;
using FieldLogger.Models;
using FieldLogger.Services;
using FieldLogger.Services.Data;
namespace FieldLogger.ViewModels;
[QueryProperty(nameof(JobId), "jobId")]
public sealed partial class MapViewModel : ObservableObject
{
private readonly AppDatabase _database;
private readonly SettingsService _settings;
/// <summary>Job to display; 0 means "use the active job".</summary>
[ObservableProperty]
private int _jobId;
[ObservableProperty]
private string _jobName = "";
[ObservableProperty]
private string _statusText = "";
public SettingsService Settings => _settings;
public MapViewModel(AppDatabase database, SettingsService settings)
{
_database = database;
_settings = settings;
}
/// <summary>Loads the points to plot (only points with a valid GNSS position).</summary>
public async Task<List<LoggedPoint>> LoadPointsAsync()
{
var jobId = JobId > 0 ? JobId : _settings.ActiveJobId;
if (jobId is null)
{
JobName = "";
StatusText = "No job selected. Create or activate a job to see its points.";
return new List<LoggedPoint>();
}
var job = await _database.GetJobAsync(jobId.Value);
JobName = job?.Name ?? "";
var all = await _database.GetPointsAsync(jobId.Value);
var plottable = all.Where(p => p.GpsValid).ToList();
StatusText = plottable.Count == all.Count
? $"{JobName}: {all.Count} points"
: $"{JobName}: {plottable.Count} of {all.Count} points have GPS positions";
return plottable;
}
}

View File

@@ -0,0 +1,52 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
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)
{
_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 async Task ForgetLocatorAsync()
{
if (await ConfirmForgetAsync(_manager.LocatorName ?? "locating receiver"))
await _manager.ForgetDeviceAsync(DeviceKind.Locator);
}
[RelayCommand]
private async Task ForgetGpsAsync()
{
if (await ConfirmForgetAsync(_manager.GpsName ?? "RTK GPS receiver"))
await _manager.ForgetDeviceAsync(DeviceKind.RtkGps);
}
private static Task<bool> ConfirmForgetAsync(string name) =>
Shell.Current.DisplayAlert("Forget Device",
$"Forget \"{name}\"? The app will no longer auto-connect to it.", "Forget", "Cancel");
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8" ?>
<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:ble="clr-namespace:FieldLogger.Services.Ble"
x:Class="FieldLogger.Views.DeviceScanPage"
x:DataType="vm:DeviceScanViewModel"
Title="{Binding Title}">
<Grid RowDefinitions="Auto,Auto,*" Padding="16" RowSpacing="12">
<Label Grid.Row="0" Text="{Binding Hint}" FontSize="13" TextColor="Gray" />
<HorizontalStackLayout Grid.Row="1" Spacing="12">
<Button Text="{Binding ScanButtonText}" Command="{Binding ScanCommand}" IsEnabled="{Binding IsScanning, Converter={StaticResource InvertBool}}" />
<ActivityIndicator IsRunning="{Binding IsScanning}" VerticalOptions="Center" />
</HorizontalStackLayout>
<CollectionView Grid.Row="2" ItemsSource="{Binding Devices}">
<CollectionView.EmptyView>
<Label Text="No devices found yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="ble:DiscoveredDevice">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Border.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding SelectCommand, Source={RelativeSource AncestorType={x:Type vm:DeviceScanViewModel}}}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Grid ColumnDefinitions="*,Auto">
<VerticalStackLayout Spacing="2">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Id}" FontSize="11" TextColor="Gray" />
</VerticalStackLayout>
<Label Grid.Column="1" VerticalOptions="Center" FontSize="12" TextColor="Gray"
Text="{Binding Rssi, StringFormat='{0} dBm'}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,26 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class DeviceScanPage : ContentPage
{
private readonly DeviceScanViewModel _viewModel;
public DeviceScanPage(DeviceScanViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.ScanAsync();
}
protected override void OnDisappearing()
{
_viewModel.StopScan();
base.OnDisappearing();
}
}

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8" ?>
<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:models="clr-namespace:FieldLogger.Models"
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">
<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" />
</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>
</Grid>
<Label Text="Press the log button on the receiver to capture a point."
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
</VerticalStackLayout>
</Border>
<!-- Recent points -->
<Label Text="Recent Points" FontAttributes="Bold" Margin="4,8,0,0" />
<CollectionView ItemsSource="{Binding RecentPoints}" HeightRequest="320">
<CollectionView.EmptyView>
<Label Text="No points logged yet." TextColor="Gray" Margin="8" />
</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">
<Label.Text>
<MultiBinding StringFormat="{}{0} · {1} Hz · depth {2}">
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
<Binding Path="Frequency" />
<Binding Path="DepthRaw" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
Text="{Binding TimestampUtc, StringFormat='{0:HH:mm:ss}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0:F7}, {1:F7} · {2}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
</MultiBinding>
</Label.Text>
</Label>
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,29 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class HomePage : ContentPage
{
private readonly HomeViewModel _viewModel;
private bool _initialized;
public HomePage(HomeViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (!_initialized)
{
_initialized = true;
await _viewModel.InitializeAsync();
}
else
{
await _viewModel.RefreshJobAsync();
}
}
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8" ?>
<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:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.JobDetailPage"
x:DataType="vm:JobDetailViewModel"
Title="{Binding JobName}">
<ContentPage.ToolbarItems>
<ToolbarItem Text="Map" Command="{Binding ViewMapCommand}" />
</ContentPage.ToolbarItems>
<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.EmptyView>
<Label Text="No points logged for this job yet." TextColor="Gray" Margin="8" />
</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">
<Label.Text>
<MultiBinding StringFormat="{}#{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"
Text="{Binding TimestampUtc, StringFormat='{0:g}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12">
<Label.Text>
<MultiBinding StringFormat="Depth {0} · Current {1} · Signal {2} · Gain {3} dB">
<Binding Path="DepthRaw" />
<Binding Path="CurrentRaw" />
<Binding Path="Signal" />
<Binding Path="GainDb" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="2" Grid.ColumnSpan="2" FontSize="12" FontFamily="Courier New">
<Label.Text>
<MultiBinding StringFormat="{}{0:F8}, {1:F8}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="3" FontSize="11" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0} · H ±{1:F3} m · {2} sats">
<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"
Command="{Binding DeletePointCommand, Source={RelativeSource AncestorType={x:Type vm:JobDetailViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,20 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class JobDetailPage : ContentPage
{
private readonly JobDetailViewModel _viewModel;
public JobDetailPage(JobDetailViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.RefreshAsync();
}
}

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 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.JobsPage"
x:DataType="vm:JobsViewModel"
Title="Jobs">
<ContentPage.ToolbarItems>
<ToolbarItem Text="New" Command="{Binding NewJobCommand}" />
</ContentPage.ToolbarItems>
<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" />
</VerticalStackLayout>
</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.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" />
</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"
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"
Command="{Binding DeleteCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,20 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class JobsPage : ContentPage
{
private readonly JobsViewModel _viewModel;
public JobsPage(JobsViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.RefreshAsync();
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 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.MapPage"
x:DataType="vm:MapViewModel"
Title="Map">
<Grid RowDefinitions="Auto,*">
<Label Grid.Row="0" Text="{Binding StatusText}" FontSize="12" TextColor="Gray" Padding="12,6" />
<!-- Populated in code-behind: native Map control on iOS/Android/macOS, Google Maps WebView on Windows. -->
<ContentView Grid.Row="1" x:Name="MapHost" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,151 @@
using FieldLogger.Models;
using FieldLogger.ViewModels;
#if WINDOWS
using System.Text.Json;
#else
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Maps;
using Map = Microsoft.Maui.Controls.Maps.Map;
#endif
namespace FieldLogger.Views;
public partial class MapPage : ContentPage
{
private readonly MapViewModel _viewModel;
#if WINDOWS
private WebView? _webView;
private bool _webMapReady;
private List<LoggedPoint>? _pendingPoints;
#else
private Map? _map;
#endif
public MapPage(MapViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await EnsureMapCreatedAsync();
var points = await _viewModel.LoadPointsAsync();
ShowPoints(points);
}
#if WINDOWS
private async Task EnsureMapCreatedAsync()
{
if (_webView is not null)
return;
var apiKey = _viewModel.Settings.GoogleMapsApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
MapHost.Content = new Label
{
Text = "Google Maps API key is not set.\nEnter your Maps JavaScript API key on the Settings page.",
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
HorizontalTextAlignment = TextAlignment.Center,
};
return;
}
using var stream = await FileSystem.OpenAppPackageFileAsync("map.html");
using var reader = new StreamReader(stream);
var html = (await reader.ReadToEndAsync()).Replace("%%GOOGLE_MAPS_API_KEY%%", apiKey);
_webView = new WebView { Source = new HtmlWebViewSource { Html = html } };
_webView.Navigated += async (_, _) =>
{
_webMapReady = true;
if (_pendingPoints is not null)
{
var pts = _pendingPoints;
_pendingPoints = null;
await PushPointsToWebAsync(pts);
}
};
MapHost.Content = _webView;
}
private async void ShowPoints(List<LoggedPoint> points)
{
if (_webView is null)
return;
if (!_webMapReady)
{
_pendingPoints = points;
return;
}
await PushPointsToWebAsync(points);
}
private async Task PushPointsToWebAsync(List<LoggedPoint> points)
{
var payload = points.Select(p => new
{
id = p.Id,
lat = p.Latitude,
lng = p.Longitude,
utility = ((UmUtility)p.Utility).ToString(),
depth = p.DepthRaw,
current = p.CurrentRaw,
frequency = p.Frequency,
fix = ((GnssFixStatus)p.FixStatus).ToString(),
hrms = p.Hrms,
time = p.TimestampUtc.ToLocalTime().ToString("g"),
});
var json = JsonSerializer.Serialize(payload);
await _webView!.EvaluateJavaScriptAsync($"setPoints({json})");
}
#else
private Task EnsureMapCreatedAsync()
{
if (_map is null)
{
_map = new Map { MapType = MapType.Hybrid, IsShowingUser = false };
MapHost.Content = _map;
}
return Task.CompletedTask;
}
private void ShowPoints(List<LoggedPoint> points)
{
if (_map is null)
return;
_map.Pins.Clear();
foreach (var p in points)
{
_map.Pins.Add(new Pin
{
Label = $"#{p.Id} {(UmUtility)p.Utility} · depth {p.DepthRaw}",
Address = $"{(GnssFixStatus)p.FixStatus} · ±{p.Hrms:F3} m · {p.TimestampUtc.ToLocalTime():g}",
Location = new Location(p.Latitude, p.Longitude),
});
}
if (points.Count > 0)
{
var minLat = points.Min(p => p.Latitude);
var maxLat = points.Max(p => p.Latitude);
var minLon = points.Min(p => p.Longitude);
var maxLon = points.Max(p => p.Longitude);
var center = new Location((minLat + maxLat) / 2, (minLon + maxLon) / 2);
var spanKm = Math.Max(0.05,
Location.CalculateDistance(minLat, minLon, maxLat, maxLon, DistanceUnits.Kilometers) * 0.7);
_map.MoveToRegion(MapSpan.FromCenterAndRadius(center, Distance.FromKilometers(spanKm)));
}
}
#endif
}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage 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.SettingsPage"
x:DataType="vm:SettingsViewModel"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="Devices" FontAttributes="Bold" FontSize="16" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Locating Receiver" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeLocatorCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetLocatorCommand}" />
</Grid>
<BoxView HeightRequest="1" Color="{AppThemeBinding Light=#E0E0E0, Dark=#333}" />
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Maglink RTK GPS" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.GpsName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeGpsCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetGpsCommand}" />
</Grid>
</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>
</Border>
<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" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,12 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}