Lekcja 24. Zegar i prosty terminarz w .NET MAUI
ŚredniPo co się tego uczymy?
Aplikacje typu zegar czy kalendarz to jeden z klasycznych przykładów prostych aplikacji mobilnych wymienionych wprost w podstawie programowej INF.04.6. Ta lekcja łączy kilka poznanych już mechanizmów (kontrolki czasu, zdarzenia, model danych, ObservableCollection) w jeden spójny projekt: zegar aktualizowany co sekundę oraz formularz dodawania wydarzeń z datą i godziną.
Teoria
Odświeżanie interfejsu w regularnych odstępach czasu — Dispatcher.StartTimer. Żeby zegar na ekranie pokazywał AKTUALNĄ godzinę (a nie tylko godzinę z momentu otwarcia strony), potrzebny jest mechanizm cyklicznie wywołujący jakiś kod. Dispatcher.StartTimer(TimeSpan.FromSeconds(1), () => { ...; return true; }) uruchamia podaną funkcję co określony czas — funkcja musi zwrócić bool: true oznacza "kontynuuj, wywołaj mnie ponownie za kolejny interwał", a false zatrzymuje timer na stałe. Dispatcher gwarantuje, że aktualizacja interfejsu (zmiana Label.Text) odbywa się na WŁAŚCIWYM wątku UI — bezpośrednia zmiana kontrolek z innego wątku mogłaby spowodować błąd.
Kontrolki daty i czasu — DatePicker i TimePicker. DatePicker.Date zwraca wybraną datę jako DateTime (z zerową częścią czasową), a TimePicker.Time zwraca wybraną godzinę jako TimeSpan. Sklejenie ich przez zwykłe dodawanie (DataPicker.Date + CzasPicker.Time) daje PEŁNY termin (data + godzina) jako jeden obiekt DateTime — to bardzo częsty, przydatny wzorzec przy każdej aplikacji z planowaniem wydarzeń.
Walidacja terminu. Sensowna aplikacja terminarza nie powinna pozwolić dodać wydarzenia z DATĄ Z PRZESZŁOŚCI — proste porównanie if (termin < DateTime.Now) wystarczy, żeby to sprawdzić i pokazać stosowny komunikat zamiast dodawać bezsensowne wydarzenie.
Sortowanie kolekcji obserwowalnej. ObservableCollection<T> (poznana w lekcji o CollectionView) nie ma wbudowanej metody sortowania w miejscu — typowy wzorzec to: posortować kopię przez LINQ (_wydarzenia.OrderBy(x => x.Termin).ToList()), WYCZYŚCIĆ oryginalną kolekcję (Clear()), a potem dodać elementy z powrotem w nowej, posortowanej kolejności (foreach + Add) — dzięki temu interfejs cały czas pokazuje wydarzenia od najbliższego do najdalszego terminu.
Właściwość wyliczana jako czytelny format wyświetlania. Model Wydarzenie ma właściwość TerminOpis => Termin.ToString("dd.MM.yyyy HH:mm") — to WYLICZANA właściwość (bez set), formatująca surowy DateTime na czytelny dla użytkownika string, bez potrzeby ręcznego formatowania w każdym miejscu, gdzie ten termin jest wyświetlany.
Schemat
Konstruktor MainPage
│
▼
Dispatcher.StartTimer(1 sekunda, () => { ZegarLabel.Text = DateTime.Now...; return true; })
│
▼ (powtarza się co sekundę, w nieskończoność, bo zwraca true)
ZegarLabel pokazuje aktualną godzinę
Button "Dodaj wydarzenie" (Clicked)
│
▼
NazwaEntry.Text puste? ──tak──► DisplayAlert "Brak nazwy"
│ nie
▼
termin = DataPicker.Date + CzasPicker.Time
│
▼
termin < DateTime.Now? ──tak──► DisplayAlert "Błędny termin"
│ nie
▼
_wydarzenia.Add(nowe Wydarzenie)
│
▼
posortuj _wydarzenia rosnąco po Termin (OrderBy → Clear → dodaj z powrotem)
│
▼
CollectionView pokazuje uporządkowaną listę wydarzeń
Przykład z życia
Prosta aplikacja przypominająca o wizytach lekarskich, terminach oddania projektów w szkole czy urodzinach znajomych działa dokładnie na tej zasadzie: zegar informujący, "która jest teraz", oraz lista nadchodzących wydarzeń uporządkowana chronologicznie, żeby najbliższy termin zawsze był na górze listy.
MainPage.xaml
<!-- MainPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="TerminarzMauiApp.MainPage"
Title="Zegar i terminarz">
<Grid Padding="20" RowDefinitions="Auto,Auto,Auto,*" RowSpacing="14">
<Label x:Name="ZegarLabel" FontSize="36" FontAttributes="Bold" HorizontalOptions="Center" />
<VerticalStackLayout Grid.Row="1" Spacing="8">
<Entry x:Name="NazwaEntry" Placeholder="Nazwa wydarzenia" />
<DatePicker x:Name="DataPicker" />
<TimePicker x:Name="CzasPicker" />
</VerticalStackLayout>
<Button Grid.Row="2" Text="Dodaj wydarzenie" Clicked="Dodaj_Clicked" />
<CollectionView Grid.Row="3" x:Name="ListaWydarzen" EmptyView="Brak wydarzeń.">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid Padding="8" ColumnDefinitions="*,Auto">
<Label Text="{Binding Nazwa}" />
<Label Grid.Column="1" Text="{Binding TerminOpis}" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>
Modele/Wydarzenie.cs + MainPage.xaml.cs
// Modele/Wydarzenie.cs
namespace TerminarzMauiApp.Modele;
public class Wydarzenie
{
public string Nazwa { get; set; } = "";
public DateTime Termin { get; set; }
public string TerminOpis => Termin.ToString("dd.MM.yyyy HH:mm");
}
// ---------------------------------------------------------------------
// MainPage.xaml.cs
using System.Collections.ObjectModel;
using TerminarzMauiApp.Modele;
namespace TerminarzMauiApp;
public partial class MainPage : ContentPage
{
private readonly ObservableCollection<Wydarzenie> _wydarzenia = new();
public MainPage()
{
InitializeComponent();
ListaWydarzen.ItemsSource = _wydarzenia;
Dispatcher.StartTimer(TimeSpan.FromSeconds(1), () =>
{
ZegarLabel.Text = DateTime.Now.ToString("HH:mm:ss");
return true;
});
}
private async void Dodaj_Clicked(object sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(NazwaEntry.Text))
{
await DisplayAlert("Brak nazwy", "Wpisz nazwę wydarzenia.", "OK");
return;
}
DateTime termin = DataPicker.Date + CzasPicker.Time;
if (termin < DateTime.Now)
{
await DisplayAlert("Błędny termin", "Wybierz datę i godzinę z przyszłości.", "OK");
return;
}
_wydarzenia.Add(new Wydarzenie { Nazwa = NazwaEntry.Text.Trim(), Termin = termin });
var uporzadkowane = _wydarzenia.OrderBy(x => x.Termin).ToList();
_wydarzenia.Clear();
foreach (Wydarzenie wydarzenie in uporzadkowane)
_wydarzenia.Add(wydarzenie);
NazwaEntry.Text = "";
}
}
Komentarz i wyjaśnienie kodu
Timer uruchomiony w konstruktorze działa przez CAŁY czas życia strony — jeśli aplikacja miałaby wiele stron i chciała oszczędzać zasoby, dobra praktyka to zatrzymanie timera (zwrócenie false z funkcji albo zapamiętanie zwróconego uchwytu i jego zatrzymanie) przy opuszczaniu strony, choć w tym prostym przykładzie (jedna strona przez cały czas działania aplikacji) nie jest to konieczne.
Sortowanie przez "wyczyść i dodaj ponownie" może wydawać się nieefektywne, ale dla małej liczby elementów (typowej w prostym terminarzu) jest w pełni wystarczające i znacznie prostsze w zapisie niż próba sortowania ObservableCollection "w miejscu". Zwróć uwagę, że KAŻDE dodanie _wydarzenia.Add(...) w pętli foreach osobno powiadamia CollectionView o zmianie — dla bardzo długich list warto by rozważyć bardziej wydajne podejście, ale nie jest to wymagane na tym poziomie kursu.
Ćwiczenie samodzielne
Utwórz projekt z powyższym przykładem. Sprawdź: czy zegar faktycznie aktualizuje się co sekundę, czy próba dodania wydarzenia z przeszłą datą jest poprawnie blokowana, i czy dodawanie kilku wydarzeń w LOSOWEJ kolejności czasowej powoduje, że lista zawsze wyświetla się posortowana chronologicznie.
Zadania do pracy własnej
Dodaj do modelu
WydarzeniewłaściwośćOpis(dodatkowy, dłuższy tekst) i poleEditorw formularzu do jej wprowadzenia.Dodaj możliwość USUWANIA wybranego wydarzenia z listy — przycisk w szablonie każdej pozycji, korzystający z techniki
(sender as BindableObject)?.BindingContextpoznanej w lekcji o CollectionView.Rozbuduj terminarz o TRWAŁY zapis wydarzeń do pliku JSON (technika z odpowiedniej lekcji) — wydarzenia powinny być wczytywane przy starcie aplikacji i zapisywane po każdej zmianie (dodaniu, usunięciu), tak żeby przetrwały zamknięcie aplikacji. Jako rozszerzenie: połącz terminarz z lekcją o powiadomieniach lokalnych, tak żeby dodanie wydarzenia automatycznie planowało/wysyłało powiadomienie o zbliżającym się terminie.
Typowe błędy
Zwrócenie false z funkcji timera przez pomyłkę — natychmiast zatrzymuje cykliczne wywoływanie, więc zegar zaktualizuje się dokładnie RAZ i już nigdy więcej; zwracana wartość MUSI być true, żeby timer działał w nieskończoność.
Sklejanie tylko DatePicker.Date bez TimePicker.Time — sam Date ma czas ustawiony na północ (00:00), więc porównanie z DateTime.Now mogłoby dawać zaskakujące wyniki (np. wydarzenie "dzisiaj o 15:00" oceniane jako już przeszłe, jeśli sprawdzono by tylko datę bez godziny).
Brak walidacji pustej nazwy lub daty z przeszłości — bez tych sprawdzeń użytkownik może dodać bezsensowne, niekompletne wpisy, które psują użyteczność terminarza.
Nawiązanie do egzaminu zawodowego
To bezpośrednia realizacja wymagania INF.04.6.2 "zegar/kalendarz", wymienionego wprost jako przykładowa prosta aplikacja mobilna w podstawie programowej — a jednocześnie praktyczne podsumowanie wcześniejszych lekcji o kontrolkach, zdarzeniach, modelach danych i ObservableCollection, połączonych teraz w jeden spójny, kompletny projekt.