2 Commits

Author SHA1 Message Date
Peter Foster
a9cfb7f613 Fix NullReferenceException in PriceSliderCard_ValueChanged
Slider ValueChanged fires during XAML init before named controls exist.
Guard with IsLoaded check, same pattern as UpdateCardTitleBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:06:17 +01:00
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
5 changed files with 397 additions and 19 deletions

View File

@@ -0,0 +1,79 @@
namespace EbayListingTool.Helpers;
public static class NumberWords
{
private static readonly string[] Ones =
[
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
"seventeen", "eighteen", "nineteen"
];
private static readonly string[] Tens =
[
"", "", "twenty", "thirty", "forty", "fifty",
"sixty", "seventy", "eighty", "ninety"
];
/// <summary>
/// Converts a price to a friendly verbal string.
/// £17.49 → "about seventeen pounds"
/// £17.50 → "about seventeen pounds fifty"
/// £0.50 → "fifty pence"
/// </summary>
public static string ToVerbalPrice(decimal price)
{
if (price <= 0) return "no price set";
// Snap to nearest 50p
var rounded = Math.Round(price * 2) / 2m;
int pounds = (int)rounded;
bool hasFifty = (rounded - pounds) >= 0.5m;
if (pounds == 0)
return "fifty pence";
var poundsWord = IntToWords(pounds);
var poundsLabel = pounds == 1 ? "pound" : "pounds";
var suffix = hasFifty ? " fifty" : "";
return $"about {poundsWord} {poundsLabel}{suffix}";
}
/// <summary>
/// Converts a UTC DateTime to a human-friendly relative string.
/// </summary>
public static string ToRelativeDate(DateTime utcTime)
{
var diff = DateTime.UtcNow - utcTime;
if (diff.TotalSeconds < 60) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
if (diff.TotalHours < 2) return "about an hour ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
if (diff.TotalDays < 2) return "yesterday";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays} days ago";
if (diff.TotalDays < 14) return "last week";
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)} weeks ago";
if (diff.TotalDays < 60) return "last month";
return $"{(int)(diff.TotalDays / 30)} months ago";
}
private static string IntToWords(int n)
{
if (n < 20) return Ones[n];
if (n < 100)
{
var t = Tens[n / 10];
var o = n % 10;
return o == 0 ? t : $"{t}-{Ones[o]}";
}
if (n < 1000)
{
var h = Ones[n / 100];
var rest = n % 100;
return rest == 0 ? $"{h} hundred" : $"{h} hundred and {IntToWords(rest)}";
}
return n.ToString(); // fallback for very large prices
}
}

View File

@@ -1,3 +1,5 @@
using EbayListingTool.Helpers;
namespace EbayListingTool.Models; namespace EbayListingTool.Models;
public class SavedListing public class SavedListing
@@ -18,5 +20,9 @@ public class SavedListing
public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—"; public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
public string PriceWords => NumberWords.ToVerbalPrice(Price);
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm"); public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
public string SavedAtRelative => NumberWords.ToRelativeDate(SavedAt);
} }

View File

@@ -325,6 +325,166 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ============================================================
Card Preview — dyscalculia-friendly quick-approve flow
============================================================ -->
<StackPanel x:Name="CardPreviewPanel" Visibility="Collapsed">
<!-- Item card -->
<Border CornerRadius="10" Padding="16" Margin="0,0,0,12"
Background="{DynamicResource MahApps.Brushes.Gray10}"
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}"
BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Cover photo -->
<Border Grid.Column="0" CornerRadius="7" ClipToBounds="True"
Width="110" Height="110">
<Image x:Name="CardPhoto" Stretch="UniformToFill"/>
</Border>
<!-- Details -->
<StackPanel Grid.Column="1" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="CardItemName"
FontSize="17" FontWeight="Bold"
TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<TextBlock x:Name="CardCondition"
FontSize="11" Margin="0,3,0,0"
TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<!-- Photo dots (built in code-behind) -->
<StackPanel x:Name="CardPhotoDots"
Orientation="Horizontal" Margin="0,8,0,0"/>
<!-- Verbal price — primary -->
<TextBlock x:Name="CardPriceVerbal"
FontSize="24" FontWeight="Bold" Margin="0,10,0,2"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<!-- Digit price — secondary/small -->
<TextBlock x:Name="CardPriceDigit"
FontSize="11" Opacity="0.40"/>
<!-- Category pill -->
<Border CornerRadius="10" Padding="8,3" Margin="0,10,0,0"
Background="{DynamicResource MahApps.Brushes.Gray9}"
HorizontalAlignment="Left">
<TextBlock x:Name="CardCategory" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- Live price note (updated async) -->
<TextBlock x:Name="CardLivePriceNote"
FontSize="10" Margin="0,0,0,10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
TextWrapping="Wrap" Visibility="Collapsed"/>
<!-- Primary action buttons -->
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" x:Name="LooksGoodBtn"
Click="LooksGood_Click"
Height="54" FontSize="15" FontWeight="SemiBold"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Check" Width="17" Height="17"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Looks good" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="2" x:Name="ChangeSomethingBtn"
Click="ChangeSomething_Click"
Height="54" FontSize="13"
Style="{DynamicResource MahApps.Styles.Button.Square}">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="ChangeChevron"
Kind="ChevronDown" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Change something" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<!-- Change panel (collapsed by default) -->
<StackPanel x:Name="CardChangePanel" Visibility="Collapsed" Margin="0,4,0,0">
<!-- Price slider -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<TextBlock Text="PRICE" Style="{StaticResource SectionHeading}"
Margin="0,0,0,10"/>
<TextBlock x:Name="SliderVerbalLabel"
FontSize="22" FontWeight="Bold" Margin="0,0,0,2"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock x:Name="SliderDigitLabel"
FontSize="11" Opacity="0.40" Margin="0,0,0,12"/>
<Slider x:Name="PriceSliderCard"
Minimum="0.50" Maximum="200"
SmallChange="0.5" LargeChange="5"
TickFrequency="0.5" IsSnapToTickEnabled="True"
ValueChanged="PriceSliderCard_ValueChanged"/>
<Grid Margin="0,3,0,0">
<TextBlock Text="cheaper" FontSize="10" Opacity="0.45"
HorizontalAlignment="Left"/>
<TextBlock Text="pricier" FontSize="10" Opacity="0.45"
HorizontalAlignment="Right"/>
</Grid>
</StackPanel>
</Border>
<!-- Title edit -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<TextBlock Text="TITLE" Style="{StaticResource SectionHeading}"
Margin="0,0,0,8"/>
<TextBox x:Name="CardTitleBox"
TextWrapping="Wrap" AcceptsReturn="False"
MaxLength="80" FontSize="13"
TextChanged="CardTitleBox_TextChanged"/>
<!-- Colour bar — no digit counter -->
<Grid Margin="0,6,0,0" Height="4">
<Border CornerRadius="2" Background="{DynamicResource MahApps.Brushes.Gray8}"/>
<Border x:Name="CardTitleBar" CornerRadius="2"
HorizontalAlignment="Left" Width="0"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Grid>
</StackPanel>
</Border>
<!-- Save with changes -->
<Button Content="Save with changes"
Click="SaveWithChanges_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="46" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Stretch" Margin="0,4,0,8"/>
<Button Content="Analyse another item"
Click="AnalyseAnother_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="36" FontSize="12"
HorizontalAlignment="Stretch"/>
</StackPanel>
</StackPanel>
<!-- Results (hidden until analysis complete) --> <!-- Results (hidden until analysis complete) -->
<StackPanel x:Name="ResultsPanel" Visibility="Collapsed" Opacity="0"> <StackPanel x:Name="ResultsPanel" Visibility="Collapsed" Opacity="0">
<StackPanel.RenderTransform> <StackPanel.RenderTransform>

View File

@@ -4,6 +4,7 @@ using System.Windows.Media;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using EbayListingTool.Helpers;
using EbayListingTool.Models; using EbayListingTool.Models;
using EbayListingTool.Services; using EbayListingTool.Services;
using Microsoft.Win32; using Microsoft.Win32;
@@ -285,9 +286,65 @@ public partial class PhotoAnalysisView : UserControl
private void ShowResults(PhotoAnalysisResult r) private void ShowResults(PhotoAnalysisResult r)
{ {
IdlePanel.Visibility = Visibility.Collapsed; IdlePanel.Visibility = Visibility.Collapsed;
LoadingPanel.Visibility = Visibility.Collapsed; LoadingPanel.Visibility = Visibility.Collapsed;
ResultsPanel.Visibility = Visibility.Visible; 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 // Item identification
ItemNameText.Text = r.ItemName; ItemNameText.Text = r.ItemName;
@@ -351,10 +408,6 @@ public partial class PhotoAnalysisView : UserControl
// Reset live price row until lookup completes // Reset live price row until lookup completes
LivePriceRow.Visibility = Visibility.Collapsed; LivePriceRow.Visibility = Visibility.Collapsed;
// Animate results in
var sb = (Storyboard)FindResource("ResultsReveal");
sb.Begin(this);
} }
private async Task UpdateLivePricesAsync(string query) private async Task UpdateLivePricesAsync(string query)
@@ -392,9 +445,23 @@ public partial class PhotoAnalysisView : UserControl
// Update suggested price to 40th percentile (competitive but not cheapest) // Update suggested price to 40th percentile (competitive but not cheapest)
var suggested = live.Suggested; var suggested = live.Suggested;
PriceSuggestedText.Text = $"£{suggested:F2}"; PriceSuggestedText.Text = $"£{suggested:F2}";
PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift PriceOverride.Value = (double)Math.Round(suggested, 2);
if (_lastResult != null) _lastResult.PriceSuggested = suggested; 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 // Update status label
LivePriceSpinner.Visibility = Visibility.Collapsed; LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text = LivePriceStatus.Text =
@@ -666,6 +733,63 @@ public partial class PhotoAnalysisView : UserControl
Clipboard.SetText(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)
{
if (!IsLoaded) return;
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 ---- // ---- Loading step cycling ----
private void LoadingTimer_Tick(object? sender, EventArgs e) private void LoadingTimer_Tick(object? sender, EventArgs e)

View File

@@ -189,23 +189,32 @@ public partial class SavedListingsView : UserControl
Margin = new Thickness(0, 0, 0, 3) Margin = new Thickness(0, 0, 0, 3)
}); });
var priceRow = new StackPanel { Orientation = Orientation.Horizontal }; // Verbal price — primary; digit price small beneath
priceRow.Children.Add(new TextBlock var priceVerbal = new TextBlock
{ {
Text = listing.PriceDisplay, Text = listing.PriceWords,
FontSize = 13, FontSize = 13,
FontWeight = FontWeights.Bold, FontWeight = FontWeights.Bold,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent"), Foreground = (Brush)FindResource("MahApps.Brushes.Accent"),
Margin = new Thickness(0, 0, 8, 0) TextTrimming = TextTrimming.CharacterEllipsis
}); };
textStack.Children.Add(priceRow); textStack.Children.Add(priceVerbal);
textStack.Children.Add(new TextBlock textStack.Children.Add(new TextBlock
{ {
Text = listing.SavedAtDisplay, Text = listing.PriceDisplay,
FontSize = 10, FontSize = 9,
Opacity = 0.40,
Margin = new Thickness(0, 1, 0, 0)
});
// Relative date
textStack.Children.Add(new TextBlock
{
Text = listing.SavedAtRelative,
FontSize = 10,
Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"), Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"),
Margin = new Thickness(0, 3, 0, 0) Margin = new Thickness(0, 3, 0, 0)
}); });
grid.Children.Add(textStack); grid.Children.Add(textStack);
@@ -264,7 +273,7 @@ public partial class SavedListingsView : UserControl
DetailTitle.Text = listing.Title; DetailTitle.Text = listing.Title;
DetailPrice.Text = listing.PriceDisplay; DetailPrice.Text = listing.PriceDisplay;
DetailCategory.Text = listing.Category; DetailCategory.Text = listing.Category;
DetailDate.Text = listing.SavedAtDisplay; DetailDate.Text = listing.SavedAtRelative;
DetailDescription.Text = listing.Description; DetailDescription.Text = listing.Description;
if (!string.IsNullOrWhiteSpace(listing.ConditionNotes)) if (!string.IsNullOrWhiteSpace(listing.ConditionNotes))