feat: Complete Milestone 4 - Views/UI Layer

Implemented complete MVVM UI layer with data binding:

**Dependency Injection (MauiProgram.cs):**
- Registered all services as Singleton (shared state)
- Registered all ViewModels and Pages as Transient
- Configured SQLite database path

**Pages:**
- MainPage: Mode selector, estimate display, shake status
- HistoryPage: CollectionView with auto-load on appearing
- SettingsPage: Mode picker, MaxHistorySize stepper

**Value Converters:**
- IsNullConverter / IsNotNullConverter (visibility)
- BoolToShakingConverter (status text)
- BoolToColorConverter (status colors)
- InvertedBoolConverter (boolean inversion)

**Navigation:**
- AppShell with TabBar (Estimate, History, Settings)
- ContentTemplate for lazy loading

**Technical details:**
- All XAML uses x:DataType for compile-time checking
- All code-behind uses constructor injection
- Zero warnings, zero errors
- 165 tests passing (no UI tests yet)

Build verified across all platforms (net8.0, iOS, macOS Catalyst)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 12:52:20 +02:00
parent 48a844b0c1
commit 53cb6df8af
14 changed files with 538 additions and 63 deletions

View File

@@ -228,19 +228,62 @@ HihaArvio.sln
- **Coverage:** 51.28% line (low due to MAUI template), 87.5% branch
- **Build:** 0 warnings, 0 errors across all platforms
**Milestone 3: ViewModels Layer (✅ Complete)**
- MainViewModel (18 tests)
- Coordinates shake detection and estimate generation
- Subscribes to ShakeDetectionService.ShakeDataChanged event
- Detects shake stop (transition from shaking → not shaking)
- Mode selection and current estimate display
- Implements IDisposable for cleanup
- HistoryViewModel (15 tests)
- Manages estimate history display with ObservableCollection
- LoadHistoryCommand for async history retrieval
- ClearHistoryCommand for pruning
- IsEmpty property for empty state handling
- SettingsViewModel (13 tests)
- Settings management (SelectedMode, MaxHistorySize)
- SaveSettingsCommand for persistence
- Auto-loads settings on initialization
- All ViewModels use CommunityToolkit.Mvvm source generators
- [ObservableProperty] for property change notifications
- [RelayCommand] for commands
- **Total: 165 tests, all passing (119 services + 46 ViewModels)**
- **Build:** 0 warnings, 0 errors across all platforms
**Milestone 4: Views/UI Layer (✅ Complete)**
- Dependency Injection configuration in MauiProgram.cs
- All services registered as Singleton
- All ViewModels and Pages registered as Transient
- SQLite database path configured with FileSystem.AppDataDirectory
- MainPage.xaml with data binding
- Mode selector (Work/Generic)
- Current estimate display with conditional visibility
- Shake status indicator
- Uses x:DataType for compile-time binding verification
- HistoryPage.xaml with data binding
- CollectionView with ItemTemplate
- Empty state when no history
- Refresh and Clear All buttons
- Auto-loads history on OnAppearing()
- SettingsPage.xaml with data binding
- Picker for mode selection (Work/Generic only - Humorous is easter egg)
- Stepper for MaxHistorySize (5-100, increment by 5)
- Save Settings button
- About section with easter egg hint
- Value Converters for UI logic
- IsNullConverter / IsNotNullConverter (conditional visibility)
- BoolToShakingConverter (status text)
- BoolToColorConverter (status colors)
- InvertedBoolConverter (boolean inversion)
- All registered in App.xaml resources
- AppShell.xaml navigation
- TabBar with 3 tabs: Estimate, History, Settings
- Each tab uses ContentTemplate for lazy loading
- **Total: 165 tests still passing (no UI tests yet)**
- **Build:** 0 warnings, 0 errors across all platforms (net8.0, iOS, macOS Catalyst)
### Remaining Work
**Milestone 3: ViewModels Layer** (Not Started)
- MainViewModel (estimate display, settings)
- HistoryViewModel (estimate history)
- SettingsViewModel (mode selection, history management)
**Milestone 4: Views/UI Layer** (Not Started)
- MainPage (shake screen, estimate display)
- HistoryPage (estimate list)
- SettingsPage (mode toggle, history clear)
- Navigation infrastructure
**Milestone 5: Platform-Specific Implementations** (Not Started)
- IAccelerometerService interface
- iOS implementation (real accelerometer)

View File

@@ -2,6 +2,7 @@
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:HihaArvio"
xmlns:converters="clr-namespace:HihaArvio.Converters"
x:Class="HihaArvio.App">
<Application.Resources>
<ResourceDictionary>
@@ -9,6 +10,13 @@
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Value Converters -->
<converters:IsNullConverter x:Key="IsNullConverter"/>
<converters:IsNotNullConverter x:Key="IsNotNullConverter"/>
<converters:BoolToShakingConverter x:Key="BoolToShakingConverter"/>
<converters:BoolToColorConverter x:Key="BoolToColorConverter"/>
<converters:InvertedBoolConverter x:Key="InvertedBoolConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -4,12 +4,26 @@
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:HihaArvio"
Shell.FlyoutBehavior="Disabled"
Title="HihaArvio">
Title="Hiha-Arvio">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
<TabBar>
<ShellContent
Title="Estimate"
Icon="home.png"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
<ShellContent
Title="History"
Icon="history.png"
ContentTemplate="{DataTemplate local:HistoryPage}"
Route="HistoryPage" />
<ShellContent
Title="Settings"
Icon="settings.png"
ContentTemplate="{DataTemplate local:SettingsPage}"
Route="SettingsPage" />
</TabBar>
</Shell>

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace HihaArvio.Converters;
public class BoolToColorConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isShaking)
{
return isShaking ? Colors.Green : Colors.Gray;
}
return Colors.Gray;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace HihaArvio.Converters;
public class BoolToShakingConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool isShaking)
{
return isShaking ? "Shaking" : "Idle";
}
return "Unknown";
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,16 @@
using System.Globalization;
namespace HihaArvio.Converters;
public class InvertedBoolConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is bool boolValue && !boolValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is bool boolValue && !boolValue;
}
}

View File

@@ -0,0 +1,29 @@
using System.Globalization;
namespace HihaArvio.Converters;
public class IsNullConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class IsNotNullConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is not null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:HihaArvio.ViewModels"
xmlns:models="clr-namespace:HihaArvio.Models"
x:Class="HihaArvio.HistoryPage"
x:DataType="viewmodels:HistoryViewModel"
Title="History">
<Grid RowDefinitions="Auto,*">
<!-- Toolbar -->
<HorizontalStackLayout Grid.Row="0" Padding="15" Spacing="10">
<Button Text="Refresh"
Command="{Binding LoadHistoryCommand}"
HorizontalOptions="FillAndExpand"/>
<Button Text="Clear All"
Command="{Binding ClearHistoryCommand}"
BackgroundColor="{StaticResource Danger}"
HorizontalOptions="FillAndExpand"/>
</HorizontalStackLayout>
<!-- Empty State -->
<VerticalStackLayout Grid.Row="1"
IsVisible="{Binding IsEmpty}"
VerticalOptions="Center"
HorizontalOptions="Center"
Padding="20">
<Label Text="No history yet"
FontSize="24"
FontAttributes="Bold"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Gray600}"/>
<Label Text="Shake your device to generate estimates"
FontSize="16"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Gray500}"
Margin="0,10,0,0"/>
</VerticalStackLayout>
<!-- History List -->
<CollectionView Grid.Row="1"
ItemsSource="{Binding History}"
IsVisible="{Binding IsEmpty, Converter={StaticResource InvertedBoolConverter}}"
Margin="10">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:EstimateResult">
<Frame Padding="15" Margin="5" HasShadow="True">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*,Auto" RowSpacing="8">
<!-- Estimate Text -->
<Label Grid.Row="0" Grid.Column="0"
Text="{Binding EstimateText}"
FontSize="24"
FontAttributes="Bold"
TextColor="{StaticResource Primary}"/>
<!-- Mode Badge -->
<Frame Grid.Row="0" Grid.Column="1"
Padding="8,4"
BackgroundColor="{StaticResource Secondary}"
CornerRadius="12"
HasShadow="False">
<Label Text="{Binding Mode}"
FontSize="12"
TextColor="White"/>
</Frame>
<!-- Timestamp -->
<Label Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
Text="{Binding Timestamp, StringFormat='{0:MMM d, yyyy h:mm tt}'}"
FontSize="14"
TextColor="{StaticResource Gray600}"/>
<!-- Shake Details -->
<Label Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
FontSize="12"
TextColor="{StaticResource Gray500}">
<Label.FormattedText>
<FormattedString>
<Span Text="Intensity: "/>
<Span Text="{Binding ShakeIntensity, StringFormat='{0:P0}'}" FontAttributes="Bold"/>
<Span Text=" | Duration: "/>
<Span Text="{Binding ShakeDuration, StringFormat='{0:0.0}s'}" FontAttributes="Bold"/>
</FormattedString>
</Label.FormattedText>
</Label>
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>

View File

@@ -0,0 +1,23 @@
using HihaArvio.ViewModels;
namespace HihaArvio;
public partial class HistoryPage : ContentPage
{
public HistoryPage(HistoryViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
// Load history when page appears
if (BindingContext is HistoryViewModel viewModel)
{
await viewModel.LoadHistoryCommand.ExecuteAsync(null);
}
}
}

View File

@@ -1,35 +1,107 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="HihaArvio.MainPage">
xmlns:viewmodels="clr-namespace:HihaArvio.ViewModels"
xmlns:models="clr-namespace:HihaArvio.Models"
x:Class="HihaArvio.MainPage"
x:DataType="viewmodels:MainViewModel"
Title="Hiha-Arvio">
<ScrollView>
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Image
Source="dotnet_bot.png"
HeightRequest="185"
Aspect="AspectFit"
SemanticProperties.Description="dot net bot in a race car number eight" />
<VerticalStackLayout Padding="20" Spacing="20">
<Label
Text="Hello, World!"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />
<!-- Mode Selector -->
<Frame BorderColor="{StaticResource Primary}" Padding="10">
<VerticalStackLayout Spacing="10">
<Label Text="Estimation Mode"
FontSize="18"
FontAttributes="Bold"/>
<Picker SelectedItem="{Binding SelectedMode}">
<Picker.ItemsSource>
<x:Array Type="{x:Type models:EstimateMode}">
<models:EstimateMode>Work</models:EstimateMode>
<models:EstimateMode>Generic</models:EstimateMode>
</x:Array>
</Picker.ItemsSource>
</Picker>
</VerticalStackLayout>
</Frame>
<Label
Text="Welcome to &#10;.NET Multi-platform App UI"
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I" />
<!-- Shake Instruction / Current Estimate Display -->
<Frame BorderColor="{StaticResource Primary}"
Padding="30"
HasShadow="True"
MinimumHeightRequest="200">
<VerticalStackLayout Spacing="15"
VerticalOptions="Center"
HorizontalOptions="Center">
<!-- Show instruction when no estimate -->
<Label Text="Shake your device to generate an estimate"
FontSize="20"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Gray600}"
IsVisible="{Binding CurrentEstimate, Converter={StaticResource IsNullConverter}}"/>
<!-- Show estimate when available -->
<VerticalStackLayout Spacing="10"
IsVisible="{Binding CurrentEstimate, Converter={StaticResource IsNotNullConverter}}">
<Label Text="Estimated Time:"
FontSize="16"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Gray600}"/>
<Label Text="{Binding CurrentEstimate.EstimateText}"
FontSize="32"
FontAttributes="Bold"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Primary}"/>
<Label FontSize="14"
HorizontalTextAlignment="Center"
TextColor="{StaticResource Gray500}">
<Label.FormattedText>
<FormattedString>
<Span Text="Mode: "/>
<Span Text="{Binding CurrentEstimate.Mode}" FontAttributes="Bold"/>
<Span Text=" | Intensity: "/>
<Span Text="{Binding CurrentEstimate.ShakeIntensity, StringFormat='{0:P0}'}" FontAttributes="Bold"/>
</FormattedString>
</Label.FormattedText>
</Label>
</VerticalStackLayout>
</VerticalStackLayout>
</Frame>
<!-- Current Shake Data (Debug Info) -->
<Frame BorderColor="{StaticResource Secondary}" Padding="15">
<VerticalStackLayout Spacing="10">
<Label Text="Shake Status"
FontSize="18"
FontAttributes="Bold"/>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="10" RowSpacing="5">
<Label Grid.Row="0" Grid.Column="0" Text="Status:" FontAttributes="Bold"/>
<Label Grid.Row="0" Grid.Column="1"
Text="{Binding CurrentShakeData.IsShaking, Converter={StaticResource BoolToShakingConverter}}"
TextColor="{Binding CurrentShakeData.IsShaking, Converter={StaticResource BoolToColorConverter}}"/>
<Label Grid.Row="1" Grid.Column="0" Text="Intensity:" FontAttributes="Bold"/>
<Label Grid.Row="1" Grid.Column="1"
Text="{Binding CurrentShakeData.Intensity, StringFormat='{0:P0}'}"/>
<Label Grid.Row="2" Grid.Column="0" Text="Duration:" FontAttributes="Bold"/>
<Label Grid.Row="2" Grid.Column="1"
Text="{Binding CurrentShakeData.Duration, StringFormat='{0:0.0} seconds'}"/>
</Grid>
</VerticalStackLayout>
</Frame>
<!-- Info Text -->
<Label Text="Note: Shake detection requires accelerometer support. Desktop users: feature coming soon!"
FontSize="12"
TextColor="{StaticResource Gray500}"
HorizontalTextAlignment="Center"
Margin="0,10,0,0"/>
<Button
x:Name="CounterBtn"
Text="Click me"
SemanticProperties.Hint="Counts the number of times you click"
Clicked="OnCounterClicked"
HorizontalOptions="Fill" />
</VerticalStackLayout>
</ScrollView>

View File

@@ -1,24 +1,12 @@
namespace HihaArvio;
using HihaArvio.ViewModels;
namespace HihaArvio;
public partial class MainPage : ContentPage
{
int count = 0;
public MainPage()
{
InitializeComponent();
}
private void OnCounterClicked(object sender, EventArgs e)
{
count++;
if (count == 1)
CounterBtn.Text = $"Clicked {count} time";
else
CounterBtn.Text = $"Clicked {count} times";
SemanticScreenReader.Announce(CounterBtn.Text);
}
public MainPage(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}

View File

@@ -1,4 +1,7 @@
using Microsoft.Extensions.Logging;
using HihaArvio.Services;
using HihaArvio.Services.Interfaces;
using HihaArvio.ViewModels;
using Microsoft.Extensions.Logging;
namespace HihaArvio;
@@ -19,6 +22,24 @@ public static class MauiProgram
builder.Logging.AddDebug();
#endif
// Register Services
builder.Services.AddSingleton<IEstimateService, EstimateService>();
builder.Services.AddSingleton<IShakeDetectionService, ShakeDetectionService>();
// Storage service with app data path
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "hihaarvio.db");
builder.Services.AddSingleton<IStorageService>(sp => new StorageService(dbPath));
// Register ViewModels
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<HistoryViewModel>();
builder.Services.AddTransient<SettingsViewModel>();
// Register Pages
builder.Services.AddTransient<MainPage>();
builder.Services.AddTransient<HistoryPage>();
builder.Services.AddTransient<SettingsPage>();
return builder.Build();
}
}

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:HihaArvio.ViewModels"
xmlns:models="clr-namespace:HihaArvio.Models"
x:Class="HihaArvio.SettingsPage"
x:DataType="viewmodels:SettingsViewModel"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<!-- Estimation Mode Section -->
<Frame BorderColor="{StaticResource Primary}" Padding="15">
<VerticalStackLayout Spacing="10">
<Label Text="Estimation Mode"
FontSize="20"
FontAttributes="Bold"/>
<Label Text="Choose the type of estimates you want to generate"
FontSize="14"
TextColor="{StaticResource Gray600}"
Margin="0,0,0,10"/>
<Picker SelectedItem="{Binding SelectedMode}">
<Picker.ItemsSource>
<x:Array Type="{x:Type models:EstimateMode}">
<models:EstimateMode>Work</models:EstimateMode>
<models:EstimateMode>Generic</models:EstimateMode>
</x:Array>
</Picker.ItemsSource>
</Picker>
<Label FontSize="12" TextColor="{StaticResource Gray500}" Margin="0,5,0,0">
<Label.FormattedText>
<FormattedString>
<Span Text="Work" FontAttributes="Bold"/>
<Span Text=": Project and task estimates (hours, days, weeks)"/><Span Text="&#10;"/>
<Span Text="Generic" FontAttributes="Bold"/>
<Span Text=": General time durations (minutes, hours)"/>
</FormattedString>
</Label.FormattedText>
</Label>
</VerticalStackLayout>
</Frame>
<!-- History Settings Section -->
<Frame BorderColor="{StaticResource Primary}" Padding="15">
<VerticalStackLayout Spacing="10">
<Label Text="History Settings"
FontSize="20"
FontAttributes="Bold"/>
<Label Text="Maximum number of estimates to keep in history"
FontSize="14"
TextColor="{StaticResource Gray600}"
Margin="0,0,0,10"/>
<HorizontalStackLayout Spacing="15">
<Label Text="Max History Size:"
VerticalOptions="Center"
FontSize="16"/>
<Stepper Value="{Binding MaxHistorySize}"
Minimum="5"
Maximum="100"
Increment="5"
VerticalOptions="Center"/>
<Label Text="{Binding MaxHistorySize}"
VerticalOptions="Center"
FontSize="18"
FontAttributes="Bold"
TextColor="{StaticResource Primary}"/>
</HorizontalStackLayout>
<Label Text="Older estimates will be automatically removed when this limit is reached"
FontSize="12"
TextColor="{StaticResource Gray500}"
Margin="0,10,0,0"/>
</VerticalStackLayout>
</Frame>
<!-- Save Button -->
<Button Text="Save Settings"
Command="{Binding SaveSettingsCommand}"
FontSize="18"
Padding="15"
Margin="0,10,0,0"/>
<!-- Info Section -->
<Frame BorderColor="{StaticResource Secondary}" Padding="15" Margin="0,20,0,0">
<VerticalStackLayout Spacing="10">
<Label Text="About Hiha-Arvio"
FontSize="18"
FontAttributes="Bold"/>
<Label FontSize="14" TextColor="{StaticResource Gray600}">
<Label.FormattedText>
<FormattedString>
<Span Text="Hiha-Arvio ("/>
<Span Text="Sleeve Estimate" FontAttributes="Italic"/>
<Span Text=") generates semi-random time estimates based on shake intensity. Perfect for those moments when you need to pull an estimate from your sleeve!"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label Text="Easter Egg: Try shaking for more than 15 seconds..."
FontSize="12"
TextColor="{StaticResource Gray500}"
FontAttributes="Italic"
Margin="0,10,0,0"/>
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,12 @@
using HihaArvio.ViewModels;
namespace HihaArvio;
public partial class SettingsPage : ContentPage
{
public SettingsPage(SettingsViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}