Files
EbayListingTool/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
Peter Foster b3ef79e495 Add dyscalculia-friendly UI: card preview, verbal prices, relative dates
- 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>
2026-04-15 03:00:36 +01:00

834 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}