Mohammed Chami
.NET Developer | Content Creator
Mohammed Chami
.NET Developer | Content Creator

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.

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:
To-do apps might seem cliché, but they’re actually brilliant learning projects. Here’s why:
Plus, once you finish, you’ll have a genuinely useful application that works identically on Linux, Windows, and macOS.
Don’t worry – the setup is simpler than you might think. Here’s what we’ll be working with:
Required Software:
Skills You Should Have:
Time Investment:
Let’s get your machine ready for cross-platform development. This setup will serve you well for future projects too.
# Update your system first
sudo pacman -Syu
# Install .NET 9 SDK
sudo pacman -S dotnet-sdk-9.0
# Verify installation
dotnet --version
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?
Let’s start by creating a well-organized project that follows best practices from day one.
# 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
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/
MVVM sounds scary, but it’s actually intuitive once you understand the purpose. Think of it like organizing a restaurant:
Why MVVM matters for cross-platform apps:
In our todo app:
Every good application starts with understanding its data. Our to-do app needs to represent individual tasks and collections of tasks.
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 identifiableJsonPropertyName attributes enable clean JSON serializationDateTime? allows null values for incomplete tasksTodoPriority enum provides structured priority levelsNote: 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.
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:
PriorityColor and CompletedAtTextWhat 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.
Nobody wants to lose their to-do list when they close the app. Let’s create a service that handles saving and loading.
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:
Environment.SpecialFolder for proper paths on any OSBeginner 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.
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:
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.
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:
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.
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:
Here’s how to verify everything works:
App won’t start:
# Check your .NET installation
dotnet --info
# Restore packages
dotnet restore
# Clean and rebuild
dotnet clean && dotnet build
Data not persisting:
~/.todoapp/todos.jsonUI looks broken:
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!
Congratulations! You’ve just built a real, functional cross-platform application using modern development patterns. Here’s what you’ve accomplished:
This todo app might seem simple, but it demonstrates the core concepts you’ll use in much larger applications:
Now that you have a working todo app, here are some enhancements that will teach you even more:
// 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;
// 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();
// 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);
}
}
[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);
}
}
Wrong:
public string Title { get; set; } // UI won't update!
Right:
[ObservableProperty]
private string _title = string.Empty; // UI updates automatically
Wrong:
var todos = _todoService.LoadTodos(); // Blocks UI
Right:
var todos = await _todoService.LoadTodosAsync(); // Keeps UI responsive
Wrong:
todoViewModel.PropertyChanged += SomeHandler; // Never unsubscribed
Right:
// Subscribe
todoViewModel.PropertyChanged += SomeHandler;
// Remember to unsubscribe when removing
todoViewModel.PropertyChanged -= SomeHandler;
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
As your app grows, these optimizations will keep it snappy:
<!-- Replace ItemsControl with ListBox for virtualization -->
<ListBox ItemsSource="{Binding FilteredTodos}"
VirtualizationMode="Standard">
<!-- Same DataTemplate as before -->
</ListBox>
private Timer? _searchTimer;
partial void OnSearchTextChanged(string value)
{
_searchTimer?.Dispose();
_searchTimer = new Timer(_ => ApplyFilter(), null, 300, Timeout.Infinite);
}
[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;
}
}
# 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
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
This tutorial taught you patterns that apply to much bigger applications:
You’ve built something real, but this is just the beginning. Here are logical next projects:
“Cannot resolve symbol” errors in Rider:
# Clear Rider caches
File → Invalidate Caches and Restart
# Restore NuGet packages
dotnet restore
App crashes on startup:
Data binding not working:
ObservableObjectDataContext is set correctlyFile 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);
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:
The async patterns you learned handle millions of operations in enterprise software. The data binding concepts scale from simple forms to complex business dashboards.
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.
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:
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!