Zaawansowane projektowanie aplikacji mobilnych

Rok akademicki: 2026/27

Projekt semestralny: Framework .NET MAUI, ekosystem C# i projekt "Stop Scout"

Prowadzący: Tomasz Goluch
Wersja: 1.0


Wprowadzenie

Cel: Przekazanie podstawowych informacji o zasobach laboratorium oraz specyfice środowiska wieloplatformowego .NET.

Laboratorium odbywa się na maszynach fizycznych (komputery laboratoryjne lub własne laptopy) oraz fizycznych smartfonach (z systemem Android lub iOS) bądź emulatorach systemowych. Wykorzystujemy ekosystem .NET MAUI (Multi-platform App UI), który kompiluje wspólny kod w języku C# oraz widoki XAML bezpośrednio do natywnych kontrolek systemów Android, iOS, macOS i Windows.


Instalacja środowiska i uruchomienie projektu

Cel: Konfiguracja narzędzi .NET SDK, obciążenia MAUI oraz wygenerowanie szablonu startowego.

⚠️ WAŻNE WYMAGANIE TECHNICZNE: Do wykonania tej instrukcji i testowania aplikacji na bieżąco, Twój komputer oraz fizyczny telefon (smartfon) muszą znajdować się w tej samej lokalnej sieci Wi-Fi. Upewnij się, że oba urządzenia są podłączone do tej samej sieci przed przejściem do kolejnych kroków.

Wymagane jest środowisko .NET 8.0 SDK (lub nowsze LTS) oraz Visual Studio 2022 (z zaznaczonym pakietem Programowanie aplikacji wieloplatformowych dla interfejsu .NET MAUI) lub Visual Studio Code z oficjalnym rozszerzeniem C# Dev Kit i .NET MAUI extension.

Weryfikacja instalacji środowiska:
Otwórz terminal (PowerShell) i sprawdź dostępność obciążenia MAUI:

dotnet --version
dotnet workload list

Jeśli obciążenie MAUI nie jest zainstalowane, wykonaj polecenie w terminalu administratora:

dotnet workload install maui

Generowanie projektu:
Tworzymy nową aplikację z architekturą opartą o AppShell:

dotnet new maui -n StopScoutMobile
cd StopScoutMobile

Podłączenie fizycznego smartfona (Android):

  1. W telefonie wejdź w Ustawienia → Informacje o telefonie i kliknij 7 razy w Numer kompilacji, aby odblokować Opcje programisty.
  2. Włącz Debugowanie USB (USB Debugging).
  3. Podłącz telefon kablem do komputera i zatwierdź monit autoryzacji klucza RSA na ekranie smartfona.
  4. Sprawdź widoczność urządzenia w konsoli:
    adb devices
  5. Uruchom aplikację bezpośrednio na telefonie:
    dotnet build -t:Run -f net8.0-android
💡 Rozwiązywanie problemów: Błędy wdrażania ADB i zawieszenie kompilatora (Kliknij, aby rozwinąć/zwinąć)

Rekomendowane narzędzia i rozszerzenia IDE


Struktura projektu i Clean Architecture

Cel: Refaktoryzacja domyślnego układu pod kątem czystej separacji odpowiedzialności biznesowej od platformy mobilnej.

Wygenerowane rozwiązanie posiada katalog Platforms/ zawierający specyficzne implementacje natywne. Aby zachować niezależność logiki biznesowej, w projekcie wydzielamy cztery kluczowe warstwy:

StopScoutMobile/
├── Domain/                         # WARSTWA DOMENY (Brak zależności od MAUI i UI)
│   ├── Entities/                   # Czyste modele C# (np. Stop, Departure, User)
│   └── Repositories/               # Kontrakty interfejsów (np. IStopRepository)
├── UseCases/                       # PRZYPADKI UŻYCIA (Czysta logika aplikacyjna)
│   ├── GetLiveDeparturesUseCase.cs
│   └── RegisterUserUseCase.cs
├── Infrastructure/                 # INFRASTRUKTURA (Konkretna technologia)
│   ├── Api/                        # Klient HTTP ZTM Gdańsk
│   └── Storage/                    # Baza danych SQLite (sqlite-net-pcl)
└── Presentation/                   # PREZENTACJA (MVVM / UI)
    ├── Views/                      # Ekrany XAML i komponenty (ContentView)
    └── ViewModels/                 # Modele widoku z CommunityToolkit.Mvvm

Tworzenie pierwszego komponentu (Deklaratywne UI w XAML)

Cel: Zapoznanie z deklaratywnym komponentem wielokrotnego użytku (ContentView).

Tworzymy komponent reprezentujący kartę pojedynczego odjazdu autobusu. Utwórz plik Presentation/Views/DepartureCardView.xaml:

<?xml version="1.0" encoding="utf-8" ?>
<ContentView x:Class="StopScoutMobile.Presentation.Views.DepartureCardView" x:Name="ThisCard" xmlns="[http://schemas.microsoft.com/dotnet/2021/maui](http://schemas.microsoft.com/dotnet/2021/maui)" xmlns:x="[http://schemas.microsoft.com/winfx/2009/xaml](http://schemas.microsoft.com/winfx/2009/xaml)">

    <Frame BackgroundColor="White" BorderColor="#E5E7EB" CornerRadius="8" HasShadow="True" Margin="0,0,0,12" Padding="16">
        
        <Grid ColumnDefinitions="50, *" ColumnSpacing="12" VerticalOptions="Center">
            <!-- Badge numeru linii -->
            <Border BackgroundColor="#DBEAFE" Grid.Column="0" HeightRequest="40" HorizontalOptions="Center" Stroke="#BFDBFE" StrokeShape="RoundRectangle 20" VerticalOptions="Center" WidthRequest="40">
                <Label FontAttributes="Bold" FontSize="16" HorizontalOptions="Center" Text="{Binding Source={x:Reference ThisCard}, Path=RouteId}" TextColor="#1D4ED8" VerticalOptions="Center"/>
            </Border>

            <!-- Szczegóły kierunku i czasu -->
            <VerticalStackLayout Grid.Column="1" VerticalOptions="Center">
                <Label FontAttributes="Bold" FontSize="16" Text="{Binding Source={x:Reference ThisCard}, Path=Headsign}" TextColor="#374151"/>
                <HorizontalStackLayout Margin="0,4,0,0" Spacing="6">
                    <Label FontSize="12" Text="{Binding Source={x:Reference ThisCard}, Path=TheoreticalTime, StringFormat='Planowo: {0}'}" TextColor="#6B7280"/>
                    <Label FontAttributes="Bold" FontSize="12" IsVisible="{Binding Source={x:Reference ThisCard}, Path=IsDelayed}" Text="{Binding Source={x:Reference ThisCard}, Path=DelayText}" TextColor="#DC2626"/>
                </HorizontalStackLayout>
            </VerticalStackLayout>
        </Grid>
    </Frame>
</ContentView>

W pliku code-behind Presentation/Views/DepartureCardView.xaml.cs definiujemy właściwości wiązane (BindableProperty):

namespace StopScoutMobile.Presentation.Views;

public partial class DepartureCardView : ContentView
{
    public static readonly BindableProperty RouteIdProperty =
        BindableProperty.Create(nameof(RouteId), typeof(string), typeof(DepartureCardView), string.Empty);

    public static readonly BindableProperty HeadsignProperty =
        BindableProperty.Create(nameof(Headsign), typeof(string), typeof(DepartureCardView), string.Empty);

    public static readonly BindableProperty TheoreticalTimeProperty =
        BindableProperty.Create(nameof(TheoreticalTime), typeof(string), typeof(DepartureCardView), string.Empty);

    public static readonly BindableProperty DelayInSecondsProperty =
        BindableProperty.Create(nameof(DelayInSeconds), typeof(int?), typeof(DepartureCardView), null,
            propertyChanged: OnDelayChanged);

    public string RouteId
    {
        get => (string)GetValue(RouteIdProperty);
        set => SetValue(RouteIdProperty, value);
    }

    public string Headsign
    {
        get => (string)GetValue(HeadsignProperty);
        set => SetValue(HeadsignProperty, value);
    }

    public string TheoreticalTime
    {
        get => (string)GetValue(TheoreticalTimeProperty);
        set => SetValue(TheoreticalTimeProperty, value);
    }

    public int? DelayInSeconds
    {
        get => (int?)GetValue(DelayInSecondsProperty);
        set => SetValue(DelayInSecondsProperty, value);
    }

    public bool IsDelayed => DelayInSeconds.HasValue && DelayInSeconds.Value > 0;
    public string DelayText => DelayInSeconds.HasValue ? $"(+{DelayInSeconds.Value}s)" : string.Empty;

    public DepartureCardView()
    {
        InitializeComponent();
    }

    private static void OnDelayChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var card = (DepartureCardView)bindable;
        card.OnPropertyChanged(nameof(IsDelayed));
        card.OnPropertyChanged(nameof(DelayText));
    }
}

Stan Aplikacji i Renderowanie List (CollectionView & MVVM)

Cel: Zastosowanie wzorca Model-View-ViewModel z biblioteką CommunityToolkit.Mvvm oraz wydajnej wirtualizacji listy (CollectionView).

Zainstaluj pakiet ułatwiający implementację MVVM:

dotnet add package CommunityToolkit.Mvvm

1. Model Danych (Domain/Entities/DepartureItem.cs):

namespace StopScoutMobile.Domain.Entities;

public record DepartureItem(string Id, string RouteId, string Headsign, string TheoreticalTime, int? DelayInSeconds);

2. Model Widoku (Presentation/ViewModels/HomeViewModel.cs):

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using StopScoutMobile.Domain.Entities;

namespace StopScoutMobile.Presentation.ViewModels;

public partial class HomeViewModel : ObservableObject
{
    [ObservableProperty]
    private ObservableCollection<DepartureItem> departures = new();

    public HomeViewModel()
    {
        LoadMockDepartures();
    }

    private void LoadMockDepartures()
    {
        Departures = new ObservableCollection<DepartureItem>
        {
            new("1", "9", "Strzyża PKM", "19:09", 12),
            new("2", "2", "Jelitkowo", "19:15", 0),
            new("3", "12", "Ujeścisko", "19:22", 150),
            new("4", "6", "Łostowice Świętokrzyska", "19:27", null),
            new("5", "8", "Stogi Plaża", "19:31", 45),
            new("6", "10", "Brętowo PKM", "19:36", 210),
            new("7", "11", "Lawendowe Wzgórze", "19:42", 0),
            new("8", "5", "Oliwa", "19:48", 85),
            new("9", "199", "Wały Piastowskie", "19:53", 130)
        };
    }
}

3. Widok Główny (MainPage.xaml): Zastąp dotychczasową zawartość MainPage.xaml:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage BackgroundColor="#F9FAFB" x:Class="StopScoutMobile.MainPage" xmlns="[http://schemas.microsoft.com/dotnet/2021/maui](http://schemas.microsoft.com/dotnet/2021/maui)" xmlns:views="clr-namespace:StopScoutMobile.Presentation.Views" xmlns:vm="clr-namespace:StopScoutMobile.Presentation.ViewModels" xmlns:x="[http://schemas.microsoft.com/winfx/2009/xaml](http://schemas.microsoft.com/winfx/2009/xaml)">

    <ContentPage.BindingContext>
        <vm:HomeViewModel />
    </ContentPage.BindingContext>

    <Grid Padding="16" RowDefinitions="Auto, *">
        <Label FontAttributes="Bold" FontSize="20" Grid.Row="0" Margin="0,0,0,16" Text="Najbliższe odjazdy (Mock)" TextColor="#1F2937"/>

        <!-- CollectionView z recyklingiem widoków -->
        <CollectionView Grid.Row="1" ItemSizingStrategy="MeasureFirstItem" ItemsSource="{Binding Departures}">
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <views:DepartureCardView RouteId="{Binding RouteId}"
                                            Headsign="{Binding Headsign}"
                                            TheoreticalTime="{Binding TheoreticalTime}"
                                            DelayInSeconds="{Binding DelayInSeconds}" />
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </Grid>
</ContentPage>

Integracja Map (WebView + Leaflet OpenStreetMap)

Cel: Wizualizacja przestrzenna w środowisku wieloplatformowym bez konieczności rejestrowania komercyjnych kluczy API Google Cloud.

Kontrolka WebView w .NET MAUI pozwala na renderowanie kodu HTML z biblioteką Leaflet. Tworzymy nowy widok Presentation/Views/MapPage.xaml:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage Title="Mapa Przystanków" x:Class="StopScoutMobile.Presentation.Views.MapPage" xmlns="[http://schemas.microsoft.com/dotnet/2021/maui](http://schemas.microsoft.com/dotnet/2021/maui)" xmlns:x="[http://schemas.microsoft.com/winfx/2009/xaml](http://schemas.microsoft.com/winfx/2009/xaml)">

    <WebView HorizontalOptions="Fill" VerticalOptions="Fill" x:Name="MapWebView"/>
</ContentPage>

W pliku code-behind Presentation/Views/MapPage.xaml.cs generujemy podkład OpenStreetMap:

namespace StopScoutMobile.Presentation.Views;

public partial class MapPage : ContentPage
{
    public MapPage()
    {
        InitializeComponent();
        LoadLeafletMap();
    }

    private void LoadLeafletMap()
    {
        var htmlSource = new HtmlWebViewSource
        {
            Html = @"
            <!DOCTYPE html>
            <html>
            <head>
                <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' />
                <link rel='stylesheet' href='[https://unpkg.com/leaflet@1.9.4/dist/leaflet.css](https://unpkg.com/leaflet@1.9.4/dist/leaflet.css)' />
                <script src='[https://unpkg.com/leaflet@1.9.4/dist/leaflet.js](https://unpkg.com/leaflet@1.9.4/dist/leaflet.js)'></script>
                <style>
                    body, html, #map { margin: 0; padding: 0; height: 100%; width: 100%; }
                </style>
            </head>
            <body>
                <div id='map'></div>
                <script>
                    var map = L.map('map').setView([54.352, 18.646], 14);
                    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                        maxZoom: 19,
                        attribution: '© OpenStreetMap'
                    }).addTo(map);

                    var marker = L.marker([54.352, 18.646]).addTo(map);
                    marker.bindPopup('<b>Dworzec Główny</b><br>Węzeł przesiadkowy').openPopup();
                </script>
            </body>
            </html>"
        };

        MapWebView.Source = htmlSource;
    }
}

Konfiguracja zakładek (AppShell.xaml):
Skonfiguruj pasek dolnych zakładek w pliku AppShell.xaml:

<Shell x:Class="StopScoutMobile.AppShell" xmlns="[http://schemas.microsoft.com/dotnet/2021/maui](http://schemas.microsoft.com/dotnet/2021/maui)" xmlns:local="clr-namespace:StopScoutMobile" xmlns:views="clr-namespace:StopScoutMobile.Presentation.Views" xmlns:x="[http://schemas.microsoft.com/winfx/2009/xaml](http://schemas.microsoft.com/winfx/2009/xaml)">

    <TabBar>
        <Tab Icon="home_icon.png" Title="Odjazdy">
            <ShellContent ContentTemplate="{DataTemplate local:MainPage}"/>
        </Tab>
        <Tab Icon="map_icon.png" Title="Mapa">
            <ShellContent ContentTemplate="{DataTemplate views:MapPage}"/>
        </Tab>
    </TabBar>
</Shell>

Zadanie Laboratoryjne do samodzielnego wykonania

Celem zadania jest stworzenie kompletnej aplikacji mobilnej "Stop Scout" w środowisku .NET MAUI służącej do monitorowania rzeczywistych czasów przyjazdów pojazdów komunikacji miejskiej w Gdańsku w oparciu o Otwarte Dane ZTM Gdańsk.

Aplikacja musi zapewniać pełną funkcjonalność w warunkach terenowych (brak sieci) dzięki wykorzystaniu lokalnej bazy danych SQLite.

1. Uwierzytelnianie i lokalna baza danych (Offline-First)

💡 Wskazówki implementacyjne: Clean Architecture, SQLite i BCrypt (Kliknij, aby rozwinąć/zwinąć)
  1. Instalacja pakietów NuGet:

    dotnet add package sqlite-net-pcl
    dotnet add package SQLitePCLRaw.bundle_green
    dotnet add package BCrypt.Net-Next
  2. Warstwa Domeny (Domain/):

    • Encja użytkownika (Domain/Entities/User.cs):
      using SQLite;
      
      namespace StopScoutMobile.Domain.Entities;
      
      [Table("Users")]
      public class User
      {
          [PrimaryKey]
          public string Id { get; set; } = Guid.NewGuid().ToString();
          [Unique, Indexed]
          public string Username { get; set; } = string.Empty;
          public string PasswordHash { get; set; } = string.Empty;
      }
    • Interfejs repozytorium (Domain/Repositories/IAuthRepository.cs):
      public interface IAuthRepository
      {
          Task<User?> FindByUsernameAsync(string username);
          Task CreateUserAsync(User user);
      }
  3. Warstwa Infrastruktury (Infrastructure/Storage/SqliteAuthRepository.cs): Wykorzystaj asynchroniczne API bazy danych:

    using SQLite;
    using StopScoutMobile.Domain.Entities;
    using StopScoutMobile.Domain.Repositories;
    
    namespace StopScoutMobile.Infrastructure.Storage;
    
    public class SqliteAuthRepository : IAuthRepository
    {
        private readonly SQLiteAsyncConnection _database;
    
        public SqliteAuthRepository()
        {
            var dbPath = Path.Combine(FileSystem.AppDataDirectory, "stopscout.db3");
            _database = new SQLiteAsyncConnection(dbPath);
            _database.CreateTableAsync<User>().Wait();
        }
    
        public Task<User?> FindByUsernameAsync(string username) =>
            _database.Table<User>().Where(u => u.Username == username).FirstOrDefaultAsync();
    
        public Task CreateUserAsync(User user) => _database.InsertAsync(user);
    }
  4. Warstwa Przypadków Użycia (UseCases/):

    • RegisterUserUseCase.cs: Sprawdza unikalność nazwy użytkownika, hashuje hasło za pomocą BCrypt.Net.BCrypt.HashPassword(password) i zapisuje encję w repozytorium.
    • LoginUserUseCase.cs: Pobiera użytkownika i weryfikuje skrót: BCrypt.Net.BCrypt.Verify(password, user.PasswordHash).
  5. Nawigacja po zalogowaniu (Shell): Po udanym logowaniu przejdź do widoku głównego:

    await Shell.Current.GoToAsync("//MainPage");

2. Pobieranie danych z API ZTM i zarządzanie stanem

💡 Wskazówki implementacyjne: HttpClient, buforowanie i Optimistic UI (Kliknij, aby rozwinąć/zwinąć)
  1. Rejestracja HttpClient w MauiProgram.cs:

    builder.Services.AddHttpClient("ZtmClient", client =>
    {
        client.Timeout = TimeSpan.FromSeconds(10);
    });
  2. Warstwa Przypadków Użycia (UseCases/GetLiveDeparturesUseCase.cs): Odfiltrowanie odjazdów starszych niż 2 minuty w C#:

    var now = DateTime.Now.TimeOfDay;
    var filtered = departures.Where(dep =>
    {
        var timeStr = dep.EstimatedTime ?? dep.TheoreticalTime;
        if (!TimeSpan.TryParse(timeStr, out var depTime)) return true;
    
        var diff = (depTime - now).TotalMinutes;
        return diff >= -2.0;
    }).ToList();
  3. Wzorzec Optimistic UI w ViewModelu:

    [RelayCommand]
    public async Task AddFavoriteOptimisticAsync(Stop stop)
    {
        // 1. Natychmiastowa aktualizacja interfejsu
        FavoriteStops.Add(stop);
    
        try
        {
            // 2. Asynchroniczny zapis do SQLite w tle
            await _stopRepository.SaveFavoriteAsync(stop);
        }
        catch (Exception ex)
        {
            // 3. Rollback w przypadku błędu
            FavoriteStops.Remove(stop);
            await Shell.Current.DisplayAlert("Błąd", "Nie udało się zapisać przystanku", "OK");
        }
    }

3. Komponent wyróżniania opóźnień (DelayHighlightView)

💡 Wskazówki implementacyjne: Logika domenowa i animacje MAUI (Kliknij, aby rozwinąć/zwinąć)
  1. Warstwa Domeny (Domain/Utils/DelayFormatter.cs): Czysta, w pełni testowalna logika bez zależności od MAUI:

    namespace StopScoutMobile.Domain.Utils;
    
    public enum DelaySeverity { OnTime, Minor, Major }
    
    public record DelayInfo(DelaySeverity Severity, string FormattedText, bool ShouldAnimate);
    
    public static class DelayFormatter
    {
        public static DelayInfo Format(int? delayInSeconds)
        {
            var delay = delayInSeconds ?? 0;
    
            if (delay <= 0)
                return new DelayInfo(DelaySeverity.OnTime, "Na czas", false);
    
            if (delay < 120)
                return new DelayInfo(DelaySeverity.Minor, $"+{delay}s", false);
    
            var minutes = delay / 60;
            return new DelayInfo(DelaySeverity.Major, $"+{minutes} min", true);
        }
    }
  2. Animacja pulsowania w Code-Behind komponentu: Uruchomienie asynchronicznej pętli animacji MAUI z tokenem anulowania (CancellationToken):

    private CancellationTokenSource? _cts;
    
    private void ApplyDelay(int? delay)
    {
        _cts?.Cancel();
        var info = DelayFormatter.Format(delay);
    
        DelayLabel.Text = info.FormattedText;
        DelayLabel.TextColor = info.Severity switch
        {
            DelaySeverity.OnTime => Color.FromArgb("#16A34A"),
            DelaySeverity.Minor => Color.FromArgb("#D97706"),
            DelaySeverity.Major => Color.FromArgb("#DC2626"),
            _ => Colors.Gray
        };
    
        if (info.ShouldAnimate)
        {
            _cts = new CancellationTokenSource();
            _ = StartPulsingAsync(_cts.Token);
        }
        else
        {
            DelayLabel.Opacity = 1.0;
        }
    }
    
    private async Task StartPulsingAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            await DelayLabel.FadeTo(0.2, 700, Easing.SinInOut);
            await DelayLabel.FadeTo(1.0, 700, Easing.SinInOut);
        }
    }

4. Interaktywna Mapa Przystanków

💡 Wskazówki implementacyjne: Komunikacja JavaScript-to-C# przez WebView (Kliknij, aby rozwinąć/zwinąć)
  1. Optymalizacja zapytania przestrzennego w SQLite:

    public Task<List<Stop>> GetVisibleStopsAsync(double minLat, double maxLat, double minLon, double maxLon) =>
        _database.Table<Stop>()
                 .Where(s => s.Lat >= minLat && s.Lat <= maxLat && s.Lon >= minLon && s.Lon <= maxLon)
                 .ToListAsync();
  2. Odbiór zdarzenia kliknięcia pinezki w .NET MAUI: W silniku Leaflet wewnątrz WebView użyj przekierowania URL (schemat Custom URL):

    function handleAddFavorite(stopId, name, lat, lon) {
        window.location.href = 'stopscout://favorite?stopId=' + stopId + '&name=' + encodeURIComponent(name);
    }

    W C# obsłuż zdarzenie Navigating kontrolki WebView:

    private void OnWebViewNavigating(object sender, WebNavigatingEventArgs e)
    {
        if (e.Url.StartsWith("stopscout://favorite"))
        {
            e.Cancel = true; // Zablokuj faktyczną nawigację przeglądarki
            var uri = new Uri(e.Url);
            var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
    
            var stopId = int.Parse(query["stopId"]!);
            var name = query["name"]!;
    
            // Wywołanie akcji w ViewModelu / UseCase
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await _viewModel.AddFavoriteByIdAsync(stopId, name);
            });
        }
    }

Kryteria oceny projektu (100 pkt)

Moduł Wymagania Punkty
1. Autoryzacja i SQLite Rejestracja, hashowanie hasła (BCrypt.Net-Next), logowanie, buforowanie danych offline w relacyjnej bazie sqlite-net-pcl. 25 pkt
2. API ZTM & MVVM Pobieranie przystanków i odjazdów z gdańskiego API, filtrowanie odjazdów starszych niż 2 min, stan w ViewModelu z obsługą Optimistic UI. 25 pkt
3. Komponent DelayHighlightView Poprawna kategoryzacja opóźnień, cykliczna animacja przezroczystości (FadeTo), separacja czystej domeny. 20 pkt
4. Interaktywna Mapa Wyświetlanie mapy, pinezki renderowane po przybliżeniu (bounding box), dymek z akcją dodawania do ulubionych przez mostek WebView. 20 pkt
5. Architektura i Kod Podział na warstwy Domain, UseCases, Infrastructure, Presentation, Dependency Injection, brak błędów w kompilacji. 10 pkt