Creating Your First Cross-Platform App: Build a Modern To-Do List with Avalonia and MVVM

Ready to build something real? This step-by-step guide will take you from zero to a fully functional to-do app that runs on Linux, Windows, and macOS.

There’s something magical about building your first real application. Sure, “Hello World” programs are nice, but they don’t give you that rush of creating something you’d actually want to use. Today, we’re going to build a to-do list application using Avalonia UI and the Community Toolkit MVVM pattern – and we’ll do it all from our EndeavourOS machine using JetBrains Rider.

By the end of this tutorial, you’ll have a working to-do app with features like adding tasks, marking them complete, filtering by status, and persisting data between sessions. More importantly, you’ll understand how real-world Avalonia applications are structured.

Why This Tutorial Will Actually Make You a Better Developer

Before we dive into code, let’s talk about why this project matters. Most beginner tutorials teach you syntax but skip the architecture. This tutorial is different – you’ll learn professional development patterns that scale from simple apps to enterprise software.

What makes this tutorial special:

  • Real-world MVVM patterns used in production applications
  • Cross-platform development that actually works on all major operating systems
  • Modern tooling with JetBrains Rider and .NET 8
  • Data persistence so your app remembers everything
  • Professional project structure that other developers will recognize

Why a To-Do App Is Perfect for Learning Cross-Platform Development

To-do apps might seem cliché, but they’re actually brilliant learning projects. Here’s why:

  • They use real data structures (lists, objects, filtering)
  • They demonstrate CRUD operations (Create, Read, Update, Delete)
  • They showcase data binding in practical ways
  • They need data persistence (saving/loading)
  • They require state management (completed vs pending tasks)
  • Users actually understand what the app should do

Plus, once you finish, you’ll have a genuinely useful application that works identically on Linux, Windows, and macOS.

Prerequisites: What You Need Before Starting

Don’t worry – the setup is simpler than you might think. Here’s what we’ll be working with:

Required Software:

  • EndeavourOS (or any Linux distribution)
  • .NET 9 SDK
  • JetBrains Rider IDE
  • Git (for version control)

Skills You Should Have:

  • Basic C# syntax (variables, methods, classes)
  • Understanding of object-oriented programming
  • Familiarity with Linux terminal commands
  • Basic knowledge of what MVVM means (don’t worry, we’ll explain everything)

Time Investment:

  • 2-3 hours for the complete tutorial
  • 30 minutes if you just want to see it running
  • A lifetime of satisfaction from building something real

Setting Up Your Development Environment on EndeavourOS

Let’s get your machine ready for cross-platform development. This setup will serve you well for future projects too.

Installing .NET 9 SDK

# Update your system first
sudo pacman -Syu

# Install .NET 9 SDK
sudo pacman -S dotnet-sdk-9.0

# Verify installation
dotnet --version

Setting Up JetBrains Rider

If you haven’t already installed Rider:

# Using yay (AUR helper)
yay -S jetbrains-rider

# Or download directly from JetBrains website
# https://www.jetbrains.com/rider/

Why Rider for this project?

  • Excellent .NET and C# support
  • Built-in debugging tools
  • Great Git integration
  • Cross-platform development features
  • Intelligent code completion

Setting Up Your Project Structure Like a Pro

Let’s start by creating a well-organized project that follows best practices from day one.

Creating the Project Foundation

# Navigate to your development folder
cd ~/Development

# Create the solution and project
dotnet new sln -n TodoApp
dotnet new avalonia.mvvm -n TodoApp.Desktop -f net9.0
dotnet sln add TodoApp.Desktop/TodoApp.Desktop.csproj

# Navigate to the project
cd TodoApp.Desktop

Organizing Your Project Structure

Your project should look like this when we’re done:

TodoApp/
├── Converters/
│   └── EqualsConverter.cs
├── Models/
│   ├── TodoItem.cs
    └── TodoPriority.cs
├── ViewModels/
│   ├── MainViewModel.cs
│   └── TodoItemViewModel.cs
├── Views/
│   └── MainWindow.axaml
├── Services/
│   └── TodoService.cs
└── Assets/
    └── icons/

Understanding MVVM Before We Code (5 Minutes That Will Save You Hours)

MVVM sounds scary, but it’s actually intuitive once you understand the purpose. Think of it like organizing a restaurant:

  • Model: The actual food (your data)
  • View: The dining room where customers sit (your UI)
  • ViewModel: The waiter who takes orders and brings food (the connector)

Why MVVM matters for cross-platform apps:

  • Your business logic (ViewModel) stays the same across platforms
  • Only the View changes between desktop, mobile, or web
  • Testing becomes much easier
  • Multiple developers can work on different parts simultaneously

In our todo app:

  • TodoItem (Model) represents the raw task data
  • MainWindow.axaml (View) is what users see and interact with
  • MainViewModel (ViewModel) handles user actions and manages the todo list

Building Your Todo Data Model: The Foundation of Everything

Every good application starts with understanding its data. Our to-do app needs to represent individual tasks and collections of tasks.

Creating the TodoItem Model

Create Models/TodoItem.cs:

using System.Text.Json.Serialization;

namespace TodoApp.Desktop.Models;

public class TodoItem
{
    [JsonPropertyName("id")]
    public Guid Id { get; set; } = Guid.NewGuid();
    
    [JsonPropertyName("title")]
    public string Title { get; set; } = string.Empty;
    
    [JsonPropertyName("description")]
    public string Description { get; set; } = string.Empty;
    
    [JsonPropertyName("isCompleted")]
    public bool IsCompleted { get; set; }
    
    [JsonPropertyName("createdAt")]
    public DateTime CreatedAt { get; set; } = DateTime.Now;
    
    [JsonPropertyName("completedAt")]
    public DateTime? CompletedAt { get; set; }
    
    [JsonPropertyName("priority")]
    public TodoPriority Priority { get; set; } = TodoPriority.Normal;
}

public enum TodoPriority
{
    Low,
    Normal,
    High,
    Urgent
}

Why this structure works:

  • Guid Id ensures each task is uniquely identifiable
  • JsonPropertyName attributes enable clean JSON serialization
  • DateTime? allows null values for incomplete tasks
  • TodoPriority enum provides structured priority levels

Note: The JsonPropertyName attributes tell the system how to save this data to a file. Without them, the JSON file would use C# property names, which aren’t as clean.

Creating the TodoItemViewModel

This is where MVVM shines. Create ViewModels/TodoItemViewModel.cs:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TodoApp.Models;

namespace TodoApp.ViewModels;

public partial class TodoItemViewModel : ViewModelBase
{
    private readonly TodoItem _model;
    
    public TodoItemViewModel(TodoItem model)
    {
        _model = model;
        Title = _model.Title;
        Description = _model.Description;
        Priority = _model.Priority;
        IsCompleted = _model.IsCompleted;
    }

    public TodoItem GetModel() => _model;
    public Guid Id => _model.Id;
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CompletedAtText))]
    private bool _isCompleted;
    
    [ObservableProperty] private string _title;
    [ObservableProperty] private string _description;
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(PriorityColor))]
    private TodoPriority _priority;

    public string CreatedAtText => _model.CreatedAt.ToString("dd/MM/yyyy");
    public string? CompletedAtText => _model.CompletedAt?.ToString("ddd, MMMM dd, yyyy");

    public string PriorityColor => Priority switch
    {
        TodoPriority.Low => "Green",
        TodoPriority.Normal => "Blue",
        TodoPriority.High => "Orange",
        TodoPriority.Urgent => "Red",
        _ => "Gray"
    };

    partial void OnIsCompletedChanged(bool value)
    {
        _model.IsCompleted = value;
        _model.CompletedAt = value ? DateTime.Now : null;
        
        OnPropertyChanged(nameof(CompletedAtText));
    }
}
namespace TodoApp.Converters;

public class EqualsConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is null || parameter is null) return false;
return value.ToString() == parameter.ToString();
}

public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

Key concepts here:

  • Wrapper pattern: The ViewModel wraps the model and adds UI-specific logic
  • Property change notifications: Essential for UI updates
  • Computed properties: Like PriorityColor and CompletedAtText
  • Business logic: Automatically setting completion time

What the [ObservableProperty] attribute does: This is Community Toolkit magic. It automatically generates the property change notification code that would normally take 10+ lines. The toolkit sees _isCompleted and creates a public IsCompleted property that notifies the UI when it changes.

Creating a Data Service That Actually Persists Your Tasks

Nobody wants to lose their to-do list when they close the app. Let’s create a service that handles saving and loading.

Building the TodoService

Create Services/TodoService.cs:

using System.Collections.ObjectModel;
using System.Text.Json;
using TodoApp.Models;

namespace TodoApp.Services;

public class TodoService
{
    private readonly string _dataFilePath;
    public string GetDataFilePath() => _dataFilePath;

    public TodoService()
    {
        // /home/yourusername/.todoapp where json stored
        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var appDataDirectory = Path.Combine(homeDirectory, ".todoapp");
        
        Directory.CreateDirectory(appDataDirectory);
        _dataFilePath = Path.Combine(appDataDirectory, "todo.json");
    }

    public async Task<List<TodoItem>> LoadTodosAsync()
    {
        try
        {
            if (!File.Exists(_dataFilePath))
            {
                return new List<TodoItem>();
            }
            
            var json = await File.ReadAllTextAsync(_dataFilePath);
            var todos = JsonSerializer.Deserialize<List<TodoItem>>(json);
            
            return todos ?? new List<TodoItem>();
        }
        catch (Exception e)
        {
            Console.WriteLine($"Error loading todos: {e.Message}");
            return new List<TodoItem>();
        }
    }
    
    public async Task SaveTodosAsync(IEnumerable<TodoItem> todos)
    {
        try
        {
            var json = JsonSerializer.Serialize(todos, new JsonSerializerOptions { WriteIndented = true });
            
            await File.WriteAllTextAsync(_dataFilePath, json);
        }
        catch (Exception e)
        {
            Console.WriteLine($"Error saving todos: {e.Message}");
            
        }
    }
}

Why this approach works:

  • Cross-platform paths: Uses Environment.SpecialFolder for proper paths on any OS
  • Async operations: Won’t block the UI during file operations
  • Error handling: Gracefully handles file system issues
  • Clean JSON: Indented for debugging purposes

Beginner insight: The async/await pattern here is crucial. File operations can be slow, and we don’t want our UI to freeze while saving or loading. This pattern lets the UI stay responsive.

Building the Main ViewModel: Where the Magic Happens

This is the brain of our application. Create ViewModels/MainWindowViewModel.cs:

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using TodoApp.Models;
using TodoApp.Services;

namespace TodoApp.ViewModels;

public partial class MainWindowViewModel : ViewModelBase
{
    private readonly TodoService _todoService;
    [ObservableProperty] private ObservableCollection<TodoItemViewModel> _todos;
    [ObservableProperty] private ObservableCollection<TodoItemViewModel> _filteredTodos;
    [ObservableProperty] private string _newTodoTitle = string.Empty;
    [ObservableProperty] private string _newTodoDescription = string.Empty;
    [ObservableProperty] private TodoPriority _newTodoPriority = TodoPriority.Normal;
    public IEnumerable<TodoPriority> Priorities { get; } = Enum.GetValues<TodoPriority>();
    [ObservableProperty] private string _currentFilter = "All";
    [ObservableProperty] private bool _isLoading;

    public int TotalTodos => Todos.Count;
    public int CompletedTodos => Todos.Count(t => t.IsCompleted);
    public int PendingTodos => Todos.Count(t => !t.IsCompleted);
    
    public MainWindowViewModel()
    {
        _todoService = new TodoService();
        Todos = new ObservableCollection<TodoItemViewModel>();
        FilteredTodos = new ObservableCollection<TodoItemViewModel>();
        var t = new TodoItem()
        {
            Title = "Task 1",
            Description = "Task 1 Description",
            IsCompleted = false,
            Priority = TodoPriority.Normal,
            CreatedAt = DateTime.Now
        };
        FilteredTodos.Add(new TodoItemViewModel(t));
        _ = LoadTodoAsync();
    }

    [RelayCommand]
    private async Task AddTodoAsync()
    {
        if (string.IsNullOrWhiteSpace(NewTodoTitle))
            return;

        var newTodo = new TodoItem
        {
            Title = NewTodoTitle.Trim(),
            Description = NewTodoDescription.Trim(),
            Priority = NewTodoPriority,
        };

        var todoViewModel = new TodoItemViewModel(newTodo);
        
        todoViewModel.PropertyChanged += TodoViewModel_PropertyChanged;
        
        Todos.Add(todoViewModel);
        
        NewTodoTitle = string.Empty;
        NewTodoDescription = string.Empty;
        NewTodoPriority = TodoPriority.Normal;
        
        ApplyFilter();
        await SaveTodoAsync();
        UpdateStats();
    }

    private async Task LoadTodoAsync()
    {
        IsLoading = true;
        try
        {
            var todos = await _todoService.LoadTodosAsync();

            Todos.Clear();
            foreach (var todo in todos)
            {
                var todoViewModel = new TodoItemViewModel(todo);
                todoViewModel.PropertyChanged += TodoViewModel_PropertyChanged;
                Todos.Add(todoViewModel);
            }

            ApplyFilter();
            UpdateStats();
        }
        catch (Exception e)
        {
            Console.WriteLine($"Failed  to load todos: {e.Message}");
            throw;
        }
        finally
        {
            IsLoading = false;
        }
    }
    
    private async void TodoViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(TodoItemViewModel.IsCompleted))
        {
            ApplyFilter();
            UpdateStats();
        }

        await SaveTodoAsync();
    }

    private async Task SaveTodoAsync()
    {
        var todos = Todos.Select(vm => vm.GetModel()).ToList();
        await _todoService.SaveTodosAsync(todos);
    }

    [RelayCommand]
    private async Task DeleteTodoAsync(TodoItemViewModel todoVm)
    {
        if(todoVm == null!) return;
        
        todoVm.PropertyChanged -= TodoViewModel_PropertyChanged;
        
        Todos.Remove(todoVm);
        ApplyFilter();
        await SaveTodoAsync();
        UpdateStats();
    }

    [RelayCommand]
    private async Task ClearCompleteAsync()
    {
        var completeTodos = Todos.Where(t => t.IsCompleted)
                                                        .ToList();
        foreach (var todo in completeTodos)
        {
            todo.PropertyChanged -= TodoViewModel_PropertyChanged;
            Todos.Remove(todo);
        }
        
        ApplyFilter();
        await SaveTodoAsync();
        UpdateStats();
    }

    private void UpdateStats()
    {
        OnPropertyChanged(nameof(TotalTodos));
        OnPropertyChanged(nameof(CompletedTodos));
        OnPropertyChanged(nameof(PendingTodos));
    }

    private void ApplyFilter()
    {
        var filtered = CurrentFilter switch
        {
            "Active" => Todos.Where(t => !t.IsCompleted),
            "Completed" => Todos.Where(t => t.IsCompleted),
            _ => Todos
        };
        FilteredTodos.Clear();
        foreach (var todo in filtered.OrderByDescending(t => t.Priority)
                     .ThenBy(t => t.IsCompleted)
                     .ThenByDescending(t => t.Id))
        {
            FilteredTodos.Add(todo);
        }
    }

    [RelayCommand]
    private void SetFilter(string filter)
    {
        CurrentFilter = filter;
        ApplyFilter();
    }
}

Advanced patterns at work:

  • ObservableCollection: Automatically notifies UI of list changes
  • Event subscriptions: Auto-save when individual todos change
  • Filtering logic: Dynamic list filtering without rebuilding data
  • Computed properties: Statistics that update automatically
  • Async operations: Non-blocking file operations

What [RelayCommand] does: This attribute automatically creates command properties that your UI can bind to. When you add [RelayCommand] to AddTodoAsync(), the toolkit creates an AddTodoCommand property that buttons can use.

Designing a Beautiful and Functional User Interface

Now for the fun part – creating the UI! Replace the contents of Views/MainWindow.axaml:

UI AXAML: link

UI Design Principles at Work:

This isn’t just thrown-together UI – there’s method to the madness:

  • Visual hierarchy: Large title, clear sections, consistent spacing
  • Interactive feedback: Hover effects, active states, loading indicators
  • Information density: Stats at a glance, detailed task info when needed
  • Empty states: Friendly messaging when there’s no data
  • Consistent styling: Reusable button and card styles

Data binding magic: Notice how {Binding TotalTodos} automatically updates when you add or remove todos. This is the power of MVVM – the UI stays in sync with your data without you writing update code.

Running and Testing Your Cross-Platform Todo App

Time for the moment of truth! Let’s run your application:

# Make sure you're in the project directory
cd ~/Development/TodoApp/TodoApp

# Run the application
dotnet run

If everything worked correctly, you should see:

  • A clean, modern interface with your app title
  • The ability to add new todos with different priorities
  • Real-time filtering between All, Active, and Completed tasks
  • Statistics that update as you complete tasks
  • Data persistence (your todos will survive app restarts!)

Testing the Core Features

Here’s how to verify everything works:

  1. Add a todo: Type “Learn Avalonia” in the title field and click “Add Todo”
  2. Mark complete: Check the checkbox next to your todo
  3. Test filtering: Click “Active” and “Completed” buttons
  4. Verify persistence: Close the app and reopen it – your todos should still be there
  5. Try priorities: Add todos with different priority levels and see the colored indicators

What To Do If Something Goes Wrong

App won’t start:

# Check your .NET installation
dotnet --info

# Restore packages
dotnet restore

# Clean and rebuild
dotnet clean && dotnet build

Data not persisting:

  • Check file permissions in your home directory
  • Verify the data file location: ~/.todoapp/todos.json
  • Look for error messages in the terminal

UI looks broken:

  • Make sure all XAML closing tags match opening tags
  • Verify your ViewModel property names match the binding expressions
  • Check that you’ve added all the required using statements

Testing Cross-Platform Compatibility (The Cool Part)

One of Avalonia’s superpowers is true cross-platform deployment. Let’s build for all major platforms:

# Build for Linux (your current platform)
dotnet publish -r linux-x64 --self-contained -c Release

# Build for Windows (from Linux!)
dotnet publish -r win-x64 --self-contained -c Release

# Build for macOS (from Linux!)
dotnet publish -r osx-x64 --self-contained -c Release

What --self-contained means: This creates an executable that includes everything needed to run, even on machines without .NET installed. Your users just double-click and it works.

The beauty of Avalonia is that the exact same code will run identically on all three platforms. No platform-specific changes needed!

Understanding What You’ve Built (And Why It Matters)

Congratulations! You’ve just built a real, functional cross-platform application using modern development patterns. Here’s what you’ve accomplished:

Technical Skills You’ve Learned

  • MVVM Pattern: Separation of concerns between UI and business logic
  • Data Binding: Connecting UI elements to data automatically
  • Observable Collections: Managing dynamic lists in the UI
  • Async Programming: Non-blocking file operations
  • JSON Serialization: Persisting complex data structures
  • Event Handling: Responding to user interactions and data changes
  • Service Architecture: Separating data access from business logic

Real-World Development Practices

  • Project Structure: Organizing code in a maintainable way
  • Dependency Injection: Using services for data management
  • Error Handling: Gracefully managing file system errors
  • User Experience: Loading states, empty states, and visual feedback
  • Cross-Platform Development: Write once, run everywhere

Why This Foundation Matters

This todo app might seem simple, but it demonstrates the core concepts you’ll use in much larger applications:

  • E-commerce sites manage product collections the same way we managed todos
  • Business applications handle forms and validation using identical patterns
  • Enterprise software uses the same MVVM structure for complex workflows
  • Mobile apps can use these exact same ViewModels with different Views

Advanced Features You Can Add Next

Now that you have a working todo app, here are some enhancements that will teach you even more:

1. Due Dates and Reminders

// Add to TodoItem model
public DateTime? DueDate { get; set; }
public bool IsOverdue => DueDate.HasValue && DueDate < DateTime.Now && !IsCompleted;

// Add to TodoItemViewModel
public string DueDateText => DueDate?.ToString("MMM dd") ?? "No due date";
public string OverdueWarning => IsOverdue ? "⚠️ Overdue" : string.Empty;

2. Categories and Tags

// Add to TodoItem model
public List<string> Tags { get; set; } = new();
public string Category { get; set; } = "General";

// UI enhancement for category filtering
public List<string> AvailableCategories => Todos
    .Select(t => t.Category)
    .Distinct()
    .OrderBy(c => c)
    .ToList();

3. Search and Advanced Filtering

// Add to MainViewModel
[ObservableProperty]
private string _searchText = string.Empty;

partial void OnSearchTextChanged(string value)
{
    ApplyFilter();
}

private void ApplyFilter()
{
    var filtered = Todos.AsEnumerable();
    
    // Apply status filter
    filtered = CurrentFilter switch
    {
        "Active" => filtered.Where(t => !t.IsCompleted),
        "Completed" => filtered.Where(t => t.IsCompleted),
        _ => filtered
    };
    
    // Apply search filter
    if (!string.IsNullOrWhiteSpace(SearchText))
    {
        filtered = filtered.Where(t => 
            t.Title.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
            t.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
    }
    
    FilteredTodos.Clear();
    foreach (var todo in filtered.OrderByDescending(t => t.Priority)
                                .ThenBy(t => t.IsCompleted)
                                .ThenByDescending(t => t.Id))
    {
        FilteredTodos.Add(todo);
    }
}

4. Import/Export Functionality

[RelayCommand]
private async Task ExportTodosAsync()
{
    var topLevel = TopLevel.GetTopLevel(this);
    var files = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
    {
        Title = "Export Todos",
        DefaultExtension = "json",
        SuggestedFileName = $"todos-{DateTime.Now:yyyy-MM-dd}.json"
    });

    if (files is not null)
    {
        var todos = Todos.Select(vm => vm.GetModel()).ToList();
        var json = JsonSerializer.Serialize(todos, new JsonSerializerOptions { WriteIndented = true });
        await File.WriteAllTextAsync(files.Path.LocalPath, json);
    }
}

Common Beginner Mistakes (And How to Avoid Them)

1. Forgetting Property Change Notifications

Wrong:

public string Title { get; set; } // UI won't update!

Right:

[ObservableProperty]
private string _title = string.Empty; // UI updates automatically

2. Blocking the UI Thread

Wrong:

var todos = _todoService.LoadTodos(); // Blocks UI

Right:

var todos = await _todoService.LoadTodosAsync(); // Keeps UI responsive

3. Memory Leaks from Event Subscriptions

Wrong:

todoViewModel.PropertyChanged += SomeHandler; // Never unsubscribed

Right:

// Subscribe
todoViewModel.PropertyChanged += SomeHandler;

// Remember to unsubscribe when removing
todoViewModel.PropertyChanged -= SomeHandler;

4. Mixing UI Logic with Business Logic

Wrong:

// In ViewModel
public void SaveTodo()
{
    // Don't do UI-specific things here
    MessageBox.Show("Todo saved!"); 
}

Right:

// In ViewModel - return status
public async Task<bool> SaveTodoAsync()
{
    return await _todoService.SaveTodoAsync(todo);
}

// Handle UI in the View or with data binding

Performance Tips for Larger Todo Lists

As your app grows, these optimizations will keep it snappy:

Virtual Scrolling for Large Lists

<!-- Replace ItemsControl with ListBox for virtualization -->
<ListBox ItemsSource="{Binding FilteredTodos}"
         VirtualizationMode="Standard">
    <!-- Same DataTemplate as before -->
</ListBox>

Debounced Search

private Timer? _searchTimer;

partial void OnSearchTextChanged(string value)
{
    _searchTimer?.Dispose();
    _searchTimer = new Timer(_ => ApplyFilter(), null, 300, Timeout.Infinite);
}

Background Data Operations

[RelayCommand]
private async Task RefreshTodosAsync()
{
    IsLoading = true;
    
    try
    {
        // Simulate network call or heavy processing
        await Task.Run(async () =>
        {
            var todos = await _todoService.LoadTodosAsync();
            
            // Update UI on main thread
            Dispatcher.UIThread.Post(() =>
            {
                Todos.Clear();
                foreach (var todo in todos)
                {
                    Todos.Add(new TodoItemViewModel(todo));
                }
                ApplyFilter();
            });
        });
    }
    finally
    {
        IsLoading = false;
    }
}

Deploying Your App to Different Platforms

Creating Release Builds

# For Linux AppImage (great for distribution)
dotnet publish -r linux-x64 --self-contained -c Release -p:PublishSingleFile=true

# For Windows executable
dotnet publish -r win-x64 --self-contained -c Release -p:PublishSingleFile=true

# For macOS app bundle
dotnet publish -r osx-x64 --self-contained -c Release -p:PublishSingleFile=true

Setting Up Continuous Integration

Create .github/workflows/build.yml for automatic builds:

name: Build Cross-Platform

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        
    runs-on: ${{ matrix.os }}
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x
        
    - name: Restore dependencies
      run: dotnet restore
      
    - name: Build
      run: dotnet build --no-restore
      
    - name: Test
      run: dotnet test --no-build --verbosity normal

What You’ve Actually Learned (Beyond Just Todo Apps)

This tutorial taught you patterns that apply to much bigger applications:

Enterprise Software Patterns

  • Repository pattern (your TodoService)
  • Command pattern (RelayCommand attributes)
  • Observer pattern (PropertyChanged events)
  • Dependency injection (service-based architecture)

Modern .NET Development

  • Source generators (Community Toolkit attributes)
  • Nullable reference types (DateTime? properties)
  • Pattern matching (switch expressions)
  • Async/await throughout the application

Cross-Platform Considerations

  • File system abstraction (Environment.SpecialFolder)
  • Platform-agnostic UI (no platform-specific code)
  • Responsive design (works on different screen sizes)

Next Steps: Where to Go From Here

You’ve built something real, but this is just the beginning. Here are logical next projects:

Immediate Improvements (1-2 days each)

  1. Add keyboard shortcuts (Ctrl+N for new todo, Delete key for deletion)
  2. Implement drag-and-drop reordering
  3. Add undo/redo functionality
  4. Create a system tray integration

Medium Projects (1-2 weeks each)

  1. Build a mobile version using Avalonia Mobile
  2. Add cloud sync with your favorite API
  3. Create a web version using Blazor with shared ViewModels
  4. Implement collaborative features (shared todo lists)

Advanced Projects (1+ months each)

  1. Convert to microservices architecture
  2. Add real-time collaboration with SignalR
  3. Build a plugin system for custom todo types
  4. Create a full productivity suite (calendar, notes, goals)

Resources for Continuing Your Journey

Essential Documentation

Community and Learning

Advanced Learning Paths

  • Desktop Development: WPF patterns (transferable to Avalonia)
  • Mobile Development: .NET MAUI and Avalonia Mobile
  • Web Development: Blazor with shared business logic
  • Game Development: Avalonia can handle 2D games too!

Troubleshooting: When Things Don’t Work as Expected

Common Issues and Solutions

“Cannot resolve symbol” errors in Rider:

# Clear Rider caches
File → Invalidate Caches and Restart

# Restore NuGet packages
dotnet restore

App crashes on startup:

  • Check your ViewModel constructor for exceptions
  • Verify all required properties have default values
  • Look at the console output for error messages

Data binding not working:

  • Ensure property names match exactly between XAML and ViewModel
  • Verify you’re inheriting from ObservableObject
  • Check that DataContext is set correctly

File save/load errors on different systems:

// More robust path handling
var appDataPath = Environment.GetFolderPath(
    Environment.SpecialFolder.ApplicationData);
var appFolder = Path.Combine(appDataPath, "TodoApp");
Directory.CreateDirectory(appFolder);

The Bigger Picture: Why This Tutorial Matters

You didn’t just build a todo app – you learned the architecture patterns that power modern software development. The MVVM pattern you implemented here is used in:

  • Microsoft Office (Word, Excel, PowerPoint)
  • Visual Studio and VS Code
  • Slack desktop application
  • Discord desktop client
  • Spotify desktop app

The async patterns you learned handle millions of operations in enterprise software. The data binding concepts scale from simple forms to complex business dashboards.

Your Development Journey Starts Here

Building this todo app is like learning to drive in a parking lot – you’ve practiced all the essential skills in a safe environment. Now you’re ready for bigger challenges:

Week 1-2: Enhance your todo app with the advanced features mentioned above Month 1: Build a different type of application (maybe a note-taking app or expense tracker) Month 2-3: Contribute to an open-source Avalonia project Month 6+: Apply for junior developer positions with confidence

The patterns you’ve learned here – MVVM, data binding, async operations, service architecture – are the same patterns used in enterprise software. You’re not just building toy applications anymore; you’re building with professional techniques.

Conclusion: You’ve Built Something Real

Take a moment to appreciate what you’ve accomplished. You started with an empty folder and ended with a fully functional, cross-platform application that:

  • Manages data professionally
  • Provides an intuitive user experience
  • Persists information between sessions
  • Runs identically on multiple operating systems
  • Follows industry-standard architecture patterns

But more importantly, you’ve learned how to think about software architecture. You understand why we separate concerns, how data flows through an application, and why certain patterns exist.

The next time someone asks if you know cross-platform development, you can confidently say yes – and show them the app you built to prove it.

Ready for your next challenge? Take this foundation and build something that solves a problem in your own life. The patterns are the same whether you’re building a simple utility or the next great productivity app.

Happy coding!

Mohammed Chami
Mohammed Chami
Articles: 45

Leave a Reply

Your email address will not be published. Required fields are marked *