Chapter 23: Phase 4 - POS Client Implementation
23.1 Overview
Phase 4 delivers the POS Client Terminal - the revenue-generating touchpoint where customer transactions occur. This 6-week dedicated phase (Weeks 14-19) builds the native, offline-capable application that cashiers and floor staff use daily.
Key Insight: The Web Admin Portal is the main portal for platform operations. The POS Client is the main portal for client operations. Both deserve equal architectural attention.
Why POS Client Needs Its Own Phase
| Aspect | Web Admin Portal | POS Client Terminal |
|---|---|---|
| Primary Users | Store managers, admins | Cashiers, floor staff |
| Technology | Blazor Server (web) | .NET MAUI Blazor Hybrid (native) |
| Connectivity | Always online | Offline-first required |
| Hardware | None | Printers, scanners, cash drawers, payment terminals |
| UI Complexity | Standard CRUD forms | Touch-optimized, configurable layouts |
| Sessions | Long (hours) | Short (per transaction) |
| Critical Path | Business management | Revenue generation |
23.2 Technology Decision: .NET MAUI Blazor Hybrid
Why Not PWA?
| Factor | PWA | Native/Hybrid | Winner |
|---|---|---|---|
| Offline reliability | iOS evicts data after 7 days unused | True SQLite, persistent | Native |
| Receipt printers | Requires bridge app | Direct P/Invoke | Native |
| Cash drawers | No Web API exists | Native access | Native |
| Barcode scanning | Safari doesn’t support | ZXing.Net.Maui | Native |
| Update deployment | Instant (web push) | Portal download | PWA |
| Development cost | Lower | Higher | PWA |
Verdict: PWA is insufficient for mission-critical POS. Native/Hybrid required.
Why .NET MAUI Blazor Hybrid?
| Rationale | Benefit |
|---|---|
| Aligns with stack | Backend uses ASP.NET Core + Blazor |
| Matches ADR-002 | Offline-first SQLite architecture maps directly |
| Single codebase | Android tablets, Windows back-office, macOS |
| Hardware access | Native APIs for printers, scanners |
| Skill reuse | Same Blazor components for web admin portal |
23.3 Phase 4 Scope
POS CLIENT SCOPE
================
1. UI/UX DESIGN
├── Touch-optimized sale screen
├── Product grid with categories
├── Cart management
├── Customer lookup
├── Retail Pro-style drag-and-drop layout configuration
└── Cashier vs Manager mode switching
2. CORE FEATURES
├── Sale processing (full workflow)
├── Payment handling (cash, card, split)
├── Receipt printing
├── Returns/exchanges
├── Discounts/promotions
├── Gift cards
├── Customer loyalty integration
└── End-of-day operations
3. HARDWARE INTEGRATION
├── Receipt printers (Epson, Star Micronics)
├── Barcode scanners (USB, Bluetooth)
├── Cash drawers (kick signals)
├── Payment terminals (Stripe Terminal)
├── Customer-facing displays
└── RFID readers (Raptag integration)
4. OFFLINE OPERATIONS
├── Local SQLite database
├── Transaction queue
├── Sync engine
├── Conflict resolution
└── Offline payment handling
5. DISTRIBUTION & UPDATES
├── Portal-based download
├── Registration flow
├── Auto-update mechanism
└── Version management
6. CONFIGURATION SYSTEM
├── Drag-and-drop UI builder (Retail Pro style)
├── Quick-access button configuration
├── Receipt template customization
├── Per-location settings
└── Hardware profile management
23.4 Week 14: Project Setup & Core UI
Day 1-2: .NET MAUI Blazor Hybrid Project
Objective: Create the project structure with offline-first architecture.
Claude Command:
/dev-team create .NET MAUI Blazor Hybrid project for POS client
Project Structure:
RapOS.PosClient/
├── RapOS.PosClient/
│ ├── App.xaml
│ ├── MauiProgram.cs
│ ├── Platforms/
│ │ ├── Android/
│ │ ├── iOS/
│ │ ├── MacCatalyst/
│ │ └── Windows/
│ ├── Resources/
│ └── wwwroot/
├── RapOS.PosClient.Core/
│ ├── Models/
│ ├── Services/
│ ├── Data/
│ └── Interfaces/
├── RapOS.PosClient.UI/
│ ├── Components/
│ │ ├── Layout/
│ │ ├── Sale/
│ │ ├── Products/
│ │ └── Shared/
│ ├── Pages/
│ └── Themes/
└── RapOS.PosClient.Hardware/
├── Printers/
├── Scanners/
├── CashDrawers/
└── Payments/
Implementation:
// MauiProgram.cs
using Microsoft.Extensions.Logging;
namespace RapOS.PosClient;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
// Core services
builder.Services.AddSingleton<ILocalDatabase, SqliteDatabase>();
builder.Services.AddSingleton<ISyncService, SyncService>();
builder.Services.AddSingleton<ITerminalContext, TerminalContext>();
// Hardware services
builder.Services.AddSingleton<IPrinterService, EscPosPrinterService>();
builder.Services.AddSingleton<IScannerService, BarcodeScannerService>();
builder.Services.AddSingleton<ICashDrawerService, CashDrawerService>();
// Sale services
builder.Services.AddScoped<ISaleService, SaleService>();
builder.Services.AddScoped<ICartService, CartService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
// Configuration
builder.Services.AddSingleton<ILayoutService, LayoutService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>();
return builder.Build();
}
}
Day 3-4: SQLite Local Database Schema
Objective: Implement offline-first local database with sync support.
Claude Command:
/dev-team create SQLite schema for offline POS operations
Implementation:
// RapOS.PosClient.Core/Data/SqliteDatabase.cs
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace RapOS.PosClient.Core.Data;
public interface ILocalDatabase
{
Task InitializeAsync();
Task<SqliteConnection> GetConnectionAsync();
Task ExecuteAsync(string sql, object? parameters = null);
Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null);
Task<List<T>> QueryAsync<T>(string sql, object? parameters = null);
}
public class SqliteDatabase : ILocalDatabase
{
private readonly string _dbPath;
private SqliteConnection? _connection;
public SqliteDatabase()
{
_dbPath = Path.Combine(
FileSystem.AppDataDirectory,
"rapos_pos.db");
}
public async Task InitializeAsync()
{
Batteries.Init();
_connection = new SqliteConnection($"Data Source={_dbPath}");
await _connection.OpenAsync();
await CreateTablesAsync();
}
private async Task CreateTablesAsync()
{
// Terminal configuration
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS terminal_config (
id INTEGER PRIMARY KEY,
tenant_code TEXT NOT NULL,
location_id TEXT NOT NULL,
terminal_id TEXT NOT NULL,
terminal_name TEXT NOT NULL,
api_endpoint TEXT NOT NULL,
api_key TEXT NOT NULL,
layout_config TEXT,
last_sync_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
// Products cache
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
sku TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
category_id TEXT,
category_name TEXT,
base_price REAL NOT NULL,
tax_rate REAL DEFAULT 0,
image_url TEXT,
barcode TEXT,
is_active INTEGER DEFAULT 1,
quantity_on_hand INTEGER DEFAULT 0,
synced_at TEXT NOT NULL,
UNIQUE(sku)
)");
// Categories cache
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
display_order INTEGER DEFAULT 0,
color TEXT,
icon TEXT,
synced_at TEXT NOT NULL
)");
// Customers cache
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT,
phone TEXT,
loyalty_points INTEGER DEFAULT 0,
loyalty_tier TEXT,
synced_at TEXT NOT NULL
)");
// Local sales (pending sync)
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS sales (
id TEXT PRIMARY KEY,
local_id INTEGER AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'pending',
customer_id TEXT,
cashier_id TEXT NOT NULL,
subtotal REAL NOT NULL,
tax_total REAL NOT NULL,
discount_total REAL DEFAULT 0,
grand_total REAL NOT NULL,
created_at TEXT NOT NULL,
completed_at TEXT,
synced_at TEXT,
sync_attempts INTEGER DEFAULT 0,
sync_error TEXT
)");
// Sale line items
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS sale_items (
id TEXT PRIMARY KEY,
sale_id TEXT NOT NULL,
product_id TEXT NOT NULL,
sku TEXT NOT NULL,
name TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_amount REAL DEFAULT 0,
tax_amount REAL NOT NULL,
line_total REAL NOT NULL,
FOREIGN KEY (sale_id) REFERENCES sales(id)
)");
// Payments
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
sale_id TEXT NOT NULL,
method TEXT NOT NULL,
amount REAL NOT NULL,
reference TEXT,
card_last_four TEXT,
card_brand TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (sale_id) REFERENCES sales(id)
)");
// Sync queue for offline transactions
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
priority INTEGER DEFAULT 0,
attempts INTEGER DEFAULT 0,
last_attempt_at TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(entity_type, entity_id, operation)
)");
// Quick access buttons configuration
await ExecuteAsync(@"
CREATE TABLE IF NOT EXISTS quick_access_buttons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
position INTEGER NOT NULL,
product_id TEXT,
category_id TEXT,
label TEXT NOT NULL,
color TEXT,
icon TEXT,
action_type TEXT NOT NULL
)");
// Create indexes for performance
await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_sku ON products(sku)");
await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_barcode ON products(barcode)");
await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id)");
await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_sales_status ON sales(status)");
await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority DESC, created_at ASC)");
}
public async Task<SqliteConnection> GetConnectionAsync()
{
if (_connection == null)
await InitializeAsync();
return _connection!;
}
public async Task ExecuteAsync(string sql, object? parameters = null)
{
var conn = await GetConnectionAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// Add parameters...
await cmd.ExecuteNonQueryAsync();
}
public async Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null)
{
// Implementation with Dapper-style mapping
throw new NotImplementedException();
}
public async Task<List<T>> QueryAsync<T>(string sql, object? parameters = null)
{
// Implementation with Dapper-style mapping
throw new NotImplementedException();
}
}
Day 5: Main Sale Screen UI
Objective: Build the primary POS interface with product grid and cart.
Claude Command:
/dev-team create main sale screen component with responsive touch layout
Implementation:
@* RapOS.PosClient.UI/Pages/SaleScreen.razor *@
@page "/sale"
@inject ICartService CartService
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject IPaymentService PaymentService
@inject ILayoutService LayoutService
<div class="sale-screen @(IsDarkMode ? "dark" : "light")">
@* Header Bar *@
<header class="pos-header">
<div class="header-left">
<img src="images/rapos-logo.svg" class="logo" alt="RapOS" />
<span class="location-name">@CurrentLocation</span>
</div>
<div class="header-center">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text"
@bind="SearchQuery"
@bind:event="oninput"
@onkeydown="HandleSearchKeydown"
placeholder="Search or scan barcode..." />
</div>
</div>
<div class="header-right">
<button class="mode-toggle" @onclick="ToggleMode">
@(IsManagerMode ? "Manager" : "Cashier")
</button>
<button class="theme-toggle" @onclick="ToggleTheme">
@(IsDarkMode ? "☀️" : "🌙")
</button>
<span class="cashier-name">@CashierName</span>
</div>
</header>
@* Main Content Area *@
<main class="pos-main">
@* Left Panel: Products *@
<section class="products-panel">
@* Category Quick Access *@
<div class="category-bar">
<button class="category-btn @(SelectedCategory == null ? "active" : "")"
@onclick="() => SelectCategory(null)">
All
</button>
@foreach (var category in Categories)
{
<button class="category-btn @(SelectedCategory?.Id == category.Id ? "active" : "")"
style="--cat-color: @category.Color"
@onclick="() => SelectCategory(category)">
@category.Name
</button>
}
</div>
@* Product Grid/List *@
<div class="products-container @(IsGridView ? "grid-view" : "list-view")">
@foreach (var product in FilteredProducts)
{
<div class="product-card" @onclick="() => AddToCart(product)">
@if (IsGridView)
{
<div class="product-image">
@if (!string.IsNullOrEmpty(product.ImageUrl))
{
<img src="@product.ImageUrl" alt="@product.Name" />
}
else
{
<span class="placeholder-icon">📦</span>
}
</div>
}
<div class="product-info">
<span class="product-name">@product.Name</span>
<span class="product-sku">@product.Sku</span>
<span class="product-price">@product.BasePrice.ToString("C")</span>
</div>
@if (product.QuantityOnHand <= 3)
{
<span class="low-stock-badge">Low Stock</span>
}
</div>
}
</div>
@* View Toggle *@
<div class="view-toggle">
<button class="@(IsGridView ? "active" : "")" @onclick="() => IsGridView = true">
Grid
</button>
<button class="@(!IsGridView ? "active" : "")" @onclick="() => IsGridView = false">
List
</button>
</div>
</section>
@* Right Panel: Cart *@
<section class="cart-panel">
@* Customer Section *@
<div class="customer-section">
@if (CurrentCustomer != null)
{
<div class="customer-info">
<span class="customer-name">@CurrentCustomer.FullName</span>
<span class="loyalty-points">@CurrentCustomer.LoyaltyPoints pts</span>
<button class="remove-customer" @onclick="ClearCustomer">✕</button>
</div>
}
else
{
<button class="add-customer-btn" @onclick="ShowCustomerLookup">
+ Add Customer
</button>
}
</div>
@* Cart Items *@
<div class="cart-items">
@if (!CartItems.Any())
{
<div class="empty-cart">
<span class="empty-icon">🛒</span>
<p>Cart is empty</p>
<p class="hint">Scan or select products to add</p>
</div>
}
else
{
@foreach (var item in CartItems)
{
<div class="cart-item">
<div class="item-info">
<span class="item-name">@item.Name</span>
<span class="item-sku">@item.Sku</span>
</div>
<div class="item-quantity">
<button @onclick="() => UpdateQuantity(item, -1)">−</button>
<span>@item.Quantity</span>
<button @onclick="() => UpdateQuantity(item, 1)">+</button>
</div>
<div class="item-price">
@item.LineTotal.ToString("C")
</div>
<button class="remove-item" @onclick="() => RemoveItem(item)">
🗑️
</button>
</div>
}
}
</div>
@* Totals *@
<div class="totals-section">
<div class="total-row">
<span>Subtotal</span>
<span>@Subtotal.ToString("C")</span>
</div>
@if (DiscountTotal > 0)
{
<div class="total-row discount">
<span>Discount</span>
<span>-@DiscountTotal.ToString("C")</span>
</div>
}
<div class="total-row">
<span>Tax</span>
<span>@TaxTotal.ToString("C")</span>
</div>
<div class="total-row grand-total">
<span>Total</span>
<span>@GrandTotal.ToString("C")</span>
</div>
</div>
@* Action Buttons *@
<div class="cart-actions">
<button class="action-btn secondary" @onclick="ShowDiscountDialog">
Discount
</button>
<button class="action-btn secondary" @onclick="HoldSale" disabled="@(!CartItems.Any())">
Hold
</button>
<button class="action-btn primary pay-btn"
@onclick="ProceedToPayment"
disabled="@(!CartItems.Any())">
Pay @GrandTotal.ToString("C")
</button>
</div>
</section>
</main>
@* Footer: Quick Access & Function Keys *@
<footer class="pos-footer">
<div class="quick-access">
@foreach (var btn in QuickAccessButtons)
{
<button class="quick-btn"
style="--btn-color: @btn.Color"
@onclick="() => HandleQuickAction(btn)">
@btn.Label
</button>
}
</div>
<div class="function-keys">
<button class="fn-key" @onclick="OpenDrawer">Open Drawer</button>
<button class="fn-key" @onclick="ShowReturns">Returns</button>
<button class="fn-key" @onclick="ShowHeldSales">Held Sales</button>
<button class="fn-key" @onclick="ReprintReceipt">Reprint</button>
@if (IsManagerMode)
{
<button class="fn-key manager" @onclick="ShowReports">Reports</button>
<button class="fn-key manager" @onclick="ShowSettings">Settings</button>
}
</div>
</footer>
@* Offline Indicator *@
@if (!IsOnline)
{
<div class="offline-banner">
<span>⚡ Offline Mode - @PendingSyncCount transactions pending</span>
</div>
}
</div>
@code {
private List<Product> Products = new();
private List<Category> Categories = new();
private List<CartItem> CartItems = new();
private List<QuickAccessButton> QuickAccessButtons = new();
private Category? SelectedCategory;
private Customer? CurrentCustomer;
private string SearchQuery = string.Empty;
private bool IsGridView = true;
private bool IsDarkMode = true;
private bool IsManagerMode = false;
private bool IsOnline = true;
private int PendingSyncCount = 0;
private string CurrentLocation => "Main Store";
private string CashierName => "Jane D.";
private IEnumerable<Product> FilteredProducts => Products
.Where(p => SelectedCategory == null || p.CategoryId == SelectedCategory.Id)
.Where(p => string.IsNullOrEmpty(SearchQuery) ||
p.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
p.Sku.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
p.Barcode == SearchQuery);
private decimal Subtotal => CartItems.Sum(i => i.UnitPrice * i.Quantity);
private decimal DiscountTotal => CartItems.Sum(i => i.DiscountAmount);
private decimal TaxTotal => CartItems.Sum(i => i.TaxAmount);
private decimal GrandTotal => Subtotal - DiscountTotal + TaxTotal;
protected override async Task OnInitializedAsync()
{
Products = await ProductService.GetProductsAsync();
Categories = await CategoryService.GetCategoriesAsync();
QuickAccessButtons = await LayoutService.GetQuickAccessButtonsAsync();
// Check connectivity
IsOnline = await CheckConnectivity();
PendingSyncCount = await CartService.GetPendingSyncCountAsync();
}
private async Task AddToCart(Product product)
{
await CartService.AddItemAsync(product);
CartItems = await CartService.GetCartItemsAsync();
}
private async Task UpdateQuantity(CartItem item, int delta)
{
await CartService.UpdateQuantityAsync(item.Id, item.Quantity + delta);
CartItems = await CartService.GetCartItemsAsync();
}
private async Task RemoveItem(CartItem item)
{
await CartService.RemoveItemAsync(item.Id);
CartItems = await CartService.GetCartItemsAsync();
}
private async Task ProceedToPayment()
{
// Navigate to payment screen
// PaymentService.InitiatePayment(GrandTotal, CartItems);
}
// Additional methods for all functionality...
}
23.5 Week 15: Sale Workflow
Day 1-2: Product Browsing & Search
Objective: Implement efficient product lookup with category navigation.
Claude Command:
/dev-team create product service with caching and search
Implementation:
// RapOS.PosClient.Core/Services/ProductService.cs
namespace RapOS.PosClient.Core.Services;
public interface IProductService
{
Task<List<Product>> GetProductsAsync();
Task<List<Product>> SearchAsync(string query);
Task<Product?> GetByBarcodeAsync(string barcode);
Task<Product?> GetBySkuAsync(string sku);
Task RefreshCacheAsync();
}
public class ProductService : IProductService
{
private readonly ILocalDatabase _db;
private readonly IApiClient _api;
private readonly IConnectivityService _connectivity;
private List<Product>? _cachedProducts;
public ProductService(
ILocalDatabase db,
IApiClient api,
IConnectivityService connectivity)
{
_db = db;
_api = api;
_connectivity = connectivity;
}
public async Task<List<Product>> GetProductsAsync()
{
if (_cachedProducts != null)
return _cachedProducts;
_cachedProducts = await _db.QueryAsync<Product>(
"SELECT * FROM products WHERE is_active = 1 ORDER BY name");
return _cachedProducts;
}
public async Task<List<Product>> SearchAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
return await GetProductsAsync();
var searchPattern = $"%{query}%";
return await _db.QueryAsync<Product>(@"
SELECT * FROM products
WHERE is_active = 1
AND (name LIKE @pattern
OR sku LIKE @pattern
OR barcode = @exact)
ORDER BY
CASE WHEN barcode = @exact THEN 0
WHEN sku LIKE @pattern THEN 1
ELSE 2 END,
name
LIMIT 50",
new { pattern = searchPattern, exact = query });
}
public async Task<Product?> GetByBarcodeAsync(string barcode)
{
return await _db.QuerySingleAsync<Product>(
"SELECT * FROM products WHERE barcode = @barcode AND is_active = 1",
new { barcode });
}
public async Task<Product?> GetBySkuAsync(string sku)
{
return await _db.QuerySingleAsync<Product>(
"SELECT * FROM products WHERE sku = @sku AND is_active = 1",
new { sku });
}
public async Task RefreshCacheAsync()
{
if (!await _connectivity.IsOnlineAsync())
return;
var lastSync = await GetLastSyncTimeAsync();
var products = await _api.GetAsync<List<Product>>(
$"/api/products?modifiedAfter={lastSync:O}");
foreach (var product in products)
{
await _db.ExecuteAsync(@"
INSERT OR REPLACE INTO products
(id, sku, name, description, category_id, category_name,
base_price, tax_rate, image_url, barcode, is_active,
quantity_on_hand, synced_at)
VALUES
(@Id, @Sku, @Name, @Description, @CategoryId, @CategoryName,
@BasePrice, @TaxRate, @ImageUrl, @Barcode, @IsActive,
@QuantityOnHand, @now)",
new { product, now = DateTime.UtcNow });
}
_cachedProducts = null; // Invalidate cache
}
private async Task<DateTime> GetLastSyncTimeAsync()
{
var result = await _db.QuerySingleAsync<string>(
"SELECT MAX(synced_at) FROM products");
return DateTime.TryParse(result, out var dt) ? dt : DateTime.MinValue;
}
}
Day 3-4: Barcode Scanning
Objective: Integrate camera and hardware barcode scanning.
Claude Command:
/dev-team implement barcode scanning with ZXing.Net.MAUI
Implementation:
// RapOS.PosClient.Hardware/Scanners/BarcodeScannerService.cs
using ZXing.Net.Maui;
namespace RapOS.PosClient.Hardware.Scanners;
public interface IScannerService
{
event EventHandler<string>? BarcodeScanned;
Task StartCameraScanAsync();
Task StopCameraScanAsync();
bool IsHardwareScannerConnected { get; }
}
public class BarcodeScannerService : IScannerService
{
private readonly IProductService _productService;
private readonly ICartService _cartService;
public event EventHandler<string>? BarcodeScanned;
public bool IsHardwareScannerConnected { get; private set; }
public BarcodeScannerService(
IProductService productService,
ICartService cartService)
{
_productService = productService;
_cartService = cartService;
// Listen for USB/Bluetooth scanner input
InitializeHardwareScanner();
}
private void InitializeHardwareScanner()
{
// USB scanners typically emit keyboard events
// Monitor for rapid sequential character input ending with Enter
#if WINDOWS
// Windows: Hook into keyboard events
SetupWindowsKeyboardHook();
#elif ANDROID
// Android: Use USB Host API for dedicated scanners
SetupAndroidUsbScanner();
#endif
}
public async Task StartCameraScanAsync()
{
// Camera scanning is handled by ZXing component in UI
// This just signals to show the camera overlay
}
public async Task StopCameraScanAsync()
{
// Hide camera overlay
}
public async Task ProcessBarcodeAsync(string barcode)
{
BarcodeScanned?.Invoke(this, barcode);
var product = await _productService.GetByBarcodeAsync(barcode);
if (product != null)
{
await _cartService.AddItemAsync(product);
}
else
{
// Play error sound, show "Product not found" notification
await PlayErrorSoundAsync();
}
}
private async Task PlayErrorSoundAsync()
{
// Platform-specific audio playback
}
#if WINDOWS
private void SetupWindowsKeyboardHook()
{
// Implementation for Windows keyboard hook
// Detect scanner input pattern (rapid keys + Enter)
}
#endif
#if ANDROID
private void SetupAndroidUsbScanner()
{
// Implementation for Android USB Host API
}
#endif
}
Camera Scanner Component:
@* RapOS.PosClient.UI/Components/Shared/CameraScannerOverlay.razor *@
@using ZXing.Net.Maui.Controls
<div class="scanner-overlay @(IsVisible ? "visible" : "")">
<div class="scanner-container">
<CameraBarcodeReaderView
x:Name="barcodeReader"
IsDetecting="true"
BarcodesDetected="OnBarcodesDetected"
Options="@(new BarcodeReaderOptions
{
Formats = BarcodeFormat.Ean13 | BarcodeFormat.Code128 | BarcodeFormat.QrCode,
AutoRotate = true,
TryHarder = true
})" />
<div class="scanner-frame">
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
<button class="close-scanner" @onclick="Close">✕ Close</button>
</div>
</div>
@code {
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<string> OnScanned { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
private void OnBarcodesDetected(object? sender, BarcodeDetectionEventArgs e)
{
var barcode = e.Results.FirstOrDefault()?.Value;
if (!string.IsNullOrEmpty(barcode))
{
OnScanned.InvokeAsync(barcode);
}
}
private async Task Close()
{
await OnClose.InvokeAsync();
}
}
Day 5: Cart Operations
Objective: Implement full cart management with discounts.
Claude Command:
/dev-team create cart service with quantity controls and line discounts
Implementation:
// RapOS.PosClient.Core/Services/CartService.cs
namespace RapOS.PosClient.Core.Services;
public interface ICartService
{
Task<List<CartItem>> GetCartItemsAsync();
Task AddItemAsync(Product product, int quantity = 1);
Task UpdateQuantityAsync(Guid itemId, int newQuantity);
Task RemoveItemAsync(Guid itemId);
Task ApplyLineDiscountAsync(Guid itemId, decimal amount, DiscountType type);
Task ApplyCartDiscountAsync(decimal amount, DiscountType type);
Task ClearCartAsync();
Task<int> GetPendingSyncCountAsync();
CartTotals GetTotals();
}
public class CartService : ICartService
{
private readonly List<CartItem> _items = new();
private decimal _cartDiscount = 0;
private DiscountType _cartDiscountType = DiscountType.Amount;
public Task<List<CartItem>> GetCartItemsAsync()
{
return Task.FromResult(_items.ToList());
}
public Task AddItemAsync(Product product, int quantity = 1)
{
var existing = _items.FirstOrDefault(i => i.ProductId == product.Id);
if (existing != null)
{
existing.Quantity += quantity;
RecalculateItem(existing);
}
else
{
var item = new CartItem
{
Id = Guid.NewGuid(),
ProductId = product.Id,
Sku = product.Sku,
Name = product.Name,
Quantity = quantity,
UnitPrice = product.BasePrice,
TaxRate = product.TaxRate
};
RecalculateItem(item);
_items.Add(item);
}
return Task.CompletedTask;
}
public Task UpdateQuantityAsync(Guid itemId, int newQuantity)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item == null) return Task.CompletedTask;
if (newQuantity <= 0)
{
_items.Remove(item);
}
else
{
item.Quantity = newQuantity;
RecalculateItem(item);
}
return Task.CompletedTask;
}
public Task RemoveItemAsync(Guid itemId)
{
_items.RemoveAll(i => i.Id == itemId);
return Task.CompletedTask;
}
public Task ApplyLineDiscountAsync(Guid itemId, decimal amount, DiscountType type)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
if (item == null) return Task.CompletedTask;
item.DiscountType = type;
item.DiscountValue = amount;
RecalculateItem(item);
return Task.CompletedTask;
}
public Task ApplyCartDiscountAsync(decimal amount, DiscountType type)
{
_cartDiscount = amount;
_cartDiscountType = type;
return Task.CompletedTask;
}
public Task ClearCartAsync()
{
_items.Clear();
_cartDiscount = 0;
return Task.CompletedTask;
}
public CartTotals GetTotals()
{
var subtotal = _items.Sum(i => i.UnitPrice * i.Quantity);
var lineDiscounts = _items.Sum(i => i.DiscountAmount);
var cartDiscountAmount = _cartDiscountType == DiscountType.Percentage
? subtotal * (_cartDiscount / 100)
: _cartDiscount;
var taxableAmount = subtotal - lineDiscounts - cartDiscountAmount;
var taxTotal = _items.Sum(i =>
((i.UnitPrice * i.Quantity - i.DiscountAmount) / subtotal) * taxableAmount * i.TaxRate);
return new CartTotals
{
Subtotal = subtotal,
LineDiscounts = lineDiscounts,
CartDiscount = cartDiscountAmount,
TotalDiscount = lineDiscounts + cartDiscountAmount,
TaxTotal = taxTotal,
GrandTotal = taxableAmount + taxTotal
};
}
private void RecalculateItem(CartItem item)
{
var lineSubtotal = item.UnitPrice * item.Quantity;
item.DiscountAmount = item.DiscountType == DiscountType.Percentage
? lineSubtotal * (item.DiscountValue / 100)
: item.DiscountValue;
var taxableAmount = lineSubtotal - item.DiscountAmount;
item.TaxAmount = taxableAmount * item.TaxRate;
item.LineTotal = taxableAmount + item.TaxAmount;
}
public async Task<int> GetPendingSyncCountAsync()
{
// Query sync_queue table for pending count
return 0;
}
}
public enum DiscountType
{
Amount,
Percentage
}
public class CartTotals
{
public decimal Subtotal { get; set; }
public decimal LineDiscounts { get; set; }
public decimal CartDiscount { get; set; }
public decimal TotalDiscount { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
}
23.6 Week 16: Payments & Transactions
Day 1-2: Cash & Card Payments
Objective: Implement payment handling with multiple methods.
Claude Command:
/dev-team create payment service supporting cash, card, and split payments
Implementation:
// RapOS.PosClient.Core/Services/PaymentService.cs
namespace RapOS.PosClient.Core.Services;
public interface IPaymentService
{
Task<PaymentResult> ProcessCashPaymentAsync(decimal amount, decimal tendered);
Task<PaymentResult> ProcessCardPaymentAsync(decimal amount);
Task<SaleCompletionResult> CompleteSaleAsync(List<Payment> payments);
Task<bool> CanProcessOfflineAsync(PaymentMethod method);
}
public class PaymentService : IPaymentService
{
private readonly ILocalDatabase _db;
private readonly ISyncService _sync;
private readonly IStripeTerminalService _stripeTerminal;
private readonly IPrinterService _printer;
private readonly ICashDrawerService _cashDrawer;
public PaymentService(
ILocalDatabase db,
ISyncService sync,
IStripeTerminalService stripeTerminal,
IPrinterService printer,
ICashDrawerService cashDrawer)
{
_db = db;
_sync = sync;
_stripeTerminal = stripeTerminal;
_printer = printer;
_cashDrawer = cashDrawer;
}
public async Task<PaymentResult> ProcessCashPaymentAsync(decimal amount, decimal tendered)
{
if (tendered < amount)
{
return PaymentResult.Failed("Insufficient payment amount");
}
var change = tendered - amount;
// Open cash drawer
await _cashDrawer.OpenAsync();
return PaymentResult.Success(new Payment
{
Id = Guid.NewGuid(),
Method = PaymentMethod.Cash,
Amount = amount,
Tendered = tendered,
Change = change,
CreatedAt = DateTime.UtcNow
});
}
public async Task<PaymentResult> ProcessCardPaymentAsync(decimal amount)
{
try
{
// Check if Stripe Terminal is connected
if (!_stripeTerminal.IsConnected)
{
return PaymentResult.Failed("Payment terminal not connected");
}
// Create payment intent
var intent = await _stripeTerminal.CreatePaymentIntentAsync(amount);
// Collect payment
var result = await _stripeTerminal.CollectPaymentAsync(intent);
if (!result.Success)
{
return PaymentResult.Failed(result.ErrorMessage ?? "Payment declined");
}
return PaymentResult.Success(new Payment
{
Id = Guid.NewGuid(),
Method = PaymentMethod.Card,
Amount = amount,
Reference = result.PaymentIntentId,
CardLastFour = result.CardLastFour,
CardBrand = result.CardBrand,
CreatedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
return PaymentResult.Failed($"Payment error: {ex.Message}");
}
}
public async Task<SaleCompletionResult> CompleteSaleAsync(List<Payment> payments)
{
var sale = await CreateSaleRecordAsync(payments);
// Save to local database
await SaveSaleLocallyAsync(sale);
// Queue for sync
await _sync.QueueForSyncAsync("sale", sale.Id, SyncOperation.Create, sale);
// Print receipt
await _printer.PrintReceiptAsync(sale);
// If cash payment, drawer is already open
// Clear cart happens in calling code
return new SaleCompletionResult
{
Success = true,
SaleId = sale.Id,
ReceiptNumber = sale.ReceiptNumber
};
}
public Task<bool> CanProcessOfflineAsync(PaymentMethod method)
{
// Cash is always available offline
// Cards require terminal which needs connectivity for most operations
return Task.FromResult(method == PaymentMethod.Cash);
}
private async Task<Sale> CreateSaleRecordAsync(List<Payment> payments)
{
// Implementation to create sale from current cart state
throw new NotImplementedException();
}
private async Task SaveSaleLocallyAsync(Sale sale)
{
await _db.ExecuteAsync(@"
INSERT INTO sales (id, status, customer_id, cashier_id, subtotal,
tax_total, discount_total, grand_total, created_at)
VALUES (@Id, 'completed', @CustomerId, @CashierId, @Subtotal,
@TaxTotal, @DiscountTotal, @GrandTotal, @CreatedAt)",
sale);
foreach (var item in sale.Items)
{
await _db.ExecuteAsync(@"
INSERT INTO sale_items (id, sale_id, product_id, sku, name,
quantity, unit_price, discount_amount,
tax_amount, line_total)
VALUES (@Id, @SaleId, @ProductId, @Sku, @Name, @Quantity,
@UnitPrice, @DiscountAmount, @TaxAmount, @LineTotal)",
item);
}
foreach (var payment in sale.Payments)
{
await _db.ExecuteAsync(@"
INSERT INTO payments (id, sale_id, method, amount, reference,
card_last_four, card_brand, created_at)
VALUES (@Id, @SaleId, @Method, @Amount, @Reference,
@CardLastFour, @CardBrand, @CreatedAt)",
payment);
}
}
}
Day 3-4: Stripe Terminal Integration
Objective: Integrate Stripe Terminal for card payments.
Claude Command:
/dev-team implement Stripe Terminal SDK integration for MAUI
Implementation:
// RapOS.PosClient.Hardware/Payments/StripeTerminalService.cs
namespace RapOS.PosClient.Hardware.Payments;
public interface IStripeTerminalService
{
bool IsConnected { get; }
Task InitializeAsync(string locationId);
Task<TerminalReader?> DiscoverAndConnectAsync();
Task<PaymentIntentResult> CreatePaymentIntentAsync(decimal amount);
Task<CollectPaymentResult> CollectPaymentAsync(string paymentIntentId);
Task DisconnectAsync();
}
public class StripeTerminalService : IStripeTerminalService
{
private readonly IConfiguration _config;
private readonly IApiClient _api;
private bool _initialized;
private TerminalReader? _connectedReader;
public bool IsConnected => _connectedReader != null;
public StripeTerminalService(IConfiguration config, IApiClient api)
{
_config = config;
_api = api;
}
public async Task InitializeAsync(string locationId)
{
if (_initialized) return;
// Get connection token from backend
var tokenResponse = await _api.PostAsync<ConnectionTokenResponse>(
"/api/terminals/stripe/connection-token",
new { locationId });
// Initialize Stripe Terminal SDK
// Note: Actual implementation depends on platform
#if ANDROID
await InitializeAndroidAsync(tokenResponse.Secret);
#elif WINDOWS
await InitializeWindowsAsync(tokenResponse.Secret);
#endif
_initialized = true;
}
public async Task<TerminalReader?> DiscoverAndConnectAsync()
{
// Discover available readers
var readers = await DiscoverReadersAsync();
if (!readers.Any())
{
return null;
}
// Auto-connect to first reader (or show picker)
var reader = readers.First();
await ConnectToReaderAsync(reader);
_connectedReader = reader;
return reader;
}
public async Task<PaymentIntentResult> CreatePaymentIntentAsync(decimal amount)
{
// Create payment intent on backend
var response = await _api.PostAsync<PaymentIntentResult>(
"/api/payments/create-intent",
new
{
amount = (long)(amount * 100), // Convert to cents
currency = "usd"
});
return response;
}
public async Task<CollectPaymentResult> CollectPaymentAsync(string paymentIntentId)
{
// This triggers the reader to collect card
#if ANDROID
return await CollectPaymentAndroidAsync(paymentIntentId);
#elif WINDOWS
return await CollectPaymentWindowsAsync(paymentIntentId);
#else
throw new PlatformNotSupportedException();
#endif
}
public async Task DisconnectAsync()
{
if (_connectedReader != null)
{
// Disconnect from reader
_connectedReader = null;
}
}
// Platform-specific implementations...
}
public class TerminalReader
{
public string Id { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public TerminalReaderType Type { get; set; }
public bool IsOnline { get; set; }
}
public enum TerminalReaderType
{
Bluetooth,
Internet,
USB
}
public class CollectPaymentResult
{
public bool Success { get; set; }
public string? PaymentIntentId { get; set; }
public string? CardLastFour { get; set; }
public string? CardBrand { get; set; }
public string? ErrorMessage { get; set; }
}
Day 5: Receipt Generation
Objective: Generate and print receipts.
Claude Command:
/dev-team create receipt generator with ESC/POS printer support
Implementation:
// RapOS.PosClient.Hardware/Printers/ReceiptGenerator.cs
namespace RapOS.PosClient.Hardware.Printers;
public interface IReceiptGenerator
{
byte[] GenerateReceipt(Sale sale);
byte[] GenerateReprint(Sale sale);
byte[] GenerateVoid(Sale sale, string reason);
}
public class EscPosReceiptGenerator : IReceiptGenerator
{
private readonly ITerminalContext _terminal;
private readonly ISettingsService _settings;
public EscPosReceiptGenerator(
ITerminalContext terminal,
ISettingsService settings)
{
_terminal = terminal;
_settings = settings;
}
public byte[] GenerateReceipt(Sale sale)
{
using var ms = new MemoryStream();
using var writer = new EscPosWriter(ms);
// Initialize printer
writer.Initialize();
// Header - Store Info
writer.SetAlignment(Alignment.Center);
writer.SetBold(true);
writer.WriteLine(_settings.StoreName);
writer.SetBold(false);
writer.WriteLine(_settings.StoreAddress);
writer.WriteLine(_settings.StorePhone);
writer.LineFeed();
// Transaction Info
writer.SetAlignment(Alignment.Left);
writer.WriteLine($"Receipt: {sale.ReceiptNumber}");
writer.WriteLine($"Date: {sale.CreatedAt:MM/dd/yyyy HH:mm}");
writer.WriteLine($"Cashier: {sale.CashierName}");
if (sale.Customer != null)
{
writer.WriteLine($"Customer: {sale.Customer.FullName}");
}
writer.PrintDivider();
// Line Items
foreach (var item in sale.Items)
{
// Product name
writer.WriteLine(item.Name);
// Quantity x Price = Total
var priceStr = item.UnitPrice.ToString("F2");
var qtyStr = $" {item.Quantity} x {priceStr}";
var totalStr = item.LineTotal.ToString("F2");
writer.WriteColumns(qtyStr, totalStr);
if (item.DiscountAmount > 0)
{
writer.WriteColumns(" Discount:", $"-{item.DiscountAmount:F2}");
}
}
writer.PrintDivider();
// Totals
writer.WriteColumns("Subtotal:", sale.Subtotal.ToString("F2"));
if (sale.DiscountTotal > 0)
{
writer.WriteColumns("Discount:", $"-{sale.DiscountTotal:F2}");
}
writer.WriteColumns("Tax:", sale.TaxTotal.ToString("F2"));
writer.SetBold(true);
writer.WriteColumns("TOTAL:", sale.GrandTotal.ToString("F2"));
writer.SetBold(false);
writer.LineFeed();
// Payments
foreach (var payment in sale.Payments)
{
var methodName = payment.Method switch
{
PaymentMethod.Cash => "Cash",
PaymentMethod.Card => $"Card ({payment.CardBrand} ...{payment.CardLastFour})",
_ => payment.Method.ToString()
};
writer.WriteColumns(methodName, payment.Amount.ToString("F2"));
if (payment.Method == PaymentMethod.Cash && payment.Change > 0)
{
writer.WriteColumns("Change:", payment.Change.ToString("F2"));
}
}
writer.LineFeed();
// Footer
writer.SetAlignment(Alignment.Center);
writer.WriteLine(_settings.ReceiptFooterMessage ?? "Thank you for your purchase!");
if (sale.Customer?.LoyaltyPoints > 0)
{
writer.LineFeed();
writer.WriteLine($"Loyalty Points Earned: {sale.LoyaltyPointsEarned}");
writer.WriteLine($"Total Points: {sale.Customer.LoyaltyPoints + sale.LoyaltyPointsEarned}");
}
// Barcode for receipt lookup
writer.LineFeed();
writer.PrintBarcode(sale.ReceiptNumber, BarcodeType.Code128);
// Cut paper
writer.CutPaper();
return ms.ToArray();
}
public byte[] GenerateReprint(Sale sale)
{
// Same as receipt but with "REPRINT" header
using var ms = new MemoryStream();
using var writer = new EscPosWriter(ms);
writer.Initialize();
writer.SetAlignment(Alignment.Center);
writer.SetBold(true);
writer.WriteLine("*** REPRINT ***");
writer.SetBold(false);
writer.LineFeed();
// Rest is same as GenerateReceipt...
// Could refactor to share code
return ms.ToArray();
}
public byte[] GenerateVoid(Sale sale, string reason)
{
// Void receipt
using var ms = new MemoryStream();
using var writer = new EscPosWriter(ms);
writer.Initialize();
writer.SetAlignment(Alignment.Center);
writer.SetBold(true);
writer.WriteLine("*** VOID ***");
writer.SetBold(false);
writer.WriteLine($"Original Receipt: {sale.ReceiptNumber}");
writer.WriteLine($"Void Reason: {reason}");
writer.LineFeed();
writer.CutPaper();
return ms.ToArray();
}
}
public class EscPosWriter : IDisposable
{
private readonly Stream _stream;
// ESC/POS command constants
private static readonly byte[] CMD_INIT = { 0x1B, 0x40 };
private static readonly byte[] CMD_ALIGN_LEFT = { 0x1B, 0x61, 0x00 };
private static readonly byte[] CMD_ALIGN_CENTER = { 0x1B, 0x61, 0x01 };
private static readonly byte[] CMD_ALIGN_RIGHT = { 0x1B, 0x61, 0x02 };
private static readonly byte[] CMD_BOLD_ON = { 0x1B, 0x45, 0x01 };
private static readonly byte[] CMD_BOLD_OFF = { 0x1B, 0x45, 0x00 };
private static readonly byte[] CMD_CUT = { 0x1D, 0x56, 0x41, 0x00 };
private const int LINE_WIDTH = 42; // Standard 80mm receipt width
public EscPosWriter(Stream stream)
{
_stream = stream;
}
public void Initialize() => _stream.Write(CMD_INIT);
public void SetAlignment(Alignment align)
{
_stream.Write(align switch
{
Alignment.Left => CMD_ALIGN_LEFT,
Alignment.Center => CMD_ALIGN_CENTER,
Alignment.Right => CMD_ALIGN_RIGHT,
_ => CMD_ALIGN_LEFT
});
}
public void SetBold(bool bold) =>
_stream.Write(bold ? CMD_BOLD_ON : CMD_BOLD_OFF);
public void WriteLine(string text)
{
var bytes = Encoding.UTF8.GetBytes(text + "\n");
_stream.Write(bytes);
}
public void WriteColumns(string left, string right)
{
var padding = LINE_WIDTH - left.Length - right.Length;
var line = left + new string(' ', Math.Max(1, padding)) + right;
WriteLine(line);
}
public void LineFeed() => _stream.WriteByte(0x0A);
public void PrintDivider() =>
WriteLine(new string('-', LINE_WIDTH));
public void PrintBarcode(string data, BarcodeType type)
{
// ESC/POS barcode commands
_stream.Write(new byte[] { 0x1D, 0x68, 50 }); // Height
_stream.Write(new byte[] { 0x1D, 0x77, 2 }); // Width
_stream.Write(new byte[] { 0x1D, 0x6B, (byte)type });
var bytes = Encoding.ASCII.GetBytes(data);
_stream.WriteByte((byte)bytes.Length);
_stream.Write(bytes);
}
public void CutPaper() => _stream.Write(CMD_CUT);
public void Dispose() { }
}
public enum Alignment { Left, Center, Right }
public enum BarcodeType { Code128 = 73 }
23.7 Week 17: Offline & Sync
Day 1-2: Local Transaction Queue
Objective: Queue transactions for sync when offline.
Claude Command:
/dev-team create sync queue service for offline transaction handling
Implementation:
// RapOS.PosClient.Core/Services/SyncService.cs
namespace RapOS.PosClient.Core.Services;
public interface ISyncService
{
Task QueueForSyncAsync<T>(string entityType, Guid entityId, SyncOperation operation, T payload);
Task ProcessQueueAsync();
Task<int> GetPendingCountAsync();
event EventHandler<SyncProgressEventArgs>? SyncProgress;
}
public class SyncService : ISyncService
{
private readonly ILocalDatabase _db;
private readonly IApiClient _api;
private readonly IConnectivityService _connectivity;
private readonly ILogger<SyncService> _logger;
private bool _isSyncing;
public event EventHandler<SyncProgressEventArgs>? SyncProgress;
public SyncService(
ILocalDatabase db,
IApiClient api,
IConnectivityService connectivity,
ILogger<SyncService> logger)
{
_db = db;
_api = api;
_connectivity = connectivity;
_logger = logger;
}
public async Task QueueForSyncAsync<T>(
string entityType,
Guid entityId,
SyncOperation operation,
T payload)
{
var priority = GetPriority(entityType);
var json = JsonSerializer.Serialize(payload);
await _db.ExecuteAsync(@"
INSERT OR REPLACE INTO sync_queue
(entity_type, entity_id, operation, payload, priority, created_at)
VALUES (@entityType, @entityId, @operation, @payload, @priority, @now)",
new
{
entityType,
entityId = entityId.ToString(),
operation = operation.ToString(),
payload = json,
priority,
now = DateTime.UtcNow.ToString("O")
});
// Try immediate sync if online
if (await _connectivity.IsOnlineAsync() && !_isSyncing)
{
_ = ProcessQueueAsync(); // Fire and forget
}
}
public async Task ProcessQueueAsync()
{
if (_isSyncing || !await _connectivity.IsOnlineAsync())
return;
_isSyncing = true;
try
{
var pending = await _db.QueryAsync<SyncQueueItem>(@"
SELECT * FROM sync_queue
WHERE attempts < 5
ORDER BY priority DESC, created_at ASC
LIMIT 50");
var total = pending.Count;
var processed = 0;
foreach (var item in pending)
{
try
{
await SyncItemAsync(item);
// Remove from queue on success
await _db.ExecuteAsync(
"DELETE FROM sync_queue WHERE id = @id",
new { item.Id });
processed++;
RaiseSyncProgress(processed, total, null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to sync {EntityType} {EntityId}",
item.EntityType, item.EntityId);
// Update attempts and error
await _db.ExecuteAsync(@"
UPDATE sync_queue
SET attempts = attempts + 1,
last_attempt_at = @now,
error = @error
WHERE id = @id",
new
{
item.Id,
now = DateTime.UtcNow.ToString("O"),
error = ex.Message
});
RaiseSyncProgress(processed, total, ex.Message);
}
}
}
finally
{
_isSyncing = false;
}
}
private async Task SyncItemAsync(SyncQueueItem item)
{
var endpoint = GetEndpointForEntity(item.EntityType);
switch (item.Operation)
{
case "Create":
await _api.PostAsync(endpoint, item.Payload);
break;
case "Update":
await _api.PutAsync($"{endpoint}/{item.EntityId}", item.Payload);
break;
case "Delete":
await _api.DeleteAsync($"{endpoint}/{item.EntityId}");
break;
}
// Update local record with synced timestamp
await MarkAsSyncedAsync(item.EntityType, item.EntityId);
}
private int GetPriority(string entityType)
{
// Sales have highest priority (revenue!)
return entityType switch
{
"sale" => 100,
"payment" => 90,
"customer" => 50,
"inventory_adjustment" => 40,
_ => 10
};
}
private string GetEndpointForEntity(string entityType)
{
return entityType switch
{
"sale" => "/api/sales",
"payment" => "/api/payments",
"customer" => "/api/customers",
_ => $"/api/{entityType}"
};
}
private async Task MarkAsSyncedAsync(string entityType, string entityId)
{
var table = entityType switch
{
"sale" => "sales",
_ => entityType
};
await _db.ExecuteAsync(
$"UPDATE {table} SET synced_at = @now WHERE id = @id",
new { id = entityId, now = DateTime.UtcNow.ToString("O") });
}
public async Task<int> GetPendingCountAsync()
{
var result = await _db.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM sync_queue WHERE attempts < 5");
return result;
}
private void RaiseSyncProgress(int current, int total, string? error)
{
SyncProgress?.Invoke(this, new SyncProgressEventArgs
{
Current = current,
Total = total,
Error = error
});
}
}
public enum SyncOperation
{
Create,
Update,
Delete
}
public class SyncQueueItem
{
public int Id { get; set; }
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Operation { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public int Priority { get; set; }
public int Attempts { get; set; }
public string? LastAttemptAt { get; set; }
public string? Error { get; set; }
public string CreatedAt { get; set; } = string.Empty;
}
public class SyncProgressEventArgs : EventArgs
{
public int Current { get; set; }
public int Total { get; set; }
public string? Error { get; set; }
}
Day 3-4: Product & Customer Cache
Objective: Sync and cache products/customers for offline access.
Claude Command:
/dev-team create background sync service for product and customer data
Implementation:
// RapOS.PosClient.Core/Services/BackgroundSyncService.cs
namespace RapOS.PosClient.Core.Services;
public interface IBackgroundSyncService
{
Task StartAsync();
Task StopAsync();
Task ForceSyncAsync();
}
public class BackgroundSyncService : IBackgroundSyncService
{
private readonly ILocalDatabase _db;
private readonly IApiClient _api;
private readonly IConnectivityService _connectivity;
private readonly IProductService _products;
private readonly ICategoryService _categories;
private readonly ICustomerService _customers;
private readonly ILogger<BackgroundSyncService> _logger;
private CancellationTokenSource? _cts;
private Timer? _syncTimer;
private const int SYNC_INTERVAL_MINUTES = 5;
public BackgroundSyncService(
ILocalDatabase db,
IApiClient api,
IConnectivityService connectivity,
IProductService products,
ICategoryService categories,
ICustomerService customers,
ILogger<BackgroundSyncService> logger)
{
_db = db;
_api = api;
_connectivity = connectivity;
_products = products;
_categories = categories;
_customers = customers;
_logger = logger;
}
public Task StartAsync()
{
_cts = new CancellationTokenSource();
// Immediate sync on start
_ = RunSyncAsync(_cts.Token);
// Periodic sync
_syncTimer = new Timer(
async _ => await RunSyncAsync(_cts.Token),
null,
TimeSpan.FromMinutes(SYNC_INTERVAL_MINUTES),
TimeSpan.FromMinutes(SYNC_INTERVAL_MINUTES));
return Task.CompletedTask;
}
public async Task StopAsync()
{
_cts?.Cancel();
if (_syncTimer != null)
{
await _syncTimer.DisposeAsync();
}
}
public async Task ForceSyncAsync()
{
await RunSyncAsync(CancellationToken.None);
}
private async Task RunSyncAsync(CancellationToken ct)
{
if (!await _connectivity.IsOnlineAsync())
{
_logger.LogInformation("Skipping sync - offline");
return;
}
_logger.LogInformation("Starting background sync...");
try
{
// Sync in parallel
await Task.WhenAll(
SyncCategoriesAsync(ct),
SyncProductsAsync(ct),
SyncCustomersAsync(ct));
_logger.LogInformation("Background sync completed");
}
catch (OperationCanceledException)
{
_logger.LogInformation("Background sync cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Background sync failed");
}
}
private async Task SyncCategoriesAsync(CancellationToken ct)
{
var lastSync = await GetLastSyncAsync("categories");
var categories = await _api.GetAsync<List<Category>>(
$"/api/categories?modifiedAfter={lastSync:O}", ct);
foreach (var category in categories)
{
ct.ThrowIfCancellationRequested();
await _db.ExecuteAsync(@"
INSERT OR REPLACE INTO categories
(id, name, parent_id, display_order, color, icon, synced_at)
VALUES (@Id, @Name, @ParentId, @DisplayOrder, @Color, @Icon, @now)",
new { category, now = DateTime.UtcNow.ToString("O") });
}
await UpdateLastSyncAsync("categories");
}
private async Task SyncProductsAsync(CancellationToken ct)
{
// Products synced by ProductService.RefreshCacheAsync()
await _products.RefreshCacheAsync();
}
private async Task SyncCustomersAsync(CancellationToken ct)
{
var lastSync = await GetLastSyncAsync("customers");
// Only sync active customers from this location
var customers = await _api.GetAsync<List<Customer>>(
$"/api/customers?modifiedAfter={lastSync:O}&limit=1000", ct);
foreach (var customer in customers)
{
ct.ThrowIfCancellationRequested();
await _db.ExecuteAsync(@"
INSERT OR REPLACE INTO customers
(id, first_name, last_name, email, phone,
loyalty_points, loyalty_tier, synced_at)
VALUES (@Id, @FirstName, @LastName, @Email, @Phone,
@LoyaltyPoints, @LoyaltyTier, @now)",
new { customer, now = DateTime.UtcNow.ToString("O") });
}
await UpdateLastSyncAsync("customers");
}
private async Task<DateTime> GetLastSyncAsync(string entityType)
{
var result = await _db.QuerySingleAsync<string>(
"SELECT MAX(synced_at) FROM @table",
new { table = entityType });
return DateTime.TryParse(result, out var dt) ? dt : DateTime.MinValue;
}
private async Task UpdateLastSyncAsync(string entityType)
{
await _db.ExecuteAsync(@"
INSERT OR REPLACE INTO sync_metadata (entity_type, last_sync)
VALUES (@entityType, @now)",
new { entityType, now = DateTime.UtcNow.ToString("O") });
}
}
Day 5: Conflict Resolution
Objective: Handle sync conflicts with server-wins strategy.
Claude Command:
/dev-team implement conflict resolution for offline sync
Implementation:
// RapOS.PosClient.Core/Services/ConflictResolver.cs
namespace RapOS.PosClient.Core.Services;
public interface IConflictResolver
{
Task<ConflictResolution> ResolveAsync(SyncConflict conflict);
}
public class ConflictResolver : IConflictResolver
{
private readonly ILogger<ConflictResolver> _logger;
public ConflictResolver(ILogger<ConflictResolver> logger)
{
_logger = logger;
}
public async Task<ConflictResolution> ResolveAsync(SyncConflict conflict)
{
_logger.LogWarning(
"Conflict detected for {EntityType} {EntityId}: Local={LocalVersion}, Server={ServerVersion}",
conflict.EntityType,
conflict.EntityId,
conflict.LocalVersion,
conflict.ServerVersion);
// Resolution strategy depends on entity type
return conflict.EntityType switch
{
// Sales: Local wins (transaction already happened)
"sale" => ConflictResolution.KeepLocal,
// Inventory: Server wins (authoritative count)
"product" => ConflictResolution.UseServer,
// Customer: Merge (combine changes if possible)
"customer" => await MergeCustomerAsync(conflict),
// Default: Server wins
_ => ConflictResolution.UseServer
};
}
private async Task<ConflictResolution> MergeCustomerAsync(SyncConflict conflict)
{
// For customers, we can merge if changes don't overlap
var local = JsonSerializer.Deserialize<Customer>(conflict.LocalData);
var server = JsonSerializer.Deserialize<Customer>(conflict.ServerData);
if (local == null || server == null)
return ConflictResolution.UseServer;
// If only loyalty points differ, add them (they're additive)
if (OnlyLoyaltyPointsDiffer(local, server))
{
// Keep server points (they include all synced transactions)
return ConflictResolution.UseServer;
}
// Otherwise, server wins
return ConflictResolution.UseServer;
}
private bool OnlyLoyaltyPointsDiffer(Customer local, Customer server)
{
return local.FirstName == server.FirstName &&
local.LastName == server.LastName &&
local.Email == server.Email &&
local.Phone == server.Phone &&
local.LoyaltyPoints != server.LoyaltyPoints;
}
}
public class SyncConflict
{
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public int LocalVersion { get; set; }
public int ServerVersion { get; set; }
public string LocalData { get; set; } = string.Empty;
public string ServerData { get; set; } = string.Empty;
}
public enum ConflictResolution
{
KeepLocal,
UseServer,
Merge
}
23.8 Week 18: Hardware & Configuration
Day 1-2: Receipt Printer Integration
Objective: Connect to ESC/POS printers via USB, Bluetooth, and network.
Claude Command:
/dev-team implement multi-platform printer service for ESC/POS printers
Implementation:
// RapOS.PosClient.Hardware/Printers/PrinterService.cs
namespace RapOS.PosClient.Hardware.Printers;
public interface IPrinterService
{
Task<List<PrinterInfo>> DiscoverPrintersAsync();
Task<bool> ConnectAsync(PrinterInfo printer);
Task PrintAsync(byte[] data);
Task PrintReceiptAsync(Sale sale);
Task<bool> TestPrintAsync();
bool IsConnected { get; }
PrinterInfo? ConnectedPrinter { get; }
}
public class EscPosPrinterService : IPrinterService
{
private readonly IReceiptGenerator _receiptGenerator;
private readonly ISettingsService _settings;
private readonly ILogger<EscPosPrinterService> _logger;
private IPrinterConnection? _connection;
public bool IsConnected => _connection?.IsConnected ?? false;
public PrinterInfo? ConnectedPrinter { get; private set; }
public EscPosPrinterService(
IReceiptGenerator receiptGenerator,
ISettingsService settings,
ILogger<EscPosPrinterService> logger)
{
_receiptGenerator = receiptGenerator;
_settings = settings;
_logger = logger;
}
public async Task<List<PrinterInfo>> DiscoverPrintersAsync()
{
var printers = new List<PrinterInfo>();
#if WINDOWS
// Discover Windows printers
printers.AddRange(await DiscoverWindowsPrintersAsync());
#elif ANDROID
// Discover Bluetooth and USB printers
printers.AddRange(await DiscoverBluetoothPrintersAsync());
printers.AddRange(await DiscoverUsbPrintersAsync());
#endif
// Discover network printers (common across platforms)
printers.AddRange(await DiscoverNetworkPrintersAsync());
return printers;
}
public async Task<bool> ConnectAsync(PrinterInfo printer)
{
try
{
_connection = printer.ConnectionType switch
{
PrinterConnectionType.USB => new UsbPrinterConnection(printer),
PrinterConnectionType.Bluetooth => new BluetoothPrinterConnection(printer),
PrinterConnectionType.Network => new NetworkPrinterConnection(printer),
_ => throw new NotSupportedException($"Connection type {printer.ConnectionType} not supported")
};
await _connection.OpenAsync();
ConnectedPrinter = printer;
_logger.LogInformation("Connected to printer: {PrinterName}", printer.Name);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect to printer: {PrinterName}", printer.Name);
return false;
}
}
public async Task PrintAsync(byte[] data)
{
if (_connection == null || !_connection.IsConnected)
{
throw new InvalidOperationException("Printer not connected");
}
await _connection.WriteAsync(data);
}
public async Task PrintReceiptAsync(Sale sale)
{
var receiptData = _receiptGenerator.GenerateReceipt(sale);
await PrintAsync(receiptData);
}
public async Task<bool> TestPrintAsync()
{
try
{
using var ms = new MemoryStream();
using var writer = new EscPosWriter(ms);
writer.Initialize();
writer.SetAlignment(Alignment.Center);
writer.WriteLine("*** TEST PRINT ***");
writer.LineFeed();
writer.WriteLine($"Printer: {ConnectedPrinter?.Name}");
writer.WriteLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
writer.LineFeed();
writer.WriteLine("If you can read this,");
writer.WriteLine("the printer is working!");
writer.LineFeed();
writer.CutPaper();
await PrintAsync(ms.ToArray());
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Test print failed");
return false;
}
}
private async Task<List<PrinterInfo>> DiscoverNetworkPrintersAsync()
{
// Common receipt printer ports: 9100 (raw), 515 (LPR)
var printers = new List<PrinterInfo>();
// Check saved network printers
var savedPrinters = await _settings.GetNetworkPrintersAsync();
foreach (var saved in savedPrinters)
{
if (await IsReachableAsync(saved.IpAddress, saved.Port))
{
printers.Add(saved);
}
}
return printers;
}
private async Task<bool> IsReachableAsync(string host, int port)
{
try
{
using var client = new TcpClient();
var connectTask = client.ConnectAsync(host, port);
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(2));
if (await Task.WhenAny(connectTask, timeoutTask) == connectTask)
{
return client.Connected;
}
return false;
}
catch
{
return false;
}
}
#if WINDOWS
private async Task<List<PrinterInfo>> DiscoverWindowsPrintersAsync()
{
// Use Windows printing API
return new List<PrinterInfo>();
}
#endif
#if ANDROID
private async Task<List<PrinterInfo>> DiscoverBluetoothPrintersAsync()
{
// Use Android Bluetooth API
return new List<PrinterInfo>();
}
private async Task<List<PrinterInfo>> DiscoverUsbPrintersAsync()
{
// Use Android USB Host API
return new List<PrinterInfo>();
}
#endif
}
public class PrinterInfo
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public PrinterConnectionType ConnectionType { get; set; }
public string Address { get; set; } = string.Empty;
public string? IpAddress { get; set; }
public int Port { get; set; } = 9100;
}
public enum PrinterConnectionType
{
USB,
Bluetooth,
Network
}
Day 3-4: Drag-and-Drop Layout Builder
Objective: Implement Retail Pro-style configurable UI layouts.
Claude Command:
/dev-team create drag-and-drop layout designer for POS screen customization
Layout Designer Component:
@* RapOS.PosClient.UI/Components/Layout/LayoutDesigner.razor *@
@inject ILayoutService LayoutService
@inject IJSRuntime JS
<div class="layout-designer">
<aside class="widget-palette">
<h3>Available Widgets</h3>
@foreach (var widget in AvailableWidgets)
{
<div class="widget-item"
draggable="true"
@ondragstart="() => StartDrag(widget)">
<span class="widget-icon">@widget.Icon</span>
<span class="widget-name">@widget.Name</span>
</div>
}
</aside>
<main class="layout-canvas">
<div class="canvas-header">
<h3>Layout Canvas</h3>
<div class="canvas-actions">
<button @onclick="ResetLayout">Reset</button>
<button @onclick="SaveLayout" class="primary">Save Layout</button>
</div>
</div>
<div class="canvas-grid"
@ondragover="HandleDragOver"
@ondrop="HandleDrop">
@foreach (var cell in LayoutCells)
{
<div class="grid-cell @(cell.Widget != null ? "occupied" : "")"
data-row="@cell.Row"
data-col="@cell.Column"
style="grid-row: @(cell.Row + 1); grid-column: @(cell.Column + 1) / span @cell.ColSpan;">
@if (cell.Widget != null)
{
<div class="placed-widget"
style="background-color: @cell.Widget.Color">
<span class="widget-icon">@cell.Widget.Icon</span>
<span class="widget-name">@cell.Widget.Name</span>
<button class="remove-widget" @onclick="() => RemoveWidget(cell)">
✕
</button>
<div class="resize-handle" @onmousedown="() => StartResize(cell)"></div>
</div>
}
else
{
<span class="drop-hint">Drop widget here</span>
}
</div>
}
</div>
</main>
<aside class="layout-preview">
<h3>Preview</h3>
<div class="preview-container">
@* Miniature preview of the layout *@
<div class="preview-grid">
@foreach (var cell in LayoutCells.Where(c => c.Widget != null))
{
<div class="preview-widget"
style="grid-row: @(cell.Row + 1);
grid-column: @(cell.Column + 1) / span @cell.ColSpan;
background-color: @cell.Widget!.Color;">
</div>
}
</div>
</div>
</aside>
</div>
@code {
private List<WidgetDefinition> AvailableWidgets = new()
{
new() { Id = "product-grid", Name = "Product Grid", Icon = "📦", Color = "#4A90D9", DefaultColSpan = 2 },
new() { Id = "cart", Name = "Cart Panel", Icon = "🛒", Color = "#7B68EE", DefaultColSpan = 1 },
new() { Id = "quick-access", Name = "Quick Access", Icon = "⚡", Color = "#FFB347", DefaultColSpan = 3 },
new() { Id = "totals", Name = "Totals Panel", Icon = "💰", Color = "#77DD77", DefaultColSpan = 1 },
new() { Id = "customer", Name = "Customer Info", Icon = "👤", Color = "#FF6B6B", DefaultColSpan = 1 },
new() { Id = "categories", Name = "Category Bar", Icon = "📁", Color = "#DDA0DD", DefaultColSpan = 3 },
new() { Id = "search", Name = "Search Bar", Icon = "🔍", Color = "#87CEEB", DefaultColSpan = 2 }
};
private List<LayoutCell> LayoutCells = new();
private WidgetDefinition? DraggedWidget;
private const int GRID_ROWS = 4;
private const int GRID_COLS = 3;
protected override async Task OnInitializedAsync()
{
// Initialize grid cells
for (int row = 0; row < GRID_ROWS; row++)
{
for (int col = 0; col < GRID_COLS; col++)
{
LayoutCells.Add(new LayoutCell { Row = row, Column = col, ColSpan = 1 });
}
}
// Load existing layout
var savedLayout = await LayoutService.GetCurrentLayoutAsync();
if (savedLayout != null)
{
ApplyLayout(savedLayout);
}
}
private void StartDrag(WidgetDefinition widget)
{
DraggedWidget = widget;
}
private void HandleDragOver(DragEventArgs e)
{
// Allow drop
}
private void HandleDrop(DragEventArgs e)
{
if (DraggedWidget == null) return;
// Get drop target from event
// Place widget in cell
// This is simplified - real implementation needs JS interop for accurate drop position
}
private void RemoveWidget(LayoutCell cell)
{
cell.Widget = null;
cell.ColSpan = 1;
}
private void StartResize(LayoutCell cell)
{
// Start resize operation
}
private void ResetLayout()
{
foreach (var cell in LayoutCells)
{
cell.Widget = null;
cell.ColSpan = 1;
}
}
private async Task SaveLayout()
{
var layout = new PosLayout
{
Id = Guid.NewGuid(),
Name = "Custom Layout",
Cells = LayoutCells.Where(c => c.Widget != null).Select(c => new LayoutCellConfig
{
WidgetId = c.Widget!.Id,
Row = c.Row,
Column = c.Column,
ColSpan = c.ColSpan
}).ToList()
};
await LayoutService.SaveLayoutAsync(layout);
}
private void ApplyLayout(PosLayout layout)
{
foreach (var cell in layout.Cells)
{
var gridCell = LayoutCells.FirstOrDefault(c => c.Row == cell.Row && c.Column == cell.Column);
if (gridCell != null)
{
gridCell.Widget = AvailableWidgets.FirstOrDefault(w => w.Id == cell.WidgetId);
gridCell.ColSpan = cell.ColSpan;
}
}
}
}
public class WidgetDefinition
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Color { get; set; } = string.Empty;
public int DefaultColSpan { get; set; } = 1;
}
public class LayoutCell
{
public int Row { get; set; }
public int Column { get; set; }
public int ColSpan { get; set; } = 1;
public WidgetDefinition? Widget { get; set; }
}
public class PosLayout
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<LayoutCellConfig> Cells { get; set; } = new();
}
public class LayoutCellConfig
{
public string WidgetId { get; set; } = string.Empty;
public int Row { get; set; }
public int Column { get; set; }
public int ColSpan { get; set; }
}
Day 5: Quick Access Button Editor
Objective: Allow customization of quick-access buttons.
Claude Command:
/dev-team create quick access button configuration editor
Implementation:
@* RapOS.PosClient.UI/Components/Layout/QuickAccessEditor.razor *@
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject ILayoutService LayoutService
<div class="quick-access-editor">
<h3>Quick Access Buttons</h3>
<p class="hint">Configure up to 12 quick access buttons for fast product selection.</p>
<div class="button-grid">
@for (int i = 0; i < 12; i++)
{
var index = i;
var button = Buttons.ElementAtOrDefault(index);
<div class="button-slot @(button != null ? "configured" : "")"
@onclick="() => EditButton(index)">
@if (button != null)
{
<div class="button-preview" style="background-color: @button.Color">
<span class="button-icon">@button.Icon</span>
<span class="button-label">@button.Label</span>
</div>
<button class="remove-btn" @onclick:stopPropagation @onclick="() => RemoveButton(index)">
✕
</button>
}
else
{
<span class="empty-slot">+ Add Button</span>
}
</div>
}
</div>
@* Edit Modal *@
@if (IsEditing)
{
<div class="modal-overlay" @onclick="CancelEdit">
<div class="modal-content" @onclick:stopPropagation>
<h4>Configure Quick Access Button</h4>
<div class="form-group">
<label>Button Type</label>
<select @bind="EditingButton.ActionType">
<option value="product">Product</option>
<option value="category">Category</option>
<option value="function">Function</option>
</select>
</div>
@if (EditingButton.ActionType == "product")
{
<div class="form-group">
<label>Select Product</label>
<input type="text" @bind="ProductSearch" @bind:event="oninput"
placeholder="Search products..." />
@if (FilteredProducts.Any())
{
<div class="search-results">
@foreach (var product in FilteredProducts.Take(10))
{
<div class="search-result" @onclick="() => SelectProduct(product)">
@product.Name - @product.Sku
</div>
}
</div>
}
</div>
}
else if (EditingButton.ActionType == "category")
{
<div class="form-group">
<label>Select Category</label>
<select @bind="EditingButton.CategoryId">
@foreach (var category in Categories)
{
<option value="@category.Id">@category.Name</option>
}
</select>
</div>
}
else if (EditingButton.ActionType == "function")
{
<div class="form-group">
<label>Select Function</label>
<select @bind="EditingButton.FunctionId">
<option value="open_drawer">Open Cash Drawer</option>
<option value="no_sale">No Sale</option>
<option value="discount">Apply Discount</option>
<option value="held_sales">Held Sales</option>
<option value="returns">Returns</option>
</select>
</div>
}
<div class="form-group">
<label>Button Label</label>
<input type="text" @bind="EditingButton.Label" maxlength="15" />
</div>
<div class="form-group">
<label>Button Color</label>
<div class="color-picker">
@foreach (var color in AvailableColors)
{
<div class="color-option @(EditingButton.Color == color ? "selected" : "")"
style="background-color: @color"
@onclick="() => EditingButton.Color = color">
</div>
}
</div>
</div>
<div class="modal-actions">
<button @onclick="CancelEdit">Cancel</button>
<button class="primary" @onclick="SaveButton">Save</button>
</div>
</div>
</div>
}
</div>
@code {
private List<QuickAccessButton> Buttons = new();
private List<Product> Products = new();
private List<Category> Categories = new();
private bool IsEditing;
private int EditingIndex;
private QuickAccessButton EditingButton = new();
private string ProductSearch = string.Empty;
private readonly string[] AvailableColors =
{
"#4A90D9", "#7B68EE", "#FFB347", "#77DD77",
"#FF6B6B", "#DDA0DD", "#87CEEB", "#F0E68C"
};
private IEnumerable<Product> FilteredProducts =>
string.IsNullOrEmpty(ProductSearch)
? Enumerable.Empty<Product>()
: Products.Where(p =>
p.Name.Contains(ProductSearch, StringComparison.OrdinalIgnoreCase) ||
p.Sku.Contains(ProductSearch, StringComparison.OrdinalIgnoreCase));
protected override async Task OnInitializedAsync()
{
Buttons = await LayoutService.GetQuickAccessButtonsAsync();
Products = await ProductService.GetProductsAsync();
Categories = await CategoryService.GetCategoriesAsync();
}
private void EditButton(int index)
{
EditingIndex = index;
EditingButton = Buttons.ElementAtOrDefault(index)?.Clone() ?? new QuickAccessButton
{
Position = index,
ActionType = "product",
Color = AvailableColors[0]
};
IsEditing = true;
}
private void SelectProduct(Product product)
{
EditingButton.ProductId = product.Id;
EditingButton.Label = product.Name.Length > 15
? product.Name.Substring(0, 15)
: product.Name;
ProductSearch = string.Empty;
}
private async Task SaveButton()
{
EditingButton.Position = EditingIndex;
if (EditingIndex < Buttons.Count)
{
Buttons[EditingIndex] = EditingButton;
}
else
{
while (Buttons.Count <= EditingIndex)
{
Buttons.Add(null!);
}
Buttons[EditingIndex] = EditingButton;
}
await LayoutService.SaveQuickAccessButtonsAsync(Buttons.Where(b => b != null).ToList());
IsEditing = false;
}
private void CancelEdit()
{
IsEditing = false;
}
private async Task RemoveButton(int index)
{
if (index < Buttons.Count)
{
Buttons.RemoveAt(index);
await LayoutService.SaveQuickAccessButtonsAsync(Buttons);
}
}
}
23.9 Week 19: Distribution & Polish
Day 1-2: Update Server (Adapt Raptag Pattern)
Objective: Deploy update distribution service based on existing Raptag pattern.
Claude Command:
/dev-team create POS client update server adapting Raptag pattern
Service Location: /volume1/docker/pos-platform/update-server/
Implementation: See existing Raptag update-server at /volume1/docker/raptag/update-server/ - adapt for multi-platform support.
Key additions:
- Platform detection (windows, android, macos)
- Tenant-specific version channels
- Rollback support
Day 3-4: Terminal Registration Flow
Objective: Implement first-launch registration experience.
Claude Command:
/dev-team create terminal registration flow with QR code provisioning
Implementation:
@* RapOS.PosClient.UI/Pages/Registration.razor *@
@page "/register"
@inject ITerminalService TerminalService
@inject ILocalDatabase Database
@inject NavigationManager Navigation
<div class="registration-screen">
<div class="registration-card">
<img src="images/rapos-logo.svg" class="logo" alt="RapOS" />
<h1>Welcome to RapOS POS</h1>
@if (!IsRegistered)
{
@if (!IsConnecting)
{
<p class="instruction">
Enter the 6-digit registration code provided by your administrator.
</p>
<div class="code-input">
@for (int i = 0; i < 6; i++)
{
var index = i;
<input type="text"
maxlength="1"
class="code-digit"
@bind="CodeDigits[index]"
@oninput="(e) => HandleDigitInput(index, e)"
@ref="DigitInputs[index]" />
}
</div>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="error-message">@ErrorMessage</div>
}
<button class="connect-btn"
@onclick="ConnectTerminal"
disabled="@(!IsCodeComplete)">
Connect Terminal
</button>
<p class="help-text">
Don't have a code? Contact your store administrator.
</p>
}
else
{
<div class="connecting">
<div class="spinner"></div>
<p>Connecting to @TenantName...</p>
<p class="status">@ConnectionStatus</p>
</div>
}
}
else
{
<div class="success">
<span class="success-icon">✓</span>
<h2>Terminal Registered!</h2>
<p>Connected to @TenantName</p>
<p>Location: @LocationName</p>
<div class="sync-status">
<div class="progress-bar">
<div class="progress" style="width: @(SyncProgress)%"></div>
</div>
<p>Syncing data... @SyncProgress%</p>
</div>
</div>
}
</div>
</div>
@code {
private string[] CodeDigits = new string[6];
private ElementReference[] DigitInputs = new ElementReference[6];
private bool IsConnecting;
private bool IsRegistered;
private string? ErrorMessage;
private string? TenantName;
private string? LocationName;
private string ConnectionStatus = "Verifying code...";
private int SyncProgress;
private bool IsCodeComplete => CodeDigits.All(d => !string.IsNullOrEmpty(d));
private string RegistrationCode => string.Join("", CodeDigits);
private void HandleDigitInput(int index, ChangeEventArgs e)
{
var value = e.Value?.ToString() ?? "";
if (value.Length > 0 && index < 5)
{
// Auto-advance to next input
// Requires JS interop to focus next input
}
}
private async Task ConnectTerminal()
{
if (!IsCodeComplete) return;
IsConnecting = true;
ErrorMessage = null;
try
{
// Step 1: Verify code with server
ConnectionStatus = "Verifying code...";
var result = await TerminalService.RegisterAsync(RegistrationCode);
if (!result.Success)
{
ErrorMessage = result.Error ?? "Invalid or expired registration code";
IsConnecting = false;
return;
}
TenantName = result.TenantName;
LocationName = result.LocationName;
// Step 2: Save configuration locally
ConnectionStatus = "Saving configuration...";
await SaveTerminalConfigAsync(result);
// Step 3: Initial data sync
ConnectionStatus = "Downloading products...";
await SyncInitialDataAsync();
IsRegistered = true;
// Navigate to main screen after delay
await Task.Delay(2000);
Navigation.NavigateTo("/sale");
}
catch (Exception ex)
{
ErrorMessage = $"Connection failed: {ex.Message}";
IsConnecting = false;
}
}
private async Task SaveTerminalConfigAsync(RegistrationResult result)
{
await Database.ExecuteAsync(@"
INSERT OR REPLACE INTO terminal_config
(id, tenant_code, location_id, terminal_id, terminal_name,
api_endpoint, api_key, created_at)
VALUES (1, @TenantCode, @LocationId, @TerminalId, @TerminalName,
@ApiEndpoint, @ApiKey, @now)",
new
{
result.TenantCode,
result.LocationId,
result.TerminalId,
result.TerminalName,
result.ApiEndpoint,
result.ApiKey,
now = DateTime.UtcNow.ToString("O")
});
// Store API key in secure storage
await SecureStorage.SetAsync("api_key", result.ApiKey);
}
private async Task SyncInitialDataAsync()
{
// Sync categories
SyncProgress = 10;
ConnectionStatus = "Syncing categories...";
await Task.Delay(500); // Simulated
// Sync products
SyncProgress = 30;
ConnectionStatus = "Syncing products...";
await Task.Delay(1000); // Simulated
// Sync customers
SyncProgress = 60;
ConnectionStatus = "Syncing customers...";
await Task.Delay(500); // Simulated
// Sync settings
SyncProgress = 80;
ConnectionStatus = "Applying settings...";
await Task.Delay(500); // Simulated
SyncProgress = 100;
ConnectionStatus = "Ready!";
}
}
Day 5: End-to-End Testing & Performance
Objective: Validate complete workflows and optimize performance.
Claude Command:
/qa-team run end-to-end testing for POS client workflows
Test Scenarios:
// RapOS.PosClient.Tests/E2E/SaleWorkflowTests.cs
namespace RapOS.PosClient.Tests.E2E;
[TestClass]
public class SaleWorkflowTests
{
[TestMethod]
public async Task CompleteSale_WithCash_PrintsReceipt()
{
// Arrange
var cart = new CartService();
await cart.AddItemAsync(TestProducts.Shirt);
await cart.AddItemAsync(TestProducts.Jeans);
var payment = new PaymentService(/*...*/);
var sale = new SaleService(/*...*/);
// Act
var cashResult = await payment.ProcessCashPaymentAsync(99.99m, 100.00m);
var saleResult = await sale.CompleteSaleAsync(new[] { cashResult.Payment });
// Assert
Assert.IsTrue(saleResult.Success);
Assert.IsNotNull(saleResult.ReceiptNumber);
Assert.AreEqual(0.01m, cashResult.Payment.Change);
}
[TestMethod]
public async Task OfflineSale_QueuesForSync()
{
// Arrange
var connectivity = new Mock<IConnectivityService>();
connectivity.Setup(c => c.IsOnlineAsync()).ReturnsAsync(false);
var sync = new SyncService(/*...*/);
var cart = new CartService();
await cart.AddItemAsync(TestProducts.Shirt);
var sale = new SaleService(/*...*/);
// Act
var result = await sale.CompleteSaleAsync(/*...*/);
var pendingCount = await sync.GetPendingCountAsync();
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual(1, pendingCount);
}
[TestMethod]
public async Task BarcodeScanner_AddsProductToCart()
{
// Arrange
var scanner = new BarcodeScannerService(/*...*/);
var cart = new CartService();
// Act
await scanner.ProcessBarcodeAsync("1234567890123");
// Assert
var items = await cart.GetCartItemsAsync();
Assert.AreEqual(1, items.Count);
}
}
23.10 Phase 4 Deliverables Checklist
Week 14: Project Setup & Core UI
- .NET MAUI Blazor Hybrid project structure
- SQLite local database schema
- Main sale screen with product grid and cart
Week 15: Sale Workflow
- Product browsing and search
- Barcode scanning (camera + hardware)
- Cart operations with discounts
Week 16: Payments & Transactions
- Cash payment processing
- Card payment (Stripe Terminal)
- Receipt generation and printing
Week 17: Offline & Sync
- Local transaction queue
- Product/customer cache
- Background sync service
- Conflict resolution
Week 18: Hardware & Configuration
- Multi-platform printer support
- Drag-and-drop layout builder
- Quick access button editor
- Hardware profile management
Week 19: Distribution & Polish
- Update server deployment
- Terminal registration flow
- End-to-end testing
- Performance optimization
23.11 Success Criteria
| Metric | Target |
|---|---|
| Offline Duration | 72+ hours fully functional |
| Transaction Speed | < 2 seconds from scan to receipt |
| Sync Reliability | 99.9% successful sync rate |
| App Startup | < 3 seconds cold start |
| Hardware Support | Epson, Star Micronics, Zebra printers |
| Platform Coverage | Windows, Android, macOS |
23.12 Next Steps
Phase 4 is the final implementation phase. After completion:
- Part VII: Operations (Chapters 24-28)
- Deployment procedures (Chapter 24)
- Monitoring and alerting (Chapter 25)
- Security compliance (Chapter 26)
- Disaster recovery (Chapter 27)
- Tenant lifecycle management (Chapter 28)
Document Information
| Attribute | Value |
|---|---|
| Version | 5.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-02-25 |
| Author | Claude Code |
| Status | Active |
| Part | VI - Implementation Guide |
| Chapter | 23 of 32 |
This chapter is part of the POS Blueprint Book. All content is self-contained.