Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions windows/AirBridge.App/Pages/DevicesPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
<Page x:Class="AirBridge.App.Pages.DevicesPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="using:AirBridge.Core.Models"
xmlns:vm="using:AirBridge.App.ViewModels"
Background="Transparent">

<Grid Margin="24,16" RowSpacing="16">
<Grid Margin="24,16" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
Expand Down Expand Up @@ -44,8 +45,23 @@
Severity="Informational"
Message="{x:Bind ViewModel.StatusMessage, Mode=OneWay}"/>

<!-- Error banner (scan/connection failure) -->
<InfoBar Grid.Row="2"
IsOpen="{x:Bind ViewModel.HasError, Mode=OneWay}"
IsClosable="True"
Severity="Error"
Title="Connection Error"
Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}"
CloseButtonClick="DeviceErrorBar_CloseButtonClick">
<InfoBar.ActionButton>
<Button Content="Retry"
Style="{StaticResource AccentButtonStyle}"
Command="{x:Bind ViewModel.ToggleScanCommand}"/>
</InfoBar.ActionButton>
</InfoBar>

<!-- Device list -->
<ListView Grid.Row="2"
<ListView Grid.Row="3"
ItemsSource="{x:Bind ViewModel.Devices}"
SelectionMode="None"
ScrollViewer.VerticalScrollBarVisibility="Auto">
Expand All @@ -57,7 +73,7 @@
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:DeviceInfo">
<DataTemplate x:DataType="vm:DeviceItemViewModel">
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
Expand All @@ -78,7 +94,7 @@
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
VerticalAlignment="Center"/>

<!-- Device info -->
<!-- Device info + reconnecting indicator -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="2">
<TextBlock Text="{x:Bind DeviceName}"
Style="{StaticResource BodyStrongTextBlockStyle}"/>
Expand All @@ -88,6 +104,17 @@
<Run Text=":"/>
<Run Text="{x:Bind Port}"/>
</TextBlock>

<!-- Reconnecting row — visible only when reconnecting -->
<StackPanel Orientation="Horizontal"
Spacing="6"
Visibility="{x:Bind IsReconnecting, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
<ProgressRing Width="14" Height="14" IsActive="True"/>
<TextBlock Text="Reconnecting&#x2026;"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>

<!-- Connect / Paired button -->
Expand All @@ -98,7 +125,7 @@
<Button Content="Connect"
Style="{StaticResource AccentButtonStyle}"
Visibility="{x:Bind IsPaired, Converter={StaticResource BoolToVisibilityNegated}}"
Tag="{x:Bind}"
Tag="{x:Bind Device}"
Click="ConnectButton_Click"/>
</StackPanel>
</Grid>
Expand All @@ -108,7 +135,7 @@
</ListView>

<!-- Bottom status bar -->
<TextBlock Grid.Row="3"
<TextBlock Grid.Row="4"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind ViewModel.Devices.Count, Mode=OneWay}"/>
Expand Down
3 changes: 3 additions & 0 deletions windows/AirBridge.App/Pages/DevicesPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,7 @@ private async void ConnectButton_Click(object sender, RoutedEventArgs e)
await dialog.ShowAsync();
}
}

private void DeviceErrorBar_CloseButtonClick(InfoBar sender, object args)
=> ViewModel.DismissErrorCommand.Execute(null);
}
39 changes: 37 additions & 2 deletions windows/AirBridge.App/Pages/TransferPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
xmlns:vm="using:AirBridge.App.ViewModels"
Background="Transparent">

<Grid Margin="24,16" RowSpacing="16">
<Grid Margin="24,16" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
Expand Down Expand Up @@ -39,8 +40,23 @@
Severity="Informational"
Message="{x:Bind ViewModel.ConnectedDeviceName, Mode=OneWay}"/>

<!-- Error banner (connection drop / transfer failure) -->
<InfoBar Grid.Row="2"
IsOpen="{x:Bind ViewModel.HasError, Mode=OneWay}"
IsClosable="True"
Severity="Error"
Title="Transfer Error"
Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}"
CloseButtonClick="TransferErrorBar_CloseButtonClick">
<InfoBar.ActionButton>
<Button Content="Retry"
Style="{StaticResource AccentButtonStyle}"
Command="{x:Bind ViewModel.RetrySendCommand}"/>
</InfoBar.ActionButton>
</InfoBar>

<!-- Transfer list -->
<ListView Grid.Row="2"
<ListView Grid.Row="3"
ItemsSource="{x:Bind ViewModel.Transfers}"
SelectionMode="None"
ScrollViewer.VerticalScrollBarVisibility="Auto">
Expand All @@ -59,6 +75,7 @@
CornerRadius="8"
Padding="16,12">
<StackPanel Spacing="8">
<!-- File name + status badge -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
Expand All @@ -76,7 +93,25 @@
Foreground="White"/>
</Border>
</Grid>

<!-- Progress bar -->
<ProgressBar Value="{x:Bind Progress, Mode=OneWay}" Maximum="1.0"/>

<!-- Speed + ETA row (only while active) -->
<Grid Visibility="{x:Bind IsActive, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Bind SpeedText, Mode=OneWay}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock Grid.Column="1"
Text="{x:Bind EtaText, Mode=OneWay}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
Expand Down
3 changes: 3 additions & 0 deletions windows/AirBridge.App/Pages/TransferPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ private async void SendFileButton_Click(object sender, RoutedEventArgs e)

await ViewModel.SendFileCommand.ExecuteAsync(file.Path);
}

private void TransferErrorBar_CloseButtonClick(InfoBar sender, object args)
=> ViewModel.DismissErrorCommand.Execute(null);
}
5 changes: 5 additions & 0 deletions windows/AirBridge.App/Services/DeviceConnectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ public void RemoveMessageHandlers(string deviceId)
/// <summary>Raised with the device ID when an active session is closed or drops.</summary>
public event EventHandler<string>? DeviceDisconnected;

/// <summary>Raised with the device ID when an automatic reconnect attempt begins.</summary>
public event EventHandler<string>? DeviceReconnecting;

// ── Existing fields ─────────────────────────────────────────────────────

/// <summary>PIN from a pending inbound pairing request, or null if none.</summary>
Expand Down Expand Up @@ -214,10 +217,12 @@ void OnDisconnect(object? s, string id)
}

// Session dropped — retry from attempt 1 (reset loop).
DeviceReconnecting?.Invoke(this, device.DeviceId);
attempt = 0; // loop increment will make it 1
continue;

Backoff:
DeviceReconnecting?.Invoke(this, device.DeviceId);
var delayMs = (int)Math.Min(
Math.Pow(BackoffBase, attempt - 1) * 1000,
BackoffMaxMs);
Expand Down
123 changes: 120 additions & 3 deletions windows/AirBridge.App/ViewModels/DevicesViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,50 @@

namespace AirBridge.App.ViewModels;

/// <summary>
/// Wraps a <see cref="DeviceInfo"/> with runtime UI state (e.g. reconnecting indicator).
/// </summary>
public sealed partial class DeviceItemViewModel : ObservableObject
{
/// <summary>The underlying device model.</summary>
public DeviceInfo Device { get; }

private bool _isReconnecting;
/// <summary>True while an automatic reconnect attempt is in progress for this device.</summary>
public bool IsReconnecting
{
get => _isReconnecting;
set => SetProperty(ref _isReconnecting, value);
}

/// <summary>Convenience passthrough: device name.</summary>
public string DeviceName => Device.DeviceName;
/// <summary>Convenience passthrough: IP address.</summary>
public string IpAddress => Device.IpAddress;
/// <summary>Convenience passthrough: port.</summary>
public int Port => Device.Port;
/// <summary>Convenience passthrough: device type.</summary>
public DeviceType DeviceType => Device.DeviceType;
/// <summary>Convenience passthrough: pairing state.</summary>
public bool IsPaired => Device.IsPaired;
/// <summary>Convenience passthrough: device ID.</summary>
public string DeviceId => Device.DeviceId;

/// <summary>Creates a new wrapper around <paramref name="device"/>.</summary>
public DeviceItemViewModel(DeviceInfo device) => Device = device;
}

/// <summary>
/// ViewModel for the Devices page. Exposes discovered devices and controls
/// for starting/stopping mDNS scanning and initiating a connection.
/// </summary>
public sealed partial class DevicesViewModel : ObservableObject
{
private readonly DeviceConnectionService _connection;
private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher;

/// <summary>Live list of devices found on the LAN.</summary>
public ObservableCollection<DeviceInfo> Devices => _connection.DiscoveredDevices;
/// <summary>Live list of devices found on the LAN, wrapped with UI state.</summary>
public ObservableCollection<DeviceItemViewModel> Devices { get; } = new();

[ObservableProperty]
private bool _isScanning = true;
Expand All @@ -26,14 +60,86 @@ public sealed partial class DevicesViewModel : ObservableObject
[ObservableProperty]
private string _statusMessage = "Scanning for devices\u2026";

[ObservableProperty]
private bool _hasError;

[ObservableProperty]
private string _errorMessage = string.Empty;

/// <summary>Raised when the user requests a connection to a device.</summary>
public event EventHandler<DeviceInfo>? ConnectRequested;

public DevicesViewModel(DeviceConnectionService connection)
{
_connection = connection;
_dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();

// Mirror raw DiscoveredDevices into Devices (wrapped)
_connection.DiscoveredDevices.CollectionChanged += OnDiscoveredDevicesChanged;
foreach (var d in _connection.DiscoveredDevices)
Devices.Add(new DeviceItemViewModel(d));

_connection.DeviceConnected += OnDeviceConnected;
_connection.DeviceDisconnected += OnDeviceDisconnected;
_connection.DeviceReconnecting += OnDeviceReconnecting;
}

private void OnDiscoveredDevicesChanged(object? sender,
System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
_dispatcher?.TryEnqueue(() =>
{
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add
&& e.NewItems is not null)
{
foreach (DeviceInfo d in e.NewItems)
Devices.Add(new DeviceItemViewModel(d));
}
else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove
&& e.OldItems is not null)
{
foreach (DeviceInfo d in e.OldItems)
{
var vm = Devices.FirstOrDefault(x => x.DeviceId == d.DeviceId);
if (vm is not null) Devices.Remove(vm);
}
}
else if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
{
Devices.Clear();
}
});
}

private void OnDeviceConnected(object? sender, string deviceId)
{
_dispatcher?.TryEnqueue(() =>
{
var vm = Devices.FirstOrDefault(x => x.DeviceId == deviceId);
if (vm is not null) vm.IsReconnecting = false;
HasError = false;
});
}

private void OnDeviceDisconnected(object? sender, string deviceId)
{
// Disconnection itself is not an error — reconnect may follow.
// If no reconnect arrives, it will just stay disconnected.
}

private void OnDeviceReconnecting(object? sender, string deviceId)
{
_dispatcher?.TryEnqueue(() =>
{
var vm = Devices.FirstOrDefault(x => x.DeviceId == deviceId);
if (vm is not null) vm.IsReconnecting = true;
});
}

/// <summary>Dismisses the current error banner.</summary>
[RelayCommand]
private void DismissError() => HasError = false;

/// <summary>Starts or stops mDNS device scanning.</summary>
[RelayCommand]
private async Task ToggleScanAsync()
Expand All @@ -48,7 +154,18 @@ private async Task ToggleScanAsync()
{
IsScanning = true;
StatusMessage = "Scanning for devices\u2026";
await _connection.StartDiscoveryAsync();
try
{
await _connection.StartDiscoveryAsync();
HasError = false;
}
catch (Exception ex)
{
IsScanning = false;
StatusMessage = "Scan failed";
ErrorMessage = $"Failed to start scanning: {ex.Message}";
HasError = true;
}
}
}

Expand Down
Loading
Loading