Initial commit: FieldLogger MAUI app with Maglink BLE support

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.HomePage"
x:DataType="vm:HomeViewModel"
Title="Field Logger">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<!-- Device connection status -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<Grid ColumnDefinitions="16,*,Auto" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="10">
<Ellipse Grid.Row="0" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Row="0" Grid.Column="1" Spacing="0">
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Locating Receiver — not selected'}" FontAttributes="Bold" />
<Label Text="{Binding Manager.LocatorState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
<Button Grid.Row="0" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
Command="{Binding SelectLocatorCommand}" />
<Ellipse Grid.Row="1" Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Row="1" Grid.Column="1" Spacing="0">
<Label Text="{Binding Manager.GpsName, TargetNullValue='Maglink RTK GPS — not selected'}" FontAttributes="Bold" />
<Label Text="{Binding Manager.GpsState, Converter={StaticResource StateText}}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
<Button Grid.Row="1" Grid.Column="2" Text="Select" FontSize="12" Padding="10,4"
Command="{Binding SelectGpsCommand}" />
</Grid>
</Border>
<!-- Live GNSS fix -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="4">
<HorizontalStackLayout Spacing="8">
<Label Text="GPS" FontAttributes="Bold" />
<Label Text="{Binding FixStatusLabel}" TextColor="DodgerBlue" FontAttributes="Bold" />
</HorizontalStackLayout>
<Label Text="{Binding FixSummary}" FontFamily="Courier New" FontSize="13" />
<Label Text="{Binding FixAccuracy}" FontSize="12" TextColor="Gray" />
</VerticalStackLayout>
</Border>
<!-- Active job -->
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<VerticalStackLayout Spacing="0">
<Label Text="{Binding ActiveJobName}" FontAttributes="Bold" FontSize="16" />
<Label FontSize="12" TextColor="Gray">
<Label.Text>
<Binding Path="PointCount" StringFormat="{}{0} points logged" />
</Label.Text>
</Label>
</VerticalStackLayout>
<HorizontalStackLayout Grid.Column="1" Spacing="6">
<Button Text="New Job" FontSize="12" Padding="10,4" Command="{Binding NewJobCommand}" />
<Button Text="Jobs" FontSize="12" Padding="10,4" Command="{Binding SelectJobCommand}" />
</HorizontalStackLayout>
</Grid>
<Label Text="Press the log button on the receiver to capture a point."
FontSize="12" TextColor="Gray" IsVisible="{Binding HasActiveJob}" />
</VerticalStackLayout>
</Border>
<!-- Recent points -->
<Label Text="Recent Points" FontAttributes="Bold" Margin="4,8,0,0" />
<CollectionView ItemsSource="{Binding RecentPoints}" HeightRequest="320">
<CollectionView.EmptyView>
<Label Text="No points logged yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:LoggedPoint">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#FAFAFA, Dark=#242426}"
StrokeShape="RoundRectangle 8" Padding="10" Margin="0,2">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<Label Grid.Row="0" FontSize="13" FontAttributes="Bold">
<Label.Text>
<MultiBinding StringFormat="{}{0} · {1} Hz · depth {2}">
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
<Binding Path="Frequency" />
<Binding Path="DepthRaw" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
Text="{Binding TimestampUtc, StringFormat='{0:HH:mm:ss}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0:F7}, {1:F7} · {2}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
</MultiBinding>
</Label.Text>
</Label>
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

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

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.JobDetailPage"
x:DataType="vm:JobDetailViewModel"
Title="{Binding JobName}">
<ContentPage.ToolbarItems>
<ToolbarItem Text="Map" Command="{Binding ViewMapCommand}" />
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*" Padding="16" RowSpacing="8">
<Label Grid.Row="0" FontSize="13" TextColor="Gray"
Text="{Binding PointCount, StringFormat='{0} logged points'}" />
<CollectionView Grid.Row="1" ItemsSource="{Binding Points}">
<CollectionView.EmptyView>
<Label Text="No points logged for this job yet." TextColor="Gray" Margin="8" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:LoggedPoint">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="2">
<Label Grid.Row="0" FontAttributes="Bold" FontSize="14">
<Label.Text>
<MultiBinding StringFormat="{}#{0} · {1} · {2} Hz">
<Binding Path="Id" />
<Binding Path="Utility" Converter="{StaticResource UtilityName}" />
<Binding Path="Frequency" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="0" Grid.Column="1" FontSize="12" TextColor="Gray"
Text="{Binding TimestampUtc, StringFormat='{0:g}'}" />
<Label Grid.Row="1" Grid.ColumnSpan="2" FontSize="12">
<Label.Text>
<MultiBinding StringFormat="Depth {0} · Current {1} · Signal {2} · Gain {3} dB">
<Binding Path="DepthRaw" />
<Binding Path="CurrentRaw" />
<Binding Path="Signal" />
<Binding Path="GainDb" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="2" Grid.ColumnSpan="2" FontSize="12" FontFamily="Courier New">
<Label.Text>
<MultiBinding StringFormat="{}{0:F8}, {1:F8}">
<Binding Path="Latitude" />
<Binding Path="Longitude" />
</MultiBinding>
</Label.Text>
</Label>
<Label Grid.Row="3" FontSize="11" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0} · H ±{1:F3} m · {2} sats">
<Binding Path="FixStatus" Converter="{StaticResource FixStatusName}" />
<Binding Path="Hrms" />
<Binding Path="SatellitesUsed" />
</MultiBinding>
</Label.Text>
</Label>
<Button Grid.Row="3" Grid.Column="1" Text="Delete" FontSize="11" Padding="8,2"
BackgroundColor="Transparent" TextColor="IndianRed"
Command="{Binding DeletePointCommand, Source={RelativeSource AncestorType={x:Type vm:JobDetailViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

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

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.JobsPage"
x:DataType="vm:JobsViewModel"
Title="Jobs">
<ContentPage.ToolbarItems>
<ToolbarItem Text="New" Command="{Binding NewJobCommand}" />
</ContentPage.ToolbarItems>
<Grid Padding="16">
<CollectionView ItemsSource="{Binding Jobs}">
<CollectionView.EmptyView>
<VerticalStackLayout Spacing="8" VerticalOptions="Center">
<Label Text="No jobs yet." TextColor="Gray" HorizontalOptions="Center" />
<Button Text="Create First Job" Command="{Binding NewJobCommand}" HorizontalOptions="Center" />
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:JobListItem">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}"
StrokeShape="RoundRectangle 10" Padding="12" Margin="0,4">
<Border.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding OpenCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
</Border.GestureRecognizers>
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<VerticalStackLayout Spacing="2">
<HorizontalStackLayout Spacing="8">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Border IsVisible="{Binding IsActive}" Background="DodgerBlue"
StrokeThickness="0" StrokeShape="RoundRectangle 6" Padding="6,1">
<Label Text="ACTIVE" FontSize="10" TextColor="White" />
</Border>
</HorizontalStackLayout>
<Label FontSize="12" TextColor="Gray">
<Label.Text>
<MultiBinding StringFormat="{}{0} · {1} points">
<Binding Path="CreatedLabel" />
<Binding Path="PointCount" />
</MultiBinding>
</Label.Text>
</Label>
</VerticalStackLayout>
<Button Grid.Column="1" Text="Set Active" FontSize="12" Padding="10,4" VerticalOptions="Center"
Command="{Binding SetActiveCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
<Button Grid.Column="2" Text="✕" FontSize="12" Padding="10,4" VerticalOptions="Center"
BackgroundColor="IndianRed" TextColor="White"
Command="{Binding DeleteCommand, Source={RelativeSource AncestorType={x:Type vm:JobsViewModel}}}"
CommandParameter="{Binding .}" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.MapPage"
x:DataType="vm:MapViewModel"
Title="Map">
<Grid RowDefinitions="Auto,*">
<Label Grid.Row="0" Text="{Binding StatusText}" FontSize="12" TextColor="Gray" Padding="12,6" />
<!-- Populated in code-behind: native Map control on iOS/Android/macOS, Google Maps WebView on Windows. -->
<ContentView Grid.Row="1" x:Name="MapHost" />
</Grid>
</ContentPage>

View File

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

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
x:Class="FieldLogger.Views.SettingsPage"
x:DataType="vm:SettingsViewModel"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="Devices" FontAttributes="Bold" FontSize="16" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.LocatorState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Locating Receiver" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.LocatorName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeLocatorCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetLocatorCommand}" />
</Grid>
<BoxView HeightRequest="1" Color="{AppThemeBinding Light=#E0E0E0, Dark=#333}" />
<Grid ColumnDefinitions="16,*,Auto,Auto" ColumnSpacing="8">
<Ellipse WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding Manager.GpsState, Converter={StaticResource StateColor}}" />
<VerticalStackLayout Grid.Column="1" Spacing="0">
<Label Text="Maglink RTK GPS" FontSize="12" TextColor="Gray" />
<Label Text="{Binding Manager.GpsName, TargetNullValue='Not selected'}" FontAttributes="Bold" />
</VerticalStackLayout>
<Button Grid.Column="2" Text="Change" FontSize="12" Padding="10,4" Command="{Binding ChangeGpsCommand}" />
<Button Grid.Column="3" Text="Forget" FontSize="12" Padding="10,4"
BackgroundColor="Transparent" TextColor="IndianRed" Command="{Binding ForgetGpsCommand}" />
</Grid>
</VerticalStackLayout>
</Border>
<Label Text="Map" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="6">
<Label Text="Google Maps API Key (Windows map view)" FontSize="12" TextColor="Gray" />
<Entry Text="{Binding MapsApiKey}" Placeholder="AIza…" IsPassword="False" />
<Label FontSize="11" TextColor="Gray"
Text="On Android the key is configured in AndroidManifest.xml; iOS and macOS use Apple Maps and need no key." />
</VerticalStackLayout>
</Border>
<Label Text="Sync" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<Label Text="MQTT job sync is planned. Data is currently stored locally on this device."
FontSize="12" TextColor="Gray" />
</Border>
<Label Text="{Binding AppVersion, StringFormat='Field Logger v{0}'}"
FontSize="11" TextColor="Gray" HorizontalOptions="Center" Margin="0,20,0,0" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

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