- NumberWords helper: decimal → "about seventeen pounds", DateTime → "3 days ago"
- PhotoAnalysisView: after analysis shows a card preview with large verbal price,
photo dots, "Looks good ✓" to save instantly, "Change something ▼" to reveal
a price slider (snaps to 50p, updates verbally as you drag) and title bar
- Card preview updates when live eBay price lookup completes
- SavedListingsView cards: verbal price as primary, £x.xx small beneath,
relative date ("yesterday", "3 days ago") instead of raw timestamp
- Detail panel also shows relative date
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
834 lines
31 KiB
C#
834 lines
31 KiB
C#
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Animation;
|
||
using System.Windows.Media.Imaging;
|
||
using System.Windows.Threading;
|
||
using EbayListingTool.Helpers;
|
||
using EbayListingTool.Models;
|
||
using EbayListingTool.Services;
|
||
using Microsoft.Win32;
|
||
|
||
namespace EbayListingTool.Views;
|
||
|
||
public partial class PhotoAnalysisView : UserControl
|
||
{
|
||
private AiAssistantService? _aiService;
|
||
private SavedListingsService? _savedService;
|
||
private EbayPriceResearchService? _priceService;
|
||
private List<string> _currentImagePaths = new();
|
||
private PhotoAnalysisResult? _lastResult;
|
||
private int _activePhotoIndex = 0;
|
||
private DispatcherTimer? _saveBtnTimer; // M1: field so we can stop it on Unloaded
|
||
private DispatcherTimer? _holdTimer; // Q2: field so we can stop it on Unloaded
|
||
|
||
private const int MaxPhotos = 4;
|
||
|
||
// 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"
|
||
];
|
||
|
||
// Event raised when user clicks "Use for New Listing" — Q1: passes all loaded photos
|
||
public event Action<PhotoAnalysisResult, IReadOnlyList<string>, decimal>? UseDetailsRequested;
|
||
|
||
public PhotoAnalysisView()
|
||
{
|
||
InitializeComponent();
|
||
|
||
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||
_loadingTimer.Tick += LoadingTimer_Tick;
|
||
|
||
// M1 / Q2: stop timers when control is unloaded to avoid memory leaks
|
||
Unloaded += (_, _) => { _saveBtnTimer?.Stop(); _holdTimer?.Stop(); };
|
||
|
||
// Keep photo clip geometry in sync with container size
|
||
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
||
}
|
||
|
||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
|
||
EbayPriceResearchService priceService)
|
||
{
|
||
_aiService = aiService;
|
||
_savedService = savedService;
|
||
_priceService = priceService;
|
||
}
|
||
|
||
// ---- Photo clip geometry sync ----
|
||
|
||
private void PhotoPreviewContainer_SizeChanged(object sender, SizeChangedEventArgs e)
|
||
{
|
||
PhotoClip.Rect = new System.Windows.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);
|
||
}
|
||
|
||
// ---- 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)
|
||
{
|
||
// Solid accent border on drag-enter
|
||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Accent");
|
||
DropBorderRect.StrokeDashArray = null;
|
||
DropBorderRect.StrokeThickness = 2.5;
|
||
}
|
||
|
||
private void DropZone_DragLeave(object sender, DragEventArgs e)
|
||
{
|
||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Gray6");
|
||
DropBorderRect.StrokeDashArray = new DoubleCollection([6, 4]);
|
||
DropBorderRect.StrokeThickness = 2;
|
||
}
|
||
|
||
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);
|
||
foreach (var file in files.Where(IsImageFile))
|
||
{
|
||
LoadPhoto(file);
|
||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
||
}
|
||
}
|
||
|
||
private void DropZone_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||
{
|
||
var dlg = new OpenFileDialog
|
||
{
|
||
Title = "Select photo(s) of the item",
|
||
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp|All files|*.*",
|
||
Multiselect = true
|
||
};
|
||
if (dlg.ShowDialog() != true) return;
|
||
foreach (var file in dlg.FileNames)
|
||
{
|
||
LoadPhoto(file);
|
||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
||
}
|
||
}
|
||
|
||
private void ClearPhoto_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_currentImagePaths.Clear();
|
||
_activePhotoIndex = 0;
|
||
PhotoPreview.Source = null;
|
||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
||
DropHint.Visibility = Visibility.Visible;
|
||
PhotoFilename.Text = "";
|
||
AnalyseBtn.IsEnabled = false;
|
||
|
||
UpdateThumbStrip();
|
||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||
ResultsPanel.Opacity = 0;
|
||
IdlePanel.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Adds <paramref name="path"/> to the photo list (up to MaxPhotos).
|
||
/// The preview always shows the most recently added image.
|
||
/// </summary>
|
||
private void LoadPhoto(string path)
|
||
{
|
||
if (_currentImagePaths.Contains(path)) return;
|
||
if (_currentImagePaths.Count >= MaxPhotos)
|
||
{
|
||
MessageBox.Show($"You can add up to {MaxPhotos} photos. Remove one before adding more.",
|
||
"Photo limit reached", MessageBoxButton.OK, MessageBoxImage.Information);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
_currentImagePaths.Add(path);
|
||
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||
bmp.DecodePixelWidth = 600;
|
||
bmp.EndInit();
|
||
bmp.Freeze(); // M2: cross-thread safe, reduces GC pressure
|
||
|
||
PhotoPreview.Source = bmp;
|
||
PhotoPreviewContainer.Visibility = Visibility.Visible;
|
||
ClearPhotoBtn.Visibility = Visibility.Visible;
|
||
DropHint.Visibility = Visibility.Collapsed;
|
||
|
||
_activePhotoIndex = _currentImagePaths.Count - 1;
|
||
|
||
UpdatePhotoFilenameLabel();
|
||
UpdateThumbStrip();
|
||
AnalyseBtn.IsEnabled = true;
|
||
|
||
// Collapse results so user re-analyses after adding photos
|
||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||
ResultsPanel.Opacity = 0;
|
||
IdlePanel.Visibility = Visibility.Visible;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_currentImagePaths.Remove(path);
|
||
MessageBox.Show($"Could not load image: {ex.Message}", "Error",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
}
|
||
|
||
private void UpdatePhotoFilenameLabel()
|
||
{
|
||
PhotoFilename.Text = _currentImagePaths.Count switch
|
||
{
|
||
0 => "",
|
||
1 => Path.GetFileName(_currentImagePaths[0]),
|
||
_ => $"{_currentImagePaths.Count} photos loaded"
|
||
};
|
||
}
|
||
|
||
// ---- Analyse ----
|
||
|
||
private async void Analyse_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_aiService == null || _currentImagePaths.Count == 0) return;
|
||
|
||
SetAnalysing(true);
|
||
try
|
||
{
|
||
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
||
_lastResult = result;
|
||
ShowResults(result);
|
||
// Fire live price lookup in background — updates price display when ready
|
||
_ = UpdateLivePricesAsync(result.Title);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
finally
|
||
{
|
||
SetAnalysing(false);
|
||
}
|
||
}
|
||
|
||
// Re-analyse simply repeats the same call — idempotent by design
|
||
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
|
||
=> Analyse_Click(sender, e);
|
||
|
||
private async void Refine_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_aiService == null || _lastResult == null) return;
|
||
|
||
var corrections = CorrectionsBox.Text.Trim();
|
||
if (string.IsNullOrEmpty(corrections))
|
||
{
|
||
CorrectionsBox.Focus();
|
||
return;
|
||
}
|
||
|
||
var title = TitleBox.Text;
|
||
var description = DescriptionBox.Text;
|
||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||
|
||
SetRefining(true);
|
||
try
|
||
{
|
||
var (newTitle, newDesc, newPrice, newReasoning) =
|
||
await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections);
|
||
|
||
TitleBox.Text = newTitle;
|
||
DescriptionBox.Text = newDesc;
|
||
PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6
|
||
PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—";
|
||
|
||
_lastResult.Title = newTitle;
|
||
_lastResult.Description = newDesc;
|
||
_lastResult.PriceSuggested = newPrice;
|
||
|
||
if (!string.IsNullOrWhiteSpace(newReasoning))
|
||
{
|
||
PriceReasoningText.Text = newReasoning;
|
||
PriceReasoningText.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
// Clear the corrections box now they're applied
|
||
CorrectionsBox.Text = "";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error",
|
||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||
}
|
||
finally
|
||
{
|
||
SetRefining(false);
|
||
}
|
||
}
|
||
|
||
private void SetRefining(bool busy)
|
||
{
|
||
RefineBtn.IsEnabled = !busy;
|
||
RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||
RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||
RefineBtnText.Text = busy ? "Refining…" : "Refine with AI";
|
||
}
|
||
|
||
private void ShowResults(PhotoAnalysisResult r)
|
||
{
|
||
IdlePanel.Visibility = Visibility.Collapsed;
|
||
LoadingPanel.Visibility = Visibility.Collapsed;
|
||
ResultsPanel.Visibility = Visibility.Collapsed; // hidden behind card preview
|
||
CardPreviewPanel.Visibility = Visibility.Visible;
|
||
CardChangePanel.Visibility = Visibility.Collapsed;
|
||
ChangeChevron.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
|
||
|
||
// --- Populate card preview ---
|
||
CardItemName.Text = r.ItemName;
|
||
CardCondition.Text = r.ConditionNotes;
|
||
CardCategory.Text = r.CategoryKeyword;
|
||
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
|
||
CardPriceDigit.Text = r.PriceSuggested > 0 ? $"£{r.PriceSuggested:F2}" : "";
|
||
CardLivePriceNote.Visibility = Visibility.Collapsed;
|
||
|
||
// Cover photo
|
||
if (_currentImagePaths.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.UriSource = new Uri(_currentImagePaths[0], UriKind.Absolute);
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||
bmp.DecodePixelWidth = 220;
|
||
bmp.EndInit();
|
||
bmp.Freeze();
|
||
CardPhoto.Source = bmp;
|
||
}
|
||
catch { CardPhoto.Source = null; }
|
||
}
|
||
|
||
// Photo dots — one dot per photo, filled accent for first
|
||
CardPhotoDots.Children.Clear();
|
||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
||
{
|
||
CardPhotoDots.Children.Add(new System.Windows.Shapes.Ellipse
|
||
{
|
||
Width = 7, Height = 7,
|
||
Margin = new Thickness(2, 0, 2, 0),
|
||
Fill = i == 0
|
||
? (Brush)FindResource("MahApps.Brushes.Accent")
|
||
: (Brush)FindResource("MahApps.Brushes.Gray7")
|
||
});
|
||
}
|
||
CardPhotoDots.Visibility = _currentImagePaths.Count > 1
|
||
? Visibility.Visible : Visibility.Collapsed;
|
||
|
||
// Price slider — centre on suggested, range ±60% clamped to sensible bounds
|
||
var suggested = (double)(r.PriceSuggested > 0 ? r.PriceSuggested : 10m);
|
||
PriceSliderCard.Minimum = Math.Max(0.50, Math.Round(suggested * 0.4 * 2) / 2);
|
||
PriceSliderCard.Maximum = Math.Round(suggested * 1.8 * 2) / 2;
|
||
PriceSliderCard.Value = Math.Round(suggested * 2) / 2; // snap to 50p
|
||
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
|
||
SliderDigitLabel.Text = $"£{r.PriceSuggested:F2}";
|
||
|
||
// Card title box
|
||
CardTitleBox.Text = r.Title;
|
||
UpdateCardTitleBar(r.Title.Length);
|
||
|
||
// Item identification
|
||
ItemNameText.Text = r.ItemName;
|
||
|
||
var brandModel = string.Join(" \u00b7 ",
|
||
new[] { r.Brand, r.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||
if (!string.IsNullOrWhiteSpace(brandModel))
|
||
{
|
||
BrandModelText.Text = brandModel;
|
||
BrandPill.Visibility = Visibility.Visible;
|
||
}
|
||
else
|
||
{
|
||
BrandPill.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
ConditionText.Text = r.ConditionNotes;
|
||
|
||
// Confidence badge
|
||
ConfidenceBadge.Visibility = Visibility.Collapsed;
|
||
if (!string.IsNullOrWhiteSpace(r.IdentificationConfidence))
|
||
{
|
||
ConfidenceText.Text = r.IdentificationConfidence;
|
||
ConfidenceBadge.Background = r.IdentificationConfidence.ToLower() switch
|
||
{
|
||
"high" => new SolidColorBrush(Color.FromRgb(34, 139, 34)),
|
||
"medium" => new SolidColorBrush(Color.FromRgb(210, 140, 0)),
|
||
_ => new SolidColorBrush(Color.FromRgb(192, 0, 0))
|
||
};
|
||
ConfidenceBadge.Visibility = Visibility.Visible;
|
||
}
|
||
ConfidenceNotesText.Text = r.ConfidenceNotes;
|
||
ConfidenceNotesText.Visibility = string.IsNullOrWhiteSpace(r.ConfidenceNotes)
|
||
? Visibility.Collapsed : Visibility.Visible;
|
||
|
||
// Price badge
|
||
PriceSuggestedText.Text = r.PriceSuggested > 0 ? $"\u00a3{r.PriceSuggested:F2}" : "\u2014";
|
||
|
||
// Price range bar
|
||
if (r.PriceMin > 0 && r.PriceMax > 0)
|
||
{
|
||
PriceMinText.Text = $"\u00a3{r.PriceMin:F2}";
|
||
PriceMaxText.Text = $"\u00a3{r.PriceMax:F2}";
|
||
PriceRangeBar.Visibility = Visibility.Visible;
|
||
}
|
||
else
|
||
{
|
||
PriceRangeBar.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
PriceOverride.Value = (double)Math.Round(r.PriceSuggested, 2); // Issue 6
|
||
|
||
// Price reasoning
|
||
PriceReasoningText.Text = r.PriceReasoning;
|
||
PriceReasoningText.Visibility = string.IsNullOrWhiteSpace(r.PriceReasoning)
|
||
? Visibility.Collapsed : Visibility.Visible;
|
||
|
||
// Editable fields
|
||
TitleBox.Text = r.Title;
|
||
DescriptionBox.Text = r.Description;
|
||
|
||
// Reset live price row until lookup completes
|
||
LivePriceRow.Visibility = Visibility.Collapsed;
|
||
}
|
||
|
||
private async Task UpdateLivePricesAsync(string query)
|
||
{
|
||
if (_priceService == null) return;
|
||
|
||
// Issue 7: guard against off-thread callers (fire-and-forget may lose sync context)
|
||
if (!Dispatcher.CheckAccess())
|
||
{
|
||
await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap();
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Issue 1: spinner-show inside try so a disposed control doesn't crash the caller
|
||
LivePriceRow.Visibility = Visibility.Visible;
|
||
LivePriceSpinner.Visibility = Visibility.Visible;
|
||
LivePriceStatus.Text = "Checking live eBay UK prices…";
|
||
|
||
var live = await _priceService.GetLivePricesAsync(query);
|
||
|
||
if (live.Count == 0)
|
||
{
|
||
LivePriceStatus.Text = "No matching live listings found.";
|
||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
// Update range bar with real data
|
||
PriceMinText.Text = $"£{live.Min:F2}";
|
||
PriceMaxText.Text = $"£{live.Max:F2}";
|
||
PriceRangeBar.Visibility = Visibility.Visible;
|
||
|
||
// Update suggested price to 40th percentile (competitive but not cheapest)
|
||
var suggested = live.Suggested;
|
||
PriceSuggestedText.Text = $"£{suggested:F2}";
|
||
PriceOverride.Value = (double)Math.Round(suggested, 2);
|
||
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
|
||
|
||
// Update card preview price
|
||
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(suggested);
|
||
CardPriceDigit.Text = $"£{suggested:F2}";
|
||
var snapped = Math.Round((double)suggested * 2) / 2;
|
||
if (snapped >= PriceSliderCard.Minimum && snapped <= PriceSliderCard.Maximum)
|
||
PriceSliderCard.Value = snapped;
|
||
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(suggested);
|
||
SliderDigitLabel.Text = $"£{suggested:F2}";
|
||
|
||
// Show note on card
|
||
var noteText = $"eBay: {live.Count} similar listing{(live.Count == 1 ? "" : "s")}, range £{live.Min:F2}–£{live.Max:F2}";
|
||
CardLivePriceNote.Text = noteText;
|
||
CardLivePriceNote.Visibility = Visibility.Visible;
|
||
|
||
// Update status label
|
||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||
LivePriceStatus.Text =
|
||
$"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
|
||
$"(range £{live.Min:F2} – £{live.Max:F2})";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
try
|
||
{
|
||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
||
LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
|
||
}
|
||
catch { /* control may be unloaded by the time catch runs */ }
|
||
}
|
||
}
|
||
|
||
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
var len = TitleBox.Text.Length;
|
||
TitleCount.Text = $"{len} / 80";
|
||
TitleCount.Foreground = len > 75
|
||
? Brushes.OrangeRed
|
||
: (Brush)FindResource("MahApps.Brushes.Gray5");
|
||
}
|
||
|
||
private void UseDetails_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_lastResult == null) return;
|
||
|
||
// Copy any edits back into result before passing on
|
||
_lastResult.Title = TitleBox.Text;
|
||
_lastResult.Description = DescriptionBox.Text;
|
||
|
||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||
UseDetailsRequested?.Invoke(_lastResult, _currentImagePaths, price); // Q1: pass all photos
|
||
|
||
// Switch to New Listing tab
|
||
if (Window.GetWindow(this) is MainWindow mw)
|
||
mw.SwitchToNewListingTab();
|
||
|
||
GetWindow()?.SetStatus($"Details loaded for: {_lastResult.Title}");
|
||
}
|
||
|
||
private void SaveListing_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_lastResult == null || _savedService == null) return;
|
||
|
||
// Use edited title/description if the user changed them
|
||
var title = TitleBox.Text.Trim();
|
||
var description = DescriptionBox.Text.Trim();
|
||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||
|
||
_savedService.Save(title, description, price,
|
||
_lastResult.CategoryKeyword, _lastResult.ConditionNotes,
|
||
_currentImagePaths);
|
||
|
||
// Brief visual confirmation on the button — M1: use field timer, stop previous if re-saved quickly
|
||
_saveBtnTimer?.Stop();
|
||
SaveIcon.Visibility = Visibility.Collapsed;
|
||
SavedIcon.Visibility = Visibility.Visible;
|
||
SaveBtnText.Text = "Saved!";
|
||
|
||
_saveBtnTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||
_saveBtnTimer.Tick += (s, _) =>
|
||
{
|
||
_saveBtnTimer?.Stop();
|
||
SaveIcon.Visibility = Visibility.Visible;
|
||
SavedIcon.Visibility = Visibility.Collapsed;
|
||
SaveBtnText.Text = "Save Listing";
|
||
};
|
||
_saveBtnTimer.Start();
|
||
|
||
ShowSaveToast();
|
||
|
||
// Notify main window to refresh the gallery
|
||
(Window.GetWindow(this) as MainWindow)?.RefreshSavedListings();
|
||
GetWindow()?.SetStatus($"Saved: {title}");
|
||
}
|
||
|
||
private void AnalyseAnother_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
_currentImagePaths.Clear();
|
||
_lastResult = null;
|
||
_activePhotoIndex = 0;
|
||
PhotoPreview.Source = null;
|
||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
||
DropHint.Visibility = Visibility.Visible;
|
||
PhotoFilename.Text = "";
|
||
AnalyseBtn.IsEnabled = false;
|
||
UpdateThumbStrip();
|
||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||
ResultsPanel.Opacity = 0;
|
||
IdlePanel.Visibility = Visibility.Visible;
|
||
}
|
||
|
||
// ---- Thumb strip ----
|
||
|
||
/// <summary>
|
||
/// Rebuilds the thumbnail strip from <see cref="_currentImagePaths"/>.
|
||
/// Shows/hides the strip based on whether 2+ photos are loaded.
|
||
/// </summary>
|
||
private void UpdateThumbStrip()
|
||
{
|
||
PhotoThumbStrip.Children.Clear();
|
||
|
||
if (_currentImagePaths.Count < 2)
|
||
{
|
||
ThumbStripScroller.Visibility = Visibility.Collapsed;
|
||
return;
|
||
}
|
||
|
||
ThumbStripScroller.Visibility = Visibility.Visible;
|
||
|
||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
||
|
||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
||
{
|
||
var index = i; // capture for closure
|
||
var path = _currentImagePaths[i];
|
||
|
||
BitmapImage? thumb = null;
|
||
try
|
||
{
|
||
thumb = new BitmapImage();
|
||
thumb.BeginInit();
|
||
thumb.UriSource = new Uri(path, UriKind.Absolute); // W1
|
||
thumb.CacheOption = BitmapCacheOption.OnLoad;
|
||
thumb.DecodePixelWidth = 80;
|
||
thumb.EndInit();
|
||
thumb.Freeze(); // M2
|
||
}
|
||
catch
|
||
{
|
||
// Skip thumbnails that fail to load
|
||
continue;
|
||
}
|
||
|
||
bool isActive = (index == _activePhotoIndex);
|
||
|
||
var img = new Image
|
||
{
|
||
Source = thumb,
|
||
Width = 60,
|
||
Height = 60,
|
||
Stretch = Stretch.UniformToFill
|
||
};
|
||
RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
|
||
// Clip image to rounded rect
|
||
img.Clip = new System.Windows.Media.RectangleGeometry(
|
||
new System.Windows.Rect(0, 0, 60, 60), 4, 4);
|
||
|
||
var border = new Border
|
||
{
|
||
Width = 64,
|
||
Height = 64,
|
||
Margin = new Thickness(3),
|
||
CornerRadius = new CornerRadius(5),
|
||
BorderThickness = new Thickness(isActive ? 2.5 : 1.5),
|
||
BorderBrush = isActive ? accentBrush : neutralBrush,
|
||
Background = System.Windows.Media.Brushes.Transparent,
|
||
Cursor = System.Windows.Input.Cursors.Hand,
|
||
Child = img
|
||
};
|
||
|
||
border.MouseLeftButtonUp += (_, _) =>
|
||
{
|
||
_activePhotoIndex = index;
|
||
|
||
// Load that photo into main preview
|
||
try
|
||
{
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.UriSource = new Uri(_currentImagePaths[index], UriKind.Absolute); // W1
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||
bmp.DecodePixelWidth = 600;
|
||
bmp.EndInit();
|
||
bmp.Freeze(); // M2
|
||
PhotoPreview.Source = bmp;
|
||
}
|
||
catch { /* silently ignore */ }
|
||
|
||
// Q3: full rebuild avoids index-desync when thumbnails skipped on load error
|
||
UpdateThumbStrip();
|
||
};
|
||
|
||
PhotoThumbStrip.Children.Add(border);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates only the border highlights on the existing thumb strip children
|
||
/// after the active index changes, avoiding a full thumbnail reload.
|
||
/// </summary>
|
||
private void UpdateThumbStripHighlight()
|
||
{
|
||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
||
|
||
int childIndex = 0;
|
||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
||
{
|
||
if (childIndex >= PhotoThumbStrip.Children.Count) break;
|
||
if (PhotoThumbStrip.Children[childIndex] is Border b)
|
||
{
|
||
bool isActive = (i == _activePhotoIndex);
|
||
b.BorderBrush = isActive ? accentBrush : neutralBrush;
|
||
b.BorderThickness = new Thickness(isActive ? 2.5 : 1.5);
|
||
}
|
||
childIndex++;
|
||
}
|
||
}
|
||
|
||
// ---- Save toast ----
|
||
|
||
private void ShowSaveToast()
|
||
{
|
||
// Issue 8: always restart — stop any in-progress hold timer and cancel the running
|
||
// animation so the flag can never get permanently stuck and rapid saves feel responsive.
|
||
_holdTimer?.Stop();
|
||
_holdTimer = null;
|
||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation
|
||
|
||
SaveToast.Visibility = Visibility.Visible;
|
||
|
||
// Slide in: Y from +40 to 0
|
||
var slideIn = new DoubleAnimation(40, 0, new Duration(TimeSpan.FromMilliseconds(220)))
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||
};
|
||
|
||
// After 2.5 s total: slide out Y from 0 to +40, then hide
|
||
slideIn.Completed += (_, _) =>
|
||
{
|
||
_holdTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(2500 - 220) }; // Q2: field
|
||
_holdTimer.Tick += (s2, _) =>
|
||
{
|
||
_holdTimer.Stop();
|
||
var slideOut = new DoubleAnimation(0, 40, new Duration(TimeSpan.FromMilliseconds(180)))
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
||
};
|
||
slideOut.Completed += (_, _) =>
|
||
{
|
||
SaveToast.Visibility = Visibility.Collapsed;
|
||
};
|
||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
|
||
};
|
||
_holdTimer.Start();
|
||
};
|
||
|
||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideIn);
|
||
}
|
||
|
||
// ---- Copy buttons ----
|
||
|
||
private void CopyTitle_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!string.IsNullOrEmpty(TitleBox.Text))
|
||
Clipboard.SetText(TitleBox.Text);
|
||
}
|
||
|
||
private void CopyDescription_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!string.IsNullOrEmpty(DescriptionBox.Text))
|
||
Clipboard.SetText(DescriptionBox.Text);
|
||
}
|
||
|
||
// ---- Card preview handlers ----
|
||
|
||
private void LooksGood_Click(object sender, RoutedEventArgs e)
|
||
=> SaveListing_Click(sender, e);
|
||
|
||
private void ChangeSomething_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
var expanding = CardChangePanel.Visibility != Visibility.Visible;
|
||
CardChangePanel.Visibility = expanding ? Visibility.Visible : Visibility.Collapsed;
|
||
ChangeChevron.Kind = expanding
|
||
? MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronUp
|
||
: MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
|
||
}
|
||
|
||
private void PriceSliderCard_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||
{
|
||
var price = (decimal)e.NewValue;
|
||
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(price);
|
||
SliderDigitLabel.Text = $"£{price:F2}";
|
||
// Keep card price display in sync
|
||
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(price);
|
||
CardPriceDigit.Text = $"£{price:F2}";
|
||
// Keep hidden ResultsPanel in sync so SaveListing_Click gets the right value
|
||
PriceOverride.Value = e.NewValue;
|
||
if (_lastResult != null) _lastResult.PriceSuggested = price;
|
||
}
|
||
|
||
private void CardTitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
||
{
|
||
var len = CardTitleBox.Text.Length;
|
||
UpdateCardTitleBar(len);
|
||
// Keep hidden ResultsPanel in sync
|
||
TitleBox.Text = CardTitleBox.Text;
|
||
}
|
||
|
||
private void UpdateCardTitleBar(int len)
|
||
{
|
||
if (!IsLoaded) return;
|
||
var trackWidth = CardTitleBar.Parent is Grid g ? g.ActualWidth : 0;
|
||
if (trackWidth <= 0) return;
|
||
CardTitleBar.Width = trackWidth * (len / 80.0);
|
||
CardTitleBar.Background = len > 75
|
||
? System.Windows.Media.Brushes.OrangeRed
|
||
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
|
||
}
|
||
|
||
private void SaveWithChanges_Click(object sender, RoutedEventArgs e)
|
||
{
|
||
if (_lastResult != null)
|
||
{
|
||
_lastResult.Title = CardTitleBox.Text.Trim();
|
||
TitleBox.Text = _lastResult.Title;
|
||
}
|
||
SaveListing_Click(sender, e);
|
||
}
|
||
|
||
// ---- Loading step cycling ----
|
||
|
||
private void LoadingTimer_Tick(object? sender, EventArgs e)
|
||
{
|
||
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
|
||
LoadingStepText.Text = LoadingSteps[_loadingStep];
|
||
}
|
||
|
||
// ---- Helpers ----
|
||
|
||
private void SetAnalysing(bool busy)
|
||
{
|
||
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
|
||
AnalyseBtn.IsEnabled = !busy;
|
||
IsEnabled = !busy;
|
||
|
||
if (busy)
|
||
{
|
||
IdlePanel.Visibility = Visibility.Collapsed;
|
||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||
LoadingPanel.Visibility = Visibility.Visible;
|
||
_loadingStep = 0;
|
||
LoadingStepText.Text = LoadingSteps[0];
|
||
_loadingTimer.Start();
|
||
}
|
||
else
|
||
{
|
||
_loadingTimer.Stop();
|
||
LoadingPanel.Visibility = Visibility.Collapsed;
|
||
}
|
||
}
|
||
|
||
private static bool IsImageFile(string path)
|
||
{
|
||
var ext = Path.GetExtension(path).ToLower();
|
||
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
|
||
}
|
||
|
||
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
|
||
}
|