Task 3: add NewListingView with drop zone, thumbnail strip, and AI analyse flow

This commit is contained in:
2026-04-16 01:37:53 +01:00
parent e9c5464df0
commit 7507030f72
2 changed files with 350 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<UserControl x:Class="EbayListingTool.Views.NewListingView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
Loaded="UserControl_Loaded">
<!-- Root grid hosts all three states; Visibility toggled in code-behind -->
<Grid>
<!-- ══════════════════════════════════════ STATE A: Drop Zone -->
<Grid x:Name="StateA" Visibility="Visible">
<DockPanel LastChildFill="True">
<!-- Loading panel — shown while AI runs -->
<Border x:Name="LoadingPanel" DockPanel.Dock="Top"
Visibility="Collapsed"
Margin="60,30,60,0" Padding="30,40"
Background="{DynamicResource MahApps.Brushes.Gray9}"
CornerRadius="10">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<mah:ProgressRing Width="36" Height="36"
HorizontalAlignment="Center" Margin="0,0,0,16"/>
<TextBlock x:Name="LoadingStepText"
Text="Examining the photo…"
FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<TextBlock Text="This usually takes 1020 seconds"
FontSize="11" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
<!-- Drop zone -->
<Border x:Name="DropZoneBorder" DockPanel.Dock="Top"
Margin="60,30,60,0"
AllowDrop="True"
MouseLeftButtonUp="DropZone_Click"
DragOver="DropZone_DragOver"
DragEnter="DropZone_DragEnter"
DragLeave="DropZone_DragLeave"
Drop="DropZone_Drop"
Cursor="Hand"
MinHeight="180">
<Grid>
<!-- Dashed border via Rectangle -->
<Rectangle x:Name="DropBorderRect"
StrokeThickness="2"
StrokeDashArray="6,4"
RadiusX="10" RadiusY="10"
Stroke="{DynamicResource MahApps.Brushes.Gray6}"/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="0,40">
<iconPacks:PackIconMaterial Kind="CameraOutline"
Width="52" Height="52"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,16"/>
<TextBlock Text="Drop photos here"
FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
<TextBlock Text="or click to browse — up to 12 photos"
FontSize="12" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- Thumbnail strip -->
<ScrollViewer x:Name="ThumbScroller" DockPanel.Dock="Top"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Margin="60,12,60,0" Visibility="Collapsed">
<StackPanel x:Name="ThumbStrip" Orientation="Horizontal"/>
</ScrollViewer>
<!-- Analyse button -->
<StackPanel DockPanel.Dock="Top" HorizontalAlignment="Center" Margin="0,20,0,0">
<Button x:Name="AnalyseBtn"
Click="Analyse_Click"
IsEnabled="False"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Padding="28,12" FontSize="14" FontWeight="SemiBold">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="AnalyseIcon"
Kind="MagnifyScan" Width="18" Height="18"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="AnalyseSpinner"
Width="18" Height="18" Margin="0,0,8,0"
Visibility="Collapsed"/>
<TextBlock x:Name="AnalyseBtnText"
Text="Identify &amp; Price with AI"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock x:Name="PhotoCountLabel"
HorizontalAlignment="Center" Margin="0,8,0,0"
FontSize="11" Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
<Grid/> <!-- fill remaining space -->
</DockPanel>
</Grid>
<!-- ══════════════════════════════════════ STATE B: Review & Edit (stub for now) -->
<Grid x:Name="StateB" Visibility="Collapsed"/>
<!-- ══════════════════════════════════════ STATE C: Success (stub for now) -->
<Grid x:Name="StateC" Visibility="Collapsed"/>
</Grid>
</UserControl>

View File

@@ -0,0 +1,235 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using EbayListingTool.Models;
using EbayListingTool.Services;
using Microsoft.Win32;
namespace EbayListingTool.Views;
public partial class NewListingView : UserControl
{
// Services (injected via Initialise)
private EbayListingService? _listingService;
private EbayCategoryService? _categoryService;
private AiAssistantService? _aiService;
private EbayAuthService? _auth;
private SavedListingsService? _savedService;
private string _defaultPostcode = "";
// State A — photos
private readonly List<string> _photoPaths = new();
private const int MaxPhotos = 12;
// State B — draft being edited (stub, populated in Task 4)
private ListingDraft _draft = new();
private PhotoAnalysisResult? _lastAnalysis;
private bool _suppressCategoryLookup;
private System.Threading.CancellationTokenSource? _categoryCts;
private string _suggestedPriceValue = "";
// Loading step cycling
private readonly DispatcherTimer _loadingTimer;
private int _loadingStep;
private static readonly string[] LoadingSteps =
[
"Examining the photo\u2026",
"Identifying the item\u2026",
"Researching eBay prices\u2026",
"Writing description\u2026"
];
public NewListingView()
{
InitializeComponent();
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.5) };
_loadingTimer.Tick += (_, _) =>
{
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
LoadingStepText.Text = LoadingSteps[_loadingStep];
};
}
private void UserControl_Loaded(object sender, RoutedEventArgs e) { }
public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
AiAssistantService aiService, EbayAuthService auth,
SavedListingsService savedService, string defaultPostcode)
{
_listingService = listingService;
_categoryService = categoryService;
_aiService = aiService;
_auth = auth;
_savedService = savedService;
_defaultPostcode = defaultPostcode;
}
// ---- State machine ----
private enum ListingState { DropZone, ReviewEdit, Success }
private void SetState(ListingState state)
{
StateA.Visibility = state == ListingState.DropZone ? Visibility.Visible : Visibility.Collapsed;
StateB.Visibility = state == ListingState.ReviewEdit ? Visibility.Visible : Visibility.Collapsed;
StateC.Visibility = state == ListingState.Success ? Visibility.Visible : Visibility.Collapsed;
}
// ---- State A: Drop zone ----
private void DropZone_DragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private void DropZone_DragEnter(object sender, DragEventArgs e)
{
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
e.Handled = true;
}
private void DropZone_DragLeave(object sender, DragEventArgs e)
{
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray6");
}
private void DropZone_Drop(object sender, DragEventArgs e)
{
DropZone_DragLeave(sender, e);
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
AddPhotos(files.Where(IsImageFile).ToArray());
}
private void DropZone_Click(object sender, MouseButtonEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Select photos of the item",
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
Multiselect = true
};
if (dlg.ShowDialog() == true)
AddPhotos(dlg.FileNames);
}
private void AddPhotos(string[] paths)
{
foreach (var path in paths)
{
if (_photoPaths.Count >= MaxPhotos) break;
if (_photoPaths.Contains(path)) continue;
if (!IsImageFile(path)) continue;
_photoPaths.Add(path);
}
UpdateThumbStrip();
UpdateAnalyseButton();
}
private void UpdateThumbStrip()
{
ThumbStrip.Children.Clear();
ThumbScroller.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
foreach (var path in _photoPaths)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 80;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Source = bmp, Width = 60, Height = 60,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Margin = new Thickness(3)
};
img.Clip = new System.Windows.Media.RectangleGeometry(
new Rect(0, 0, 60, 60), 4, 4);
ThumbStrip.Children.Add(img);
}
catch { /* skip bad files */ }
}
PhotoCountLabel.Text = $"{_photoPaths.Count} / {MaxPhotos} photos";
PhotoCountLabel.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void UpdateAnalyseButton()
{
AnalyseBtn.IsEnabled = _photoPaths.Count > 0;
}
private async void Analyse_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null || _photoPaths.Count == 0) return;
SetAnalysing(true);
try
{
var result = await _aiService.AnalyseItemFromPhotosAsync(_photoPaths);
_lastAnalysis = result;
await PopulateStateBAsync(result);
SetState(ListingState.ReviewEdit);
}
catch (Exception ex)
{
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
SetAnalysing(false);
}
}
private void SetAnalysing(bool busy)
{
AnalyseBtn.IsEnabled = !busy;
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
LoadingPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
DropZoneBorder.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
if (busy)
{
_loadingStep = 0;
LoadingStepText.Text = LoadingSteps[0];
_loadingTimer.Start();
}
else
{
_loadingTimer.Stop();
}
}
// Stub for State B — implemented in Task 4
private Task PopulateStateBAsync(PhotoAnalysisResult result) => Task.CompletedTask;
// Stub for ResetToStateA — implemented in Task 4
public void ResetToStateA()
{
_photoPaths.Clear();
_draft = new ListingDraft { Postcode = _defaultPostcode };
_lastAnalysis = null;
UpdateThumbStrip();
UpdateAnalyseButton();
SetState(ListingState.DropZone);
}
private static bool IsImageFile(string path)
{
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
}
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
}