Dataniedziela, 9 sierpnia 2026 Czas07:39:10
← Aplikacje mobilne (.NET MAUI)

Lekcja 24. Zegar i prosty terminarz w .NET MAUI

Średni

Po 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

  1. Dodaj do modelu Wydarzenie właściwość Opis (dodatkowy, dłuższy tekst) i pole Editor w formularzu do jej wprowadzenia.

  2. Dodaj możliwość USUWANIA wybranego wydarzenia z listy — przycisk w szablonie każdej pozycji, korzystający z techniki (sender as BindableObject)?.BindingContext poznanej w lekcji o CollectionView.

  3. 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.