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:
21
FieldLogger/App.xaml
Normal file
21
FieldLogger/App.xaml
Normal 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
14
FieldLogger/App.xaml.cs
Normal 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
16
FieldLogger/AppShell.xaml
Normal 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>
|
||||
14
FieldLogger/AppShell.xaml.cs
Normal file
14
FieldLogger/AppShell.xaml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
68
FieldLogger/Converters/AppConverters.cs
Normal file
68
FieldLogger/Converters/AppConverters.cs
Normal 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();
|
||||
}
|
||||
82
FieldLogger/FieldLogger.csproj
Normal file
82
FieldLogger/FieldLogger.csproj
Normal 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>
|
||||
2
FieldLogger/GlobalXmlns.cs
Normal file
2
FieldLogger/GlobalXmlns.cs
Normal 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")]
|
||||
64
FieldLogger/MauiProgram.cs
Normal file
64
FieldLogger/MauiProgram.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
FieldLogger/Models/DeviceKind.cs
Normal file
11
FieldLogger/Models/DeviceKind.cs
Normal 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,
|
||||
}
|
||||
155
FieldLogger/Models/GnssFix.cs
Normal file
155
FieldLogger/Models/GnssFix.cs
Normal 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
23
FieldLogger/Models/Job.cs
Normal 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; }
|
||||
}
|
||||
145
FieldLogger/Models/LoggedPoint.cs
Normal file
145
FieldLogger/Models/LoggedPoint.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
51
FieldLogger/Models/UmDeviceInfo.cs
Normal file
51
FieldLogger/Models/UmDeviceInfo.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
141
FieldLogger/Models/UmLogPacket.cs
Normal file
141
FieldLogger/Models/UmLogPacket.cs
Normal 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);
|
||||
}
|
||||
18
FieldLogger/Platforms/Android/AndroidManifest.xml
Normal file
18
FieldLogger/Platforms/Android/AndroidManifest.xml
Normal 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>
|
||||
10
FieldLogger/Platforms/Android/MainActivity.cs
Normal file
10
FieldLogger/Platforms/Android/MainActivity.cs
Normal 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
|
||||
{
|
||||
}
|
||||
15
FieldLogger/Platforms/Android/MainApplication.cs
Normal file
15
FieldLogger/Platforms/Android/MainApplication.cs
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
9
FieldLogger/Platforms/MacCatalyst/AppDelegate.cs
Normal file
9
FieldLogger/Platforms/MacCatalyst/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
17
FieldLogger/Platforms/MacCatalyst/Entitlements.plist
Normal file
17
FieldLogger/Platforms/MacCatalyst/Entitlements.plist
Normal 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>
|
||||
|
||||
42
FieldLogger/Platforms/MacCatalyst/Info.plist
Normal file
42
FieldLogger/Platforms/MacCatalyst/Info.plist
Normal 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>
|
||||
15
FieldLogger/Platforms/MacCatalyst/Program.cs
Normal file
15
FieldLogger/Platforms/MacCatalyst/Program.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
16
FieldLogger/Platforms/Tizen/Main.cs
Normal file
16
FieldLogger/Platforms/Tizen/Main.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
FieldLogger/Platforms/Tizen/tizen-manifest.xml
Normal file
15
FieldLogger/Platforms/Tizen/tizen-manifest.xml
Normal 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>
|
||||
8
FieldLogger/Platforms/Windows/App.xaml
Normal file
8
FieldLogger/Platforms/Windows/App.xaml
Normal 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>
|
||||
24
FieldLogger/Platforms/Windows/App.xaml.cs
Normal file
24
FieldLogger/Platforms/Windows/App.xaml.cs
Normal 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();
|
||||
}
|
||||
|
||||
47
FieldLogger/Platforms/Windows/Package.appxmanifest
Normal file
47
FieldLogger/Platforms/Windows/Package.appxmanifest
Normal 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>
|
||||
15
FieldLogger/Platforms/Windows/app.manifest
Normal file
15
FieldLogger/Platforms/Windows/app.manifest
Normal 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>
|
||||
9
FieldLogger/Platforms/iOS/AppDelegate.cs
Normal file
9
FieldLogger/Platforms/iOS/AppDelegate.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Foundation;
|
||||
|
||||
namespace FieldLogger;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
36
FieldLogger/Platforms/iOS/Info.plist
Normal file
36
FieldLogger/Platforms/iOS/Info.plist
Normal 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>
|
||||
15
FieldLogger/Platforms/iOS/Program.cs
Normal file
15
FieldLogger/Platforms/iOS/Program.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
51
FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal file
51
FieldLogger/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
Normal 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>
|
||||
8
FieldLogger/Properties/launchSettings.json
Normal file
8
FieldLogger/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "Project",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
||||
4
FieldLogger/Resources/AppIcon/appicon.svg
Normal file
4
FieldLogger/Resources/AppIcon/appicon.svg
Normal 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 |
8
FieldLogger/Resources/AppIcon/appiconfg.svg
Normal file
8
FieldLogger/Resources/AppIcon/appiconfg.svg
Normal 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 |
BIN
FieldLogger/Resources/Fonts/OpenSans-Regular.ttf
Normal file
BIN
FieldLogger/Resources/Fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
BIN
FieldLogger/Resources/Fonts/OpenSans-Semibold.ttf
Normal file
Binary file not shown.
BIN
FieldLogger/Resources/Images/dotnet_bot.png
Normal file
BIN
FieldLogger/Resources/Images/dotnet_bot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
15
FieldLogger/Resources/Raw/AboutAssets.txt
Normal file
15
FieldLogger/Resources/Raw/AboutAssets.txt
Normal 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();
|
||||
}
|
||||
64
FieldLogger/Resources/Raw/map.html
Normal file
64
FieldLogger/Resources/Raw/map.html
Normal 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 + ' · ' + p.utility + '</b><br/>' +
|
||||
'Depth: ' + p.depth + ' · Current: ' + p.current + '<br/>' +
|
||||
'Frequency: ' + p.frequency + ' Hz<br/>' +
|
||||
'Fix: ' + p.fix + ' (±' + 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>
|
||||
8
FieldLogger/Resources/Splash/splash.svg
Normal file
8
FieldLogger/Resources/Splash/splash.svg
Normal 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 |
45
FieldLogger/Resources/Styles/Colors.xaml
Normal file
45
FieldLogger/Resources/Styles/Colors.xaml
Normal 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>
|
||||
444
FieldLogger/Resources/Styles/Styles.xaml
Normal file
444
FieldLogger/Resources/Styles/Styles.xaml
Normal 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>
|
||||
81
FieldLogger/Services/Ble/BleScanner.cs
Normal file
81
FieldLogger/Services/Ble/BleScanner.cs
Normal 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
|
||||
}
|
||||
}
|
||||
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal file
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
90
FieldLogger/Services/Data/AppDatabase.cs
Normal file
90
FieldLogger/Services/Data/AppDatabase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal file
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
98
FieldLogger/Services/MaglinkService.cs
Normal file
98
FieldLogger/Services/MaglinkService.cs
Normal 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();
|
||||
}
|
||||
67
FieldLogger/Services/PointLogger.cs
Normal file
67
FieldLogger/Services/PointLogger.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
58
FieldLogger/Services/SettingsService.cs
Normal file
58
FieldLogger/Services/SettingsService.cs
Normal 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;
|
||||
}
|
||||
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal file
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal 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;
|
||||
}
|
||||
88
FieldLogger/Services/UmReceiverService.cs
Normal file
88
FieldLogger/Services/UmReceiverService.cs
Normal 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();
|
||||
}
|
||||
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal file
107
FieldLogger/ViewModels/DeviceScanViewModel.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal file
149
FieldLogger/ViewModels/HomeViewModel.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal file
59
FieldLogger/ViewModels/JobDetailViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal file
104
FieldLogger/ViewModels/JobsViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
53
FieldLogger/ViewModels/MapViewModel.cs
Normal file
53
FieldLogger/ViewModels/MapViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal file
52
FieldLogger/ViewModels/SettingsViewModel.cs
Normal 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");
|
||||
}
|
||||
44
FieldLogger/Views/DeviceScanPage.xaml
Normal file
44
FieldLogger/Views/DeviceScanPage.xaml
Normal 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>
|
||||
26
FieldLogger/Views/DeviceScanPage.xaml.cs
Normal file
26
FieldLogger/Views/DeviceScanPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
108
FieldLogger/Views/HomePage.xaml
Normal file
108
FieldLogger/Views/HomePage.xaml
Normal 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>
|
||||
29
FieldLogger/Views/HomePage.xaml.cs
Normal file
29
FieldLogger/Views/HomePage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
FieldLogger/Views/JobDetailPage.xaml
Normal file
77
FieldLogger/Views/JobDetailPage.xaml
Normal 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>
|
||||
20
FieldLogger/Views/JobDetailPage.xaml.cs
Normal file
20
FieldLogger/Views/JobDetailPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
60
FieldLogger/Views/JobsPage.xaml
Normal file
60
FieldLogger/Views/JobsPage.xaml
Normal 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>
|
||||
20
FieldLogger/Views/JobsPage.xaml.cs
Normal file
20
FieldLogger/Views/JobsPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
13
FieldLogger/Views/MapPage.xaml
Normal file
13
FieldLogger/Views/MapPage.xaml
Normal 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>
|
||||
151
FieldLogger/Views/MapPage.xaml.cs
Normal file
151
FieldLogger/Views/MapPage.xaml.cs
Normal 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
|
||||
}
|
||||
64
FieldLogger/Views/SettingsPage.xaml
Normal file
64
FieldLogger/Views/SettingsPage.xaml
Normal 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>
|
||||
12
FieldLogger/Views/SettingsPage.xaml.cs
Normal file
12
FieldLogger/Views/SettingsPage.xaml.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using FieldLogger.ViewModels;
|
||||
|
||||
namespace FieldLogger.Views;
|
||||
|
||||
public partial class SettingsPage : ContentPage
|
||||
{
|
||||
public SettingsPage(SettingsViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
BindingContext = viewModel;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user