Compare commits

3 Commits

5 changed files with 523 additions and 3 deletions

View File

@@ -11,6 +11,18 @@ public class SavedListing
public string ConditionNotes { get; set; } = "";
public string ExportFolder { get; set; } = "";
/// <summary>eBay category ID — stored at save time so we can post without re-looking it up.</summary>
public string CategoryId { get; set; } = "";
/// <summary>Item condition — defaults to Used for existing records without this field.</summary>
public ItemCondition Condition { get; set; } = ItemCondition.Used;
/// <summary>Listing format — defaults to FixedPrice for existing records.</summary>
public ListingFormat Format { get; set; } = ListingFormat.FixedPrice;
/// <summary>Seller postcode — populated from appsettings default at save time.</summary>
public string Postcode { get; set; } = "";
/// <summary>Absolute paths to photos inside ExportFolder.</summary>
public List<string> PhotoPaths { get; set; } = new();
@@ -19,4 +31,21 @@ public class SavedListing
public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
/// <summary>
/// Converts this saved draft back into a ListingDraft suitable for PostListingAsync.
/// </summary>
public ListingDraft ToListingDraft() => new ListingDraft
{
Title = Title,
Description = Description,
Price = Price,
CategoryId = CategoryId,
CategoryName = Category,
Condition = Condition,
Format = Format,
Postcode = Postcode,
PhotoPaths = new List<string>(PhotoPaths),
Quantity = 1
};
}

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;
}

View File

@@ -318,6 +318,21 @@
<!-- Action buttons -->
<WrapPanel Orientation="Horizontal">
<Button x:Name="PostDraftBtn"
Click="PostDraft_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6"
ToolTip="Post this draft to eBay">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PostDraftIcon"
Kind="CartArrowRight" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PostDraftSpinner"
Width="14" Height="14" Margin="0,0,6,0"
Visibility="Collapsed"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="EditListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
@@ -443,5 +458,40 @@
</ScrollViewer>
</Grid>
<!-- Draft posted toast -->
<Border x:Name="DraftPostedToast"
Grid.Column="2"
Visibility="Collapsed"
VerticalAlignment="Bottom"
Margin="12" Padding="14,10"
CornerRadius="6"
Background="{DynamicResource MahApps.Brushes.Gray8}"
BorderThickness="0,0,0,3"
BorderBrush="{DynamicResource MahApps.Brushes.Accent}"
Panel.ZIndex="10">
<Border.RenderTransform>
<TranslateTransform x:Name="ToastTranslate" Y="60"/>
</Border.RenderTransform>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<iconPacks:PackIconMaterial Kind="CheckCircleOutline"
Width="16" Height="16" Margin="0,0,10,0"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"/>
<TextBlock x:Name="ToastUrlText" Grid.Column="1"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource MahApps.Brushes.Gray1}" FontSize="12"/>
<Button Grid.Column="2" Content="✕" Width="20" Height="20"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Click="DismissToast_Click" Margin="8,0,0,0"/>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@@ -4,6 +4,7 @@ using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using EbayListingTool.Models;
using EbayListingTool.Services;
using Microsoft.Win32;
@@ -14,6 +15,8 @@ public partial class SavedListingsView : UserControl
{
private SavedListingsService? _service;
private PriceLookupService? _priceLookup;
private EbayListingService? _ebayListing;
private EbayAuthService? _ebayAuth;
private SavedListing? _selected;
// Edit mode working state
@@ -34,10 +37,15 @@ public partial class SavedListingsView : UserControl
};
}
public void Initialise(SavedListingsService service, PriceLookupService? priceLookup = null)
public void Initialise(SavedListingsService service,
PriceLookupService? priceLookup = null,
EbayListingService? ebayListing = null,
EbayAuthService? ebayAuth = null)
{
_service = service;
_priceLookup = priceLookup;
_ebayListing = ebayListing;
_ebayAuth = ebayAuth;
RefreshList();
}
@@ -753,4 +761,87 @@ public partial class SavedListingsView : UserControl
ClearDetail();
RefreshList();
}
private async void PostDraft_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _ebayListing == null || _ebayAuth == null) return;
if (!_ebayAuth.IsConnected)
{
MessageBox.Show("Please connect to eBay first.", "Not Connected",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SetPostDraftBusy(true);
try
{
var draft = _selected.ToListingDraft();
var url = await _ebayListing.PostListingAsync(draft);
ToastUrlText.Text = url;
ShowDraftPostedToast();
var posted = _selected;
_selected = null;
_service?.Delete(posted);
ClearDetail();
RefreshList();
}
catch (Exception ex)
{
MessageBox.Show($"Failed to post listing:\n\n{ex.Message}", "Post Failed",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
SetPostDraftBusy(false);
}
}
private void SetPostDraftBusy(bool busy)
{
PostDraftBtn.IsEnabled = !busy;
PostDraftIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
PostDraftSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
IsEnabled = !busy;
}
private DispatcherTimer? _toastTimer;
private void ShowDraftPostedToast()
{
_toastTimer?.Stop();
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, null);
DraftPostedToast.Visibility = Visibility.Visible;
var slideIn = new System.Windows.Media.Animation.DoubleAnimation(
60, 0, new Duration(TimeSpan.FromMilliseconds(220)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
slideIn.Completed += (_, _) =>
{
_toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(8) };
_toastTimer.Tick += (_, _) => DismissToastAnimated();
_toastTimer.Start();
};
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideIn);
}
private void DismissToast_Click(object sender, RoutedEventArgs e)
=> DismissToastAnimated();
private void DismissToastAnimated()
{
_toastTimer?.Stop();
var slideOut = new System.Windows.Media.Animation.DoubleAnimation(
0, 60, new Duration(TimeSpan.FromMilliseconds(180)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseIn }
};
slideOut.Completed += (_, _) => DraftPostedToast.Visibility = Visibility.Collapsed;
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideOut);
}
}