Zaawansowane projektowanie aplikacji mobilnych

Rok akademicki: 2026/27

Projekt semestralny: Framework React Native, ekosystem Expo i projekt "Stop Scout"

Prowadzący: Tomasz Goluch Wersja: 1.2


Wprowadzenie

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

Laboratorium odbywa się w trybie stacjonarnym lub zdalnym na maszynach fizycznych (komputery laboratoryjne lub własne laptopy) oraz własnych smartfonach (fizyczny telefon z systemem Android lub iOS). Kod tworzymy z wykorzystaniem preferowanego edytora lokalnego (np. Visual Studio Code, WebStorm). W przeciwieństwie do klasycznego programowania natywnego, wykorzystamy ekosystem Expo, który pozwala na natychmiastowe uruchamianie i testowanie kodu bezpośrednio na Twoim telefonie (tzw. mechanizm Over-The-Air).

💻 Wskazówka dla zajęć zdalnych (Prezentacja ekranu telefonu):
Podczas zajęć zdalnych i odbiorów projektów wymagane jest udostępnianie całego ekranu komputera. Aby wygodnie prezentować aplikację działającą na fizycznym telefonie bez konieczności trzymania go przed kamerą internetową, zaleca się skorzystanie z narzędzia scrcpy (klonowanie ekranu Androida przez kabel USB z zerowym opóźnieniem) lub emulatora Android Studio / symulatora iOS.

📱 Instrukcja krok po kroku: Instalacja i uruchomienie scrcpy (Kliknij, aby rozwinąć/zwinąć)

Program scrcpy pozwala wyświetlić ekran Twojego telefonu w oknie na komputerze oraz sterować nim za pomocą myszy i klawiatury. Narzędzie nie wymaga uprawnień roota ani instalowania żadnych aplikacji na smartfonie.

Krok 1: Instalacja programu w systemie Windows

Otwórz PowerShell lub terminal w VS Code i wykonaj polecenie menedżera pakietów Windows:

winget install Genymobile.scrcpy

Gdy instalator zapyta o akceptację warunków licencji, zatwierdź wpisując Y.

💡 Rozwiązywanie problemów z winget:
Jeśli terminal zwróci komunikat No package found matching input criteria, odśwież bazę pakietów komendą:

winget source update
winget install --id Genymobile.scrcpy -e

Alternatywa manualna: Zawsze możesz pobrać gotową paczkę ZIP bezpośrednio z oficjalnego wydania: GitHub Genymobile/scrcpy Releases (pobierz plik scrcpy-win64-*.zip i wypakuj go na dysk).

Krok 2: Odblokowanie debugowania USB w telefonie (Android)

Komputer może komunikować się ze smartfonem tylko wtedy, gdy włączony jest tryb programisty:

  1. Na telefonie wejdź w Ustawienia → Informacje o telefonie → Informacje o oprogramowaniu.
  2. Kliknij 7 razy z rzędu w pozycję „Numer kompilacji” (Build number), aż pojawi się komunikat informujący, że jesteś programistą.
  3. Wróć do menu głównego Ustawień i przejdź do nowej sekcji Opcje programisty (Developer options).
  4. Znajdź i włącz przełącznik Debugowanie USB (USB debugging).

Krok 3: Połączenie i uruchomienie podglądu

  1. Podłącz telefon do komputera kablem USB.
  2. Na ekranie smartfona pojawi się komunikat: „Zezwalać na debugowanie USB?”. Zaznacz opcję „Zawsze zezwalaj z tego komputera” i stuknij Zezwól.
  3. W terminalu na komputerze (lub w nowo otwartym oknie PowerShell) wpisz:
scrcpy

Na pulpicie komputera pojawi się okno z płynnym podglądem ekranu Twojego smartfona.

Przydatne parametry (opcjonalnie):


Instalacja środowiska i uruchomienie projektu

Cel: Zapoznanie z procesem generowania i uruchamiania projektu React Native przy użyciu Expo CLI.

⚠️ 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.

Aby rozpocząć pracę z frameworkiem, wymagane jest środowisko wykonawcze Node.js. Zgodnie z dobrymi praktykami, do jego instalacji zaleca się wykorzystanie menedżera NVM (Node Version Manager).

Dla systemu Windows instalator (plik nvm-setup.exe) można pobrać z: nvm-windows releases.

Po instalacji NVM, otwórz terminal (np. PowerShell lub terminal w VS Code) i wykonaj poniższe komendy (uruchom terminal jako administrator), aby zainstalować i aktywować najnowszą stabilną wersję Node.js (LTS):

nvm install lts
nvm use lts

Weryfikacja instalacji: Aby sprawdzić, czy środowisko zostało zainstalowane poprawnie, wpisz w terminalu:

node -v
npm -v

Jeśli instalacja przebiegła pomyślnie, w konsoli powinny wyświetlić się numery zainstalowanych wersji (np. v20.x.x).

Mając gotowe środowisko, wygenerujemy strukturę projektu korzystając z oficjalnego narzędzia Expo. Tworzymy aplikację z obsługą języka TypeScript oraz nowoczesnym routingiem opartym na plikach (Expo Router). W konsoli wykonujemy polecenie:

npx create-expo-app@latest stop-scout-mobile --template tabs
💡 Rozwiązywanie problemów: Zawieszenie Bundlera lub zjawisko "Aplikacji-Zombie" (Kliknij, aby rozwinąć/zwinąć)
Jeśli napotkasz jeden z poniższych problemów, oznacza to, że telefon lub serwer zablokował się na starej pamięci podręcznej (cache):

Sposoby rozwiązania:

  1. Wymuś zatrzymanie aplikacji na telefonie:
    • Zamknij aplikację Expo Go.
    • W telefonie wejdź w Ustawienia → Aplikacje → Expo Go.
    • Wybierz opcję Wymuś zatrzymanie (Force stop).
    • Przejdź do sekcji Pamięć / Domyślna pamięć i kliknij Wyczyść pamięć podręczną (Clear cache).
  2. Wyczyszczenie cache Metro Bundlera na komputerze:
    • Zatrzymaj serwer deweloperski w terminalu (Ctrl + C).
    • Uruchom serwer z flagą czyszczenia pamięci podręcznej:
      npx expo start -c
    • Zeskanuj kod QR ponownie w aplikacji Expo Go.
  3. Alternatywa (gdy sieć lokalna blokuje transfer WebSocket):
    • Jeśli powyższe kroki nie pomogą, router może izolować klientów (tzw. AP Isolation). Uruchom serwer w trybie tunelu:
      npx expo start --tunnel
      (Przy pierwszym uruchomieniu zatwierdź instalację pakietu tunnelera wpisując y).

Po pomyślnym wygenerowaniu projektu przechodzimy do nowo utworzonego katalogu:

cd stop-scout-mobile
⚠️ Kontrola wersji (Git) i higiena repozytorium: Weryfikacja pliku .gitignore (Kliknij, aby rozwinąć/zwinąć)

Narzędzie create-expo-app domyślnie generuje poprawny plik .gitignore, jednak przed wykonaniem pierwszego commita warto upewnić się, że katalog node_modules/ ani pliki instalacyjne nie trafią do repozytorium (katalog node_modules/ zawiera dziesiątki tysięcy plików i waży setki megabajtów – pod żadnym pozorem nie wolno commitować go do Gita!).


Zalecana zawartość pliku .gitignore:

# Zależności i pakiety
node_modules/

# Pamięć podręczna Expo i Metro
.expo/
dist/
web-build/

# Lokalne artefakty budowania natywnego i EAS
*.apk
*.aab
.eas/
android/app/build/
android/.gradle/

# Pliki środowiskowe i klucze
.env
.env*.local

# Pliki systemowe i edytorów
.DS_Store
Thumbs.db
.vscode/
.idea/

Weryfikacja przed commitem (Sanity check):
Wpisz w terminalu:

git status

Jeśli na liście nieśledzonych plików (Untracked files) widzisz katalog node_modules/, .expo/ lub pliki instalacyjne .apk – uzupełnij plik .gitignore.

Co zrobić, jeśli omyłkowo dodałeś node_modules do Gita?
Wykonaj polecenie usuwające pliki ze śledzenia bez fizycznego kasowania ich z dysku:

git rm -r --cached node_modules
git commit -m "fix: usunięcie node_modules ze śledzenia Gita"

Uruchomienie serwera deweloperskiego (Metro Bundler)

Będąc w katalogu projektu, uruchamiamy serwer deweloperski:

npm run start

Po uruchomieniu w terminalu wyświetli się kod QR. Aby podejrzeć aplikację:

  1. Pobierz aplikację Expo Go ze sklepu Google Play (Android) lub App Store (iOS).
  2. Zeskanuj kod QR (na iOS użyj systemowej aplikacji Aparat, na Androidzie użyj skanera wbudowanego w aplikację Expo Go).

Widok konsoli z kodem QR i logiem budowania pakietu

🧠 Co dzieje się pod maską po zeskanowaniu kodu? Zwróć uwagę na ostatnią linijkę na powyższym zrzucie (Android Bundled 7163ms...). Nie pojawi się ona od razu po uruchomieniu serwera – w konsoli zobaczysz początkowo tylko kod QR i adresy sieciowe. Ta linijka (oznaczona na zielono) zostanie dopisana na samym dole dopiero wtedy, gdy Twój smartfon po zeskanowaniu kodu nawiąże połączenie przez Wi-Fi i poprosi serwer o pobranie aplikacji.

W tym momencie:

💡 Rozwiązywanie problemów: Błąd Project is incompatible with this version of Expo Go (Kliknij, aby rozwinąć/zwinąć)
Błąd ten oznacza niezgodność wersji SDK – wersja bibliotek Expo w nowo utworzonym projekcie jest nowsza niż wersja aplikacji Expo Go zainstalowana na Twoim smartfonie.

Sposoby rozwiązania:

  1. Aktualizacja aplikacji na smartfonie (Zalecane):

    • Otwórz sklep Google Play (Android) lub App Store (iOS).
    • Wyszukaj aplikację Expo Go i kliknij przycisk Aktualizuj.
    • Po zakończeniu aktualizacji zamknij aplikację w telefonie, upewnij się, że serwer na komputerze działa, i zeskanuj kod QR ponownie.
  2. Wyczyszczenie pamięci podręcznej Metro Bundlera:

    • Czasami po aktualizacji aplikacji serwer deweloperski pamięta stary cache. Zatrzymaj proces w terminalu (Ctrl + C) i uruchom go ponownie z flagą czyszczenia:
      npx expo start -c
  3. Obniżenie wersji SDK w projekcie (Gdy telefon nie wspiera najnowszej wersji Expo Go):

    • Jeśli Twój telefon ma starszą wersję systemu operacyjnego i sklep nie pozwala zainstalować najnowszego Expo Go, sprawdź na dolnym pasku aplikacji Expo Go, jakie wersje SDK obsługuje (np. SDK 51).
    • Następnie w terminalu projektu dostosuj zależności do tej wersji (np. dla SDK 51):
      npx expo install expo@^51.0.0 --fix
    • Po zakończeniu instalacji uruchom ponownie serwer poleceniem npm run start.

Rekomendowane rozszerzenia IDE

Dla zapewnienia wygody pracy oraz wczesnego wykrywania błędów składniowych zaleca się instalację poniższych rozszerzeń w Visual Studio Code:

Dla użytkowników JetBrains WebStorm: Środowisko posiada natywną obsługę React Native i Expo – wystarczy upewnić się, że włączona jest wtyczka React Native Console.


Przygotowanie projektu do wysyłki na Moodle: Procedura "Clean" i odzyskiwanie zależności

Zgodnie z zasadami zaliczenia, na platformę Moodle przesyłane jest wyłącznie archiwum ZIP z czystym kodem źródłowym oraz zrzutami ekranu. W paczce ZIP pod żadnym pozorem nie może znaleźć się katalog node_modules/ ani folder pamięci podręcznej .expo/.


1. Dlaczego usuwamy node_modules?

Folder node_modules zawiera pobrane biblioteki zewnętrzne. Wszystkie wymagane zależności są precyzyjnie opisane w plikach package.json oraz package-lock.json. Prowadzący po pobraniu archiwum może w pełni odtworzyć środowisko w kilkadziesiąt sekund za pomocą jednej komendy.


2. Czyszczenie projektu przed spakowaniem (CLI)

Przed spakowaniem projektu zatrzymaj serwer deweloperski skrótem Ctrl + C. Następnie w terminalu, w głównym katalogu projektu, wykonaj polecenie odpowiadające Twojemu systemowi operacyjnemu:

PowerShell (Windows / terminal wbudowany w VS Code):

Remove-Item -Recurse -Force node_modules, .expo

Klasyczny Wiersz poleceń CMD (Windows):

rmdir /s /q node_modules
rmdir /s /q .expo

Bash / Zsh (macOS / Linux):

rm -rf node_modules .expo

Po wykonaniu operacji projekt zmniejszy swoją objętość z kilkuset megabajtów do zaledwie 1–2 MB.


3. Zawartość archiwum ZIP

Do przesyłanej paczki dołącz:

  1. Oczyszczony folder projektu – upewnij się, że zawiera pliki konfiguracyjne (package.json, app.json, tsconfig.json) oraz katalogi źródłowe (app/, components/, src/).
  2. Katalog ze zrzutami ekranu (screenshotami) – dokumentujący kluczowe widoki poprawnie działającej aplikacji na fizycznym smartfonie.
  3. Archiwum nazwij według wzoru: Etap1_Imie_Nazwisko_NrIndeksu.zip.

4. Jak przywrócić działanie projektu po czyszczeniu? (npm install)

Aby przywrócić usunięte zależności (np. w celu kontynuowania pracy lub po rozpakowaniu archiwum na innym komputerze), wpisz w terminalu w katalogu projektu:

npm install

Menedżer npm odczyta plik package.json, pobierze wymagane biblioteki i odtworzy katalog node_modules. Następnie możesz ponownie uruchomić aplikację poleceniem:

npm run start

Struktura projektu, Clean Architecture oraz taktyczne DDD

Cel: Zrozumienie domyślnej struktury plików oraz jej refaktoryzacja pod kątem czystej architektury, wzorców Domain-Driven Design (Encje vs Obiekty Wartości) oraz kontenera wstrzykiwania zależności (DI).

Wygenerowany projekt z szablonu tabs posiada następującą strukturę bazową:

Aby zapewnić niezależność logiki biznesowej od interfejsu użytkownika (zgodnie z zasadami Czystej Architektury), w głównym katalogu projektu utworzymy strukturę folderu src/:

src/
├── di/                 # Kontener Wstrzykiwania Zależności (Composition Root - container.ts)
├── domain/
│   ├── entities/       # Encje domenowe posiadające unikalne ID (User, Stop, Departure)
│   ├── value-objects/  # Niemutowalne Obiekty Wartości (Coordinates, GeoBoundingBox, RouteNumber, DepartureDelay)
│   └── repositories/   # Interfejsy repozytoriów i portów domenowych ( w tym IPasswordHasher)
├── infrastructure/
│   ├── api/            # Klienci HTTP (ZtmApiClient)
│   ├── location/       # Integracja sprzętowego GPS (ExpoLocationService)
│   ├── security/       # Kryptografia i hashowanie haseł (BcryptPasswordHasher)
│   └── storage/        # Implementacje bazy SQLite (SqliteAuthRepository, SqliteStopRepository)
├── store/              # Globalny magazyn stanu Zustand (Optimistic UI)
└── use-cases/          # Przypadki użycia (czysta orkiestracja logiki aplikacyjnej)

🧠 Taktyczny Domain-Driven Design (DDD): Encja (Entity) a Obiekt Wartości (Value Object)

Jednym z najczęstszych błędów w projektach obiektowych jest tzw. Primitive Obsession (obsesja prymitywów) – reprezentowanie ważnych pojęć domenowych (np. współrzędnych GPS, numeru linii, opóźnienia) za pomocą surowych typów number lub string oraz luźnych funkcji pomocniczych (utils).

W podejściu DDD warstwę domain/ dzielimy na dwa fundamentalne bloki konstrukcyjne:

  1. Encje (src/domain/entities/):

    • Obiekty posiadające unikalną tożsamość (ID) oraz własny cykl życia, a ich atrybuty mogą zmieniać się w czasie.
    • Dwie encje są równe wtedy i tylko wtedy, gdy mają to samo id, nawet jeśli pozostałe pola się różnią.
    • Przykłady w aplikacji Stop Scout:
      • User (id, username, passwordHash) – zmiana hasła nie tworzy nowego użytkownika.
      • Stop (stopId, stopName, stopLat, stopLon, notes) – zmiana notatki lub korekta nazwy przystanku nie zmienia faktu, że to wciąż ten sam słupek przystankowy o numerze 2072.
      • Departure (id, routeId, headsign, theoreticalTime, delayInSeconds) – pojedynczy kurs pojazdu w rozkładzie.
  2. Obiekty Wartości – Value Objects (src/domain/value-objects/):

    • Obiekty pozbawione tożsamości (brak pola id), które opisują cechy, miary lub pojęcia w domenie.
    • Równość strukturalna (Value Equality): Dwa Value Objecty są sobie równe (equals()), jeśli mają identyczne wartości wszystkich pól (np. dwa obiekty Coordinates(54.352, 18.646) wskazują dokładnie ten sam punkt na kuli ziemskiej).
    • Niemutowalność (Immutability): Wszystkie właściwości są oznaczone modyfikatorem readonly. Po utworzeniu obiektu nie można zmienić jego stanu – każda operacja zwraca nową instancję.
    • Samowalidacja (Self-Validation): Konstruktor obiektu wymusza niezmienniki biznesowe (invariants). Nie da się utworzyć obiektu Coordinates z szerokością geograficzną 150° – konstruktor natychmiast rzuci wyjątek.

Przykładowa implementacja Value Objectu RouteNumber (src/domain/value-objects/RouteNumber.ts):

Zamiast rozrzucać po widokach warunki sprawdzające prefiksy z systemu TRISTAR (np. 10177 zamiast 177), zamykamy tę regułę wewnątrz klasy Value Objectu:

// src/domain/value-objects/RouteNumber.ts
export class RouteNumber {
  public readonly value: string;

  constructor(rawRouteId: number | string | null | undefined) {
    this.value = RouteNumber.normalize(rawRouteId);
  }

  private static normalize(raw: number | string | null | undefined): string {
    if (raw === null || raw === undefined) return '?';
    const trimmed = String(raw).trim();
    if (!trimmed) return '?';

    const numericVal = Number(trimmed);
    if (!Number.isNaN(numericVal) && Number.isInteger(numericVal)) {
      // Reguła domenowa TRISTAR: usunięcie prefiksu operatora (>= 10000)
      return numericVal >= 10000 ? String(numericVal % 10000) : String(numericVal);
    }

    return trimmed.toUpperCase();
  }

  public isNightLine(): boolean {
    return this.value.startsWith('N');
  }

  public equals(other: RouteNumber | null | undefined): boolean {
    if (!other) return false;
    return this.value === other.value;
  }
}

🔌 Odwrócenie Zależności (DIP) oraz Kontener DI (src/di/container.ts)

W Czystej Architekturze widoki (app/) nie mogą bezpośrednio tworzyć instancji klas infrastruktury (np. new SqliteAuthRepository()), ponieważ wiązałoby to warstwę prezentacji z konkretną technologią bazy danych. Podobnie warstwa use-cases/ nie może bezpośrednio importować bibliotek sprzętowych (np. expo-crypto czy expo-sqlite).

Aby połączyć wszystkie warstwy bez łamania granic architektonicznych, stosujemy wzorzec Composition Root (lekki kontener DI z leniwą inicjalizacją w pliku src/di/container.ts). Kontener ten:

  1. Przechowuje pojedyncze instancje (Singletony) repozytoriów i serwisów infrastruktury.
  2. Automatycznie wstrzykuje je do konstruktorów Przypadków Użycia (Constructor Injection).
  3. Tworzy obiekty leniwie (dopiero przy pierwszym wywołaniu getterów get), co chroni Expo Router przed błędami wczesnej ewaluacji modułów.

Tworzenie pierwszego komponentu (Deklaratywne UI)

Cel: Zapoznanie z różnicami między tagami HTML a natywnymi komponentami React Native na przykładzie danych z ZTM.

W przeciwieństwie do aplikacji webowych budowanych w React, w React Native nie używamy standardowych tagów HTML (<div>, <span>, <ul>). Zamiast list używających znaczników <ul> i <li>, stosujemy zoptymalizowane komponenty mobilne, które system operacyjny renderuje jako swoje natywne kontrolki.

Przygotowując się do implementacji aplikacji "Stop Scout", stworzymy komponent reprezentujący pojedynczy odjazd autobusu. Tworzymy nowy plik components/DepartureCard.tsx. Będzie on przyjmował właściwości (props) odpowiadające strukturze danych z API ZTM, takim jak numer linii (routeId), kierunek (headsign), czas rozkładowy (theoreticalTime) oraz opóźnienie (delayInSeconds).

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

interface DepartureCardProps {
  routeId: number | string;
  headsign: string;
  theoreticalTime: string;
  delayInSeconds: number | null;
}

export default function DepartureCard({ routeId, headsign, theoreticalTime, delayInSeconds }: DepartureCardProps) {
  const isDelayed = delayInSeconds && delayInSeconds > 0;

  return (
    <View style={styles.cardContainer}>
      <View style={styles.routeBadge}>
        <Text style={styles.routeText}>{routeId}</Text>
      </View>
      
      <View style={styles.infoContainer}>
        <Text style={styles.headsign}>{headsign}</Text>
        <Text style={styles.timeText}>
          Planowo: {theoreticalTime} 
          {isDelayed && <Text style={styles.delayText}> (+{delayInSeconds}s)</Text>}
        </Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  cardContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 16,
    backgroundColor: '#ffffff',
    borderRadius: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    elevation: 3,
    marginBottom: 12,
  },
  routeBadge: {
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: '#DBEAFE', 
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 12,
  },
  routeText: {
    color: '#1D4ED8', 
    fontWeight: 'bold',
    fontSize: 16,
  },
  infoContainer: {
    flex: 1,
  },
  headsign: {
    fontSize: 16,
    fontWeight: 'bold',
    color: '#374151',
  },
  timeText: {
    fontSize: 12,
    color: '#6B7280',
    marginTop: 4,
  },
  delayText: {
    color: '#DC2626', 
    fontWeight: 'bold',
  }
});

🧠 Anatomia komponentu: Wyjaśnienie kluczowych pojęć (JavaScript vs TypeScript & React Native)

Dla osób, które do tej pory tworzyły interfejsy w czystym JavaScript i HTML/CSS, powyższy kod wprowadza kilka kluczowych różnic:


Stan Aplikacji i Renderowanie List (FlatList)

Cel: Wykorzystanie stanu do przechowywania danych z API oraz natywnego renderowania wydajnych list.

Zamiast ręcznie mapować elementy za pomocą metody .map() w webowym kontenerze, w React Native korzystamy z komponentu FlatList. Renderuje on tylko te elementy, które są aktualnie widoczne na ekranie, co znacząco optymalizuje zużycie pamięci urządzenia mobilnego.

W pliku app/(tabs)/index.tsx (który odpowiada domyślnemu widokowi pierwszej zakładki) zastępujemy dotychczasową zawartość nowym komponentem oraz implementujemy stan useState, ładując przykładowe dane na wzór odpowiedzi z gdańskiego API ZTM:

import React, { useState } from 'react';
import { StyleSheet, View, FlatList, Text } from 'react-native';
import DepartureCard from '../../components/DepartureCard';

export default function HomeScreen() {
  const [departures] = useState([
    { id: '1', routeId: 9, headsign: 'Strzyża PKM', theoreticalTime: '19:09', delayInSeconds: 12 },
    { id: '2', routeId: 2, headsign: 'Jelitkowo', theoreticalTime: '19:15', delayInSeconds: 0 },
    { id: '3', routeId: 12, headsign: 'Ujeścisko', theoreticalTime: '19:22', delayInSeconds: 150 },
    { id: '4', routeId: 6, headsign: 'Łostowice Świętokrzyska', theoreticalTime: '19:27', delayInSeconds: null },
    { id: '5', routeId: 8, headsign: 'Stogi Plaża', theoreticalTime: '19:31', delayInSeconds: 45 },
    { id: '6', routeId: 10, headsign: 'Brętowo PKM', theoreticalTime: '19:36', delayInSeconds: 210 },
    { id: '7', routeId: 11, headsign: 'Lawendowe Wzgórze', theoreticalTime: '19:42', delayInSeconds: 0 },
    { id: '8', routeId: 5, headsign: 'Oliwa', theoreticalTime: '19:48', delayInSeconds: 85 },
    { id: '9', routeId: 199, headsign: 'Wały Piastowskie', theoreticalTime: '19:53', delayInSeconds: 130 },
  ]);

  return (
    <View style={styles.container}>
      <Text style={styles.header}>Najbliższe odjazdy (Mock)</Text>

      <FlatList
        data={departures}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <DepartureCard
            routeId={item.routeId}
            headsign={item.headsign}
            theoreticalTime={item.theoreticalTime}
            delayInSeconds={item.delayInSeconds}
          />
        )}
        contentContainerStyle={{ paddingBottom: 20 }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
    backgroundColor: '#F9FAFB',
  },
  header: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 16,
    color: '#1F2937',
  },
});

⚠️ PUŁAPKA RENDEROWANIA WARUNKOWEGO W REACT NATIVE:
W standardowym Reactcie zapis {item.notes && <Text>{item.notes}</Text>} jest powszechny. W React Native, jeśli item.notes ma wartość pustego ciągu znaków "", wyrażenie ewaluuje się do pustego stringa "". Wstawienie surowego tekstu bezpośrednio wewnątrz <View>, <TouchableOpacity> lub komórki <FlatList> powoduje natychmiastową awarię aplikacji:
Invariant Violation: Text strings must be rendered within a <Text> component.
Zasada: W elementach list zawsze stosuj rzutowanie logiczne lub operator trójargumentowy:
{Boolean(item.notes) ? <Text>{item.notes}</Text> : null}.

Oczekiwany rezultat: Po poprawnym zaimplementowaniu komponentu i podpięciu stanu, na ekranie Twojego smartfona powinien pojawić się następujący widok. Zwróć uwagę, jak natywnie wygląda systemowy cień (elevation) oraz pasek zakładek.

Widok poprawnie działającej karty reprezentującej pojedynczy odjazd autobusu


Działanie aplikacji i interakcja użytkownika

Cel: Weryfikacja działania aplikacji na urządzeniu oraz zrozumienie natywnej reaktywności.

Po przejściu dotychczasowych kroków na ekranie Twojego smartfona działa w pełni funkcjonalna aplikacja mobilna:

  1. Nawigacja dolna (Domyślny szablon tabs):

    • Na dole ekranu widoczny jest systemowy pasek zakładek (Bottom Tab Bar) z domyślnymi pozycjami Tab One oraz Tab Two.
    • Możesz przełączać się między zakładkami – animacja przejścia oraz ikony renderowane są jako natywne komponenty mobilne, a nie zwykłe odnośniki HTML.
    • Wskazówka: Nazwy zakładek, ikony oraz ich konfigurację znajdziesz w pliku app/(tabs)/_layout.tsx we właściwościach options={{ title: ... }}.
  2. Wydajność list (FlatList) i natywny wygląd:

    • Na głównym ekranie wyświetla się lista odjazdów. Spróbuj ją przewinąć – zauważysz natywną płynność działania oraz charakterystyczny dla Twojego systemu operacyjnego efekt sprężynowania (bounce) na końcu listy.
    • Nasz komponent DepartureCard ma zdefiniowany systemowy cień (elevation: 3 dla Androida). Zwróć uwagę, że system operacyjny sam dba o prawidłowe renderowanie tego efektu w przestrzeni 3D.
  3. Menu Deweloperskie (Expo Dev Menu):

    • Potrząśnij fizycznie telefonem (Shake gesture), aby wywołać podręczne menu deweloperskie Expo.
    • Z tego poziomu możesz m.in. wymusić pełny restart aplikacji (Reload), włączyć podgląd wydajności klatek na sekundę (Performance Monitor) czy sprawdzić logi błędów.
    • Spróbuj zmienić kolor tła lub tekst w pliku na komputerze i zapisz plik (Ctrl + S) – zobaczysz, że zmiany pojawią się na ekranie telefonu w ułamku sekundy (Fast Refresh).

Zaawansowane debugowanie kodu (Hermes & DevTools)

W aplikacjach React Native debugowanie nie ogranicza się wyłącznie do console.log:

  1. Wbudowany debugger Chrome DevTools (Klawisz j):

    • Gdy serwer deweloperski działa w konsoli (npm run start), naciśnij na klawiaturze literę j.
    • Metro Bundler automatycznie otworzy kartę Chrome DevTools połączoną bezpośrednio z silnikiem JavaScript (Hermes) działającym na Twoim telefonie.
    • W zakładce Sources możesz przeglądać pliki projektu w TypeScript, stawiać tradycyjne punkty wstrzymania (Breakpoints), zatrzymywać wykonanie kodu krok po kroku oraz podglądać wartości zmiennych w locie.
  2. Inspekcja komponentów na żywo (Element Inspector):

    • Potrząśnij telefonem, aby otworzyć Expo Dev Menu, a następnie stuknij Toggle Element Inspector.
    • Dotknięcie dowolnego elementu na ekranie telefonu (np. przycisku lub karty) wyświetli jego marginesy, paddingi, dokładne wymiary i wartości propsów – analogicznie do narzędzia Inspect Element w przeglądarkach.
  3. Podgląd bazy SQLite na telefonie:

    • Podczas implementacji zadań bazodanowych loguj wykonanie zapytań w konsoli deweloperskiej. W razie potrzeby zresetowania stanu bazy danych najprostszą metodą w Expo Go jest wyczyszczenie danych aplikacji w ustawieniach systemu Android/iOS lub usunięcie pliku bazy metodą SQLite.deleteDatabaseAsync('stopscout.db').

Zarządzanie stanem globalnym (Zustand)

Cel: Współdzielenie danych (ulubione przystanki, notatki) między wieloma ekranami i zakładkami bez konieczności przekazywania właściwości w głąb drzewa komponentów (prop drilling).

Gdy aplikacja rozrasta się o kolejne widoki (np. osobną zakładkę z listą odjazdów oraz zakładkę z mapą), stan lokalny useState przestaje wystarczać. W React Native do zarządzania stanem globalnym doskonale sprawdza się biblioteka Zustand – jest lekka, nie wymaga boilerplate'u ani wrapperów typu Provider.

Instalacja biblioteki w projekcie:

npm install zustand

Tworzymy magazyn stanu w pliku src/store/useStore.ts. Będzie on przechowywał dane zalogowanego użytkownika oraz listę zapisanych przystanków z operacjami CRUD:

import { create } from 'zustand';

export interface SavedStop {
  stopId: number;
  name: string;
  subName?: string;
  lat: number;
  lon: number;
  notes?: string;
}

interface AppState {
  user: string | null;
  favoriteStops: SavedStop[];
  login: (username: string) => void;
  logout: () => void;
  setFavorites: (stops: SavedStop[]) => void;
  addStop: (stop: SavedStop) => void;
  removeStop: (stopId: number) => void;
  updateStopNote: (stopId: number, note: string) => void;
}

export const useStore = create<AppState>((set) => ({
  user: null,
  favoriteStops: [],

  login: (username) => set({ user: username }),
  logout: () => set({ user: null, favoriteStops: [] }),

  setFavorites: (stops) => set({ favoriteStops: stops }),

  addStop: (stop) =>
    set((state) => ({
      favoriteStops: [...state.favoriteStops, stop],
    })),

  removeStop: (stopId) =>
    set((state) => ({
      favoriteStops: state.favoriteStops.filter((s) => s.stopId !== stopId),
    })),

  updateStopNote: (stopId, note) =>
    set((state) => ({
      favoriteStops: state.favoriteStops.map((s) =>
        s.stopId === stopId ? { ...s, notes: note } : s
      ),
    })),
}));

Zastosowanie w komponencie:
Komponenty mogą subskrybować tylko wybrane fragmenty stanu, co zapobiega zbędnemu przeładowywaniu całego drzewa widoków.

Tworzymy plik components/FavoritesCounter.tsx, który odczytuje ze store liczbę zapisanych przystanków oraz oferuje przycisk do natychmiastowego dodania testowej pozycji:

import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useStore } from '../src/store/useStore';

export function FavoritesCounter() {
  const count = useStore((state) => state.favoriteStops.length);
  const addStop = useStore((state) => state.addStop);

  const handleAddMockStop = () => {
    addStop({
      stopId: Date.now(),
      name: `Przystanek testowy #${count + 1}`,
      lat: 54.352,
      lon: 18.646,
    });
  };

  return (
    <View style={styles.counterBox}>
      <Text style={styles.counterText}>Zapisane ulubione: {count}</Text>

      <TouchableOpacity onPress={handleAddMockStop} style={styles.addButton}>
        <Text style={styles.buttonText}>+ Dodaj testowy</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  counterBox: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    backgroundColor: '#E5E7EB',
    padding: 12,
    borderRadius: 8,
    marginBottom: 16,
  },
  counterText: {
    fontWeight: 'bold',
    color: '#374151',
    fontSize: 14,
  },
  addButton: {
    backgroundColor: '#2563EB',
    paddingVertical: 6,
    paddingHorizontal: 12,
    borderRadius: 6,
  },
  buttonText: {
    color: '#ffffff',
    fontSize: 12,
    fontWeight: 'bold',
  },
});

Podpięcie do widoku głównego:
Aby przetestować działanie magazynu, zaimportuj komponent FavoritesCounter i osadź go w pliku app/(tabs)/index.tsx bezpośrednio nad listą odjazdów:

import { FavoritesCounter } from '../../components/FavoritesCounter';

// Wewnątrz HomeScreen w app/(tabs)/index.tsx:
return (
  <View style={styles.container}>
    <Text style={styles.header}>Najbliższe odjazdy (Mock)</Text>

    {/* Reaktywny licznik podpięty pod Zustand */}
    <FavoritesCounter />

    <FlatList
      data={departures}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <DepartureCard
          routeId={item.routeId}
          headsign={item.headsign}
          theoreticalTime={item.theoreticalTime}
          delayInSeconds={item.delayInSeconds}
        />
      )}
      contentContainerStyle={{ paddingBottom: 20 }}
    />
  </View>
);

🧠 Reaktywność w praktyce:
Po kliknięciu w niebieski przycisk „+ Dodaj testowy” na telefonie, liczba zapisanych przystanków w szarej belce natychmiast wzrośnie (0 -> 1 -> 2 -> 3). Stan został zaktualizowany w pamięci magazynu Zustand bez przeładowywania widoku, a sam komponent przerysował wyłącznie swój fragment ekranu.

Widok działającego licznika Zustand oraz listy odjazdów


Integracja Map (react-native-webview)

Cel: Wizualizacja danych przestrzennych (mapa, pinezki) w środowisku wieloplatformowym bez konieczności konfiguracji zewnętrznych kluczy API.

W ekosystemie mobilnym biblioteka react-native-maps wymaga na systemie Android wygenerowania komercyjnego klucza w Google Cloud Console oraz powiązania go z cyfrowym odciskiem aplikacji (certyfikat SHA-1).

Aby aplikacja działała natychmiast na każdym telefonie (Android oraz iOS) w środowisku deweloperskim Expo Go bez dodatkowej konfiguracji chmurowej, do renderowania podkładu OpenStreetMap wykorzystamy komponent WebView z silnikiem Leaflet.

Instalacja modułu WebView w projekcie:

npx expo install react-native-webview

Komponent WebView pozwala na osadzenie izolowanego kontenera renderującego widok mapy wraz z interaktywnymi znacznikami przystanków.

Uruchomienie mapy w drugiej zakładce:
Otwórz istniejący w szablonie plik app/(tabs)/two.tsx i zastąp całą jego zawartość poniższym kodem:

import React from 'react';
import { StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';

export default function MapTabScreen() {
  const mapHtml = `
    <!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" />
        <script src="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>
  `;

  return (
    <View style={styles.container}>
      <WebView
        originWhitelist={['*']}
        source={{ html: mapHtml }}
        style={styles.map}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  map: {
    width: '100%',
    height: '100%',
  },
});

🧠 Co zobaczysz na ekranie:
Po zapisaniu pliku przejdź na smartfonie do dolnego paska nawigacji i stuknij zakładkę Tab Two. Na ekranie natychmiast załaduje się płynna, kafelkowa mapa OpenStreetMap wyśrodkowana na centrum Gdańska z widoczną pinezką oraz rozwiniętym dymkiem informacyjnym. Widok w pełni obsługuje gesty dotykowe (przesuwanie i przybliżanie palcami).

Widok działającej mapy z pinezką w zakładce Tab Two


Natywny moduł GPS i uprawnienia systemowe (expo-location)

Cel: Wykorzystanie sprzętowego modułu lokalizacji smartfona (GPS/GLONASS) oraz poprawna obsługa uprawnień systemowych (Foreground Permissions).

W środowisku mobilnym bezpośredni dostęp do podzespołów urządzenia (moduł lokalizacji, aparat, biometria) wymaga odrębnej obsługi uprawnień w czasie wykonywania aplikacji (Runtime Permissions). Zanim aplikacja odpyta antenę GPS, system operacyjny (Android / iOS) musi wyświetlić natywny dialog z prośbą o zgodę użytkownika.

Instalacja oficjalnej biblioteki w projekcie:

npx expo install expo-location

Archit#### Architektura modułu lokalizacji (Clean Architecture)

Zgodnie z zasadami czystej architektury, komponenty widoku nie powinny odpytywać biblioteki expo-location bezpośrednio. Komunikację z podsystemem lokalizacji zamykamy w dedykowanym serwisie infrastruktury, implementującym interfejs domenowy.

  1. Warstwa Domeny (src/domain/):

Definiujemy Obiekt Wartości (Value Object) współrzędnych geograficznych Coordinates (który pilnuje poprawności zakresów kątowych i potrafi obliczyć dystans do innego punktu) oraz kontrakt serwisu ILocationService:

// src/domain/value-objects/Coordinates.ts
export class Coordinates {
  constructor(
    public readonly latitude: number,
    public readonly longitude: number,
    public readonly accuracy: number | null = null
  ) {
    if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
      throw new Error(`Nieprawidłowa szerokość geograficzna: ${latitude}`);
    }
    if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
      throw new Error(`Nieprawidłowa długość geograficzna: ${longitude}`);
    }
  }

  public equals(other: Coordinates | null | undefined): boolean {
    if (!other) return false;
    return this.latitude === other.latitude && this.longitude === other.longitude;
  }

  public distanceToInMeters(other: Coordinates): number {
    const R = 6371e3;
    const toRad = (deg: number) => (deg * Math.PI) / 180;
    const dLat = toRad(other.latitude - this.latitude);
    const dLon = toRad(other.longitude - this.longitude);

    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos(toRad(this.latitude)) * Math.cos(toRad(other.latitude)) * Math.sin(dLon / 2) ** 2;

    return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
  }
}
// src/domain/repositories/ILocationService.ts
import { Coordinates } from '../value-objects/Coordinates';

export interface ILocationService {
  requestPermissions(): Promise<boolean>;
  getCurrentPosition(): Promise<Coordinates null |>;
}
  1. Warstwa Infrastruktury (src/infrastructure/):

Tworzymy klasę realizującą komunikację ze sprzętem za pośrednictwem expo-location, która zwraca gotową, zwalidowaną instancję Value Objectu Coordinates:

// src/infrastructure/location/ExpoLocationService.ts
import * as Location from 'expo-location';
import { Coordinates } from '../../domain/value-objects/Coordinates';
import { ILocationService } from '../../domain/repositories/ILocationService';

export class ExpoLocationService implements ILocationService {
  async requestPermissions(): Promise<boolean> {
    const { status } = await Location.requestForegroundPermissionsAsync();
    return status === Location.PermissionStatus.GRANTED;
  }

  async getCurrentPosition(): Promise<Coordinates null |> {
    const hasPermission = await this.requestPermissions();
    if (!hasPermission) {
      console.warn('Brak uprawnień do odczytu lokalizacji urządzenia.');
      return null;
    }

    const location = await Location.getCurrentPositionAsync({
      accuracy: Location.Accuracy.Balanced,
    });

    return new Coordinates(
      location.coords.latitude,
      location.coords.longitude,
      location.coords.accuracy
    );
  }
}
  1. Warstwa Prezentacji – Integracja z mapą Leaflet w WebView (app/(tabs)/two.tsx):

Aby zaktualizować widok mapy w kontenerze WebView bez konieczności jego ponownego przeładowywania, wykorzystujemy referencję (useRef) oraz metodę injectJavaScript. Pozwala to na dynamiczne wywołanie funkcji JavaScript wewnątrz silnika Leaflet.

Zastąp zawartość pliku app/(tabs)/two.tsx poniższym kodem:

import React, { useRef, useState } from 'react';
import { StyleSheet, View, Text, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
import { WebView } from 'react-native-webview';
import { ExpoLocationService } from '../../src/infrastructure/location/ExpoLocationService';

const locationService = new ExpoLocationService();

export default function MapTabScreen() {
  const webViewRef = useRef<WebView>(null);
  const [loadingLoc, setLoadingLoc] = useState(false);

  const handleCenterOnUser = async () => {
    try {
      setLoadingLoc(true);
      const coords = await locationService.getCurrentPosition();

      if (!coords) {
        Alert.alert(
          'Wymagane uprawnienia',
          'Aplikacja potrzebuje dostępu do lokalizacji, aby wskazać Twoją pozycję na mapie.'
        );
        return;
      }

      // Wstrzyknięcie kodu JS do Leafleta: usunięcie starego markera, narysowanie nowego i wycentrowanie widoku
      const jsCode = `
        if (typeof userMarker !== 'undefined') {
          map.removeLayer(userMarker);
        }
        var userMarker = L.circleMarker([${coords.latitude}, ${coords.longitude}], {
          radius: 9,
          fillColor: '#2563EB',
          color: '#FFFFFF',
          weight: 2,
          opacity: 1,
          fillOpacity: 0.9
        }).addTo(map);
        userMarker.bindPopup("<b>Twoja lokalizacja</b>").openPopup();
        map.setView([${coords.latitude}, ${coords.longitude}], 16);
        true;
      `;

      webViewRef.current?.injectJavaScript(jsCode);
    } catch (error) {
      Alert.alert('Błąd GPS', 'Nie udało się pobrać aktualnej pozycji urządzenia.');
      console.error(error);
    } finally {
      setLoadingLoc(false);
    }
  };

  const mapHtml = `
    <!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>
  `;

  return (
    <View style="{styles.container}">
      <WebView html: mapHtml originWhitelist="{['*']}" ref="{webViewRef}" source="{{" style="{styles.map}" }}/>

      {/* Pływający przycisk lokalizacji użytkownika (FAB) */}
      <TouchableOpacity disabled="{loadingLoc}" onPress="{handleCenterOnUser}" style="{styles.fabButton}">
        {loadingLoc ? (
          <ActivityIndicator color="#FFFFFF" size="small"/>
        ) : (
          <Text style="{styles.fabText}">📍 Zlokalizuj mnie</Text>
        )}
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  map: {
    width: '100%',
    height: '100%',
  },
  fabButton: {
    position: 'absolute',
    bottom: 24,
    right: 24,
    backgroundColor: '#2563EB',
    paddingVertical: 12,
    paddingHorizontal: 16,
    borderRadius: 24,
    elevation: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 4,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  fabText: {
    color: '#FFFFFF',
    fontWeight: 'bold',
    fontSize: 14,
  },
});

🧠 Co dzieje się w systemie operacyjnym?

  1. Przy pierwszym kliknięciu przycisku „📍 Zlokalizuj mnie” Android lub iOS wyświetli systemowy komunikat autoryzacyjny (Runtime Permission).
  2. Po wyrażeniu zgody biblioteka expo-location pobiera odczyt z odbiornika GPS/modułu lokalizacji urządzenia.
  3. Metoda injectJavaScript przekazuje uzyskane współrzędne wprost do działającego kodu Leaflet, centruje widok (map.setView) i nanosi niebieski punkt w miejscu pobranych współrzędnych.

Testy jednostkowe w ekosystemie Expo (Jest & Clean Architecture)

Cel: Wykorzystanie największej zalety Clean Architecture i taktycznego DDD – możliwości błyskawicznego przetestowania Obiektów Wartości (Value Objects) i przypadków użycia bez uruchamiania aplikacji, emulatora czy telefonu.

Architektura warstwowa sprawia, że warstwy domain i use-cases są całkowicie niezależne od React Native, bazy SQLite i Expo. Dzięki temu testy jednostkowe uruchamiają się w milisekundach w środowisku Node.js:

1. Instalacja i konfiguracja silnika Jest

W terminalu projektu zainstaluj oficjalny preset Expo dla Jest:

npm install --save-dev jest jest-expo @types/jest

W pliku package.json dodaj skrypt uruchamiający oraz konfigurację presetu:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "jest": {
    "preset": "jest-expo",
    "transformIgnorePatterns": [
      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)"
    ]
  }
}

2. Test jednostkowy Obiektu Wartości (DepartureDelay.test.ts)

Klasa DepartureDelay z pliku src/domain/value-objects/DepartureDelay.ts enkapsuluje reguły progów opóźnień. Tworzymy dla niej test w src/domain/value-objects/__tests__/DepartureDelay.test.ts:

import { DepartureDelay } from '../DepartureDelay';

describe('Value Object: DepartureDelay', () => {
  it('ustawia status ON_TIME ("Na czas") bez animacji dla 0, wartości ujemnych oraz null/undefined', () => {
    const onTimeZero = new DepartureDelay(0);
    expect(onTimeZero.severity).toBe('ON_TIME');
    expect(onTimeZero.formattedText).toBe('Na czas');
    expect(onTimeZero.shouldAnimate).toBe(false);

    expect(new DepartureDelay(-30).severity).toBe('ON_TIME');
    expect(new DepartureDelay(null).severity).toBe('ON_TIME');
    expect(new DepartureDelay(undefined).severity).toBe('ON_TIME');
  });

  it('ustawia status MINOR i formatuje sekundy dla opóźnień poniżej 120s', () => {
    const minor = new DepartureDelay(90);
    expect(minor.severity).toBe('MINOR');
    expect(minor.formattedText).toBe('+90s');
    expect(minor.shouldAnimate).toBe(false);

    expect(new DepartureDelay(119).severity).toBe('MINOR');
  });

  it('ustawia status MAJOR, przelicza minuty i włącza animację dla opóźnień >= 120s', () => {
    const major = new DepartureDelay(120);
    expect(major.severity).toBe('MAJOR');
    expect(major.formattedText).toBe('+2 min');
    expect(major.shouldAnimate).toBe(true);

    // Weryfikacja zaokrąglenia w dół do pełnych minut
    expect(new DepartureDelay(175).formattedText).toBe('+2 min');
    expect(new DepartureDelay(180).formattedText).toBe('+3 min');
  });

  it('poprawnie porównuje dwa obiekty po wartości za pomocą metody equals()', () => {
    const delayA = new DepartureDelay(60);
    const delayB = new DepartureDelay(60);
    const delayC = new DepartureDelay(120);

    expect(delayA.equals(delayB)).toBe(true);
    expect(delayA.equals(delayC)).toBe(false);
  });
});

3. Test przypadku użycia z mockowaniem repozytorium i czasu (GetLiveDeparturesUseCase.test.ts)

Przypadek użycia odpytuje repozytorium i odrzuca kursy, które odjechały dawniej niż 2 minuty temu. Testujemy go w izolacji – bez faktycznych zapytań HTTP oraz ze sztucznie zamrożonym zegarem (jest.useFakeTimers()).

Tworzymy plik src/use-cases/__tests__/GetLiveDeparturesUseCase.test.ts:

import { GetLiveDeparturesUseCase } from '../GetLiveDeparturesUseCase';
import { IZtmApiRepository } from '../../domain/repositories/IZtmApiRepository';
import { Departure } from '../../domain/entities/Departure';

describe('GetLiveDeparturesUseCase', () => {
  let mockApiRepo: jest.Mocked<IZtmApiRepository>;
  let useCase: GetLiveDeparturesUseCase;

  beforeEach(() => {
    // Zamrażamy zegar na godzinie 12:00:00
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2026-10-15T12:00:00'));

    mockApiRepo = {
      fetchStopsFromApi: jest.fn(),
      fetchDepartures: jest.fn(),
    };

    useCase = new GetLiveDeparturesUseCase(mockApiRepo);
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('odrzuca kursy starsze niż 2 minuty, a zachowuje te z ostatnich 2 minut i przyszłe', async () => {
    const rawData: Departure[] = [
      { routeId: 2, headsign: 'Jelitkowo', theoreticalTime: '11:57', delayInSeconds: 0 }, // 3 min temu -> ODRZUCIĆ
      { routeId: 6, headsign: 'Łostowice', theoreticalTime: '11:58', delayInSeconds: 0 }, // 2 min temu -> ZOSTAWIĆ
      { routeId: 9, headsign: 'Strzyża', theoreticalTime: '11:59', delayInSeconds: 0 },   // 1 min temu -> ZOSTAWIĆ
      { routeId: 12, headsign: 'Migowo', theoreticalTime: '12:05', delayInSeconds: 0 },   // przyszłość -> ZOSTAWIĆ
    ];

    mockApiRepo.fetchDepartures.mockResolvedValue(rawData);

    const result = await useCase.execute(2072);

    expect(mockApiRepo.fetchDepartures).toHaveBeenCalledWith(2072);
    expect(result).toHaveLength(3);
    expect(result.map((d) => d.routeId)).toEqual([6, 9, 12]);
  });

  it('daje priorytet estimatedTime przed theoreticalTime przy sprawdzaniu reguły 2 minut', async () => {
    const rawData: Departure[] = [
      {
        routeId: 8,
        headsign: 'Stogi',
        theoreticalTime: '11:55', // Według rozkładu dawno minął
        estimatedTime: '12:03',   // Z powodu opóźnienia odjedzie za 3 minuty
        delayInSeconds: 480,
      },
    ];

    mockApiRepo.fetchDepartures.mockResolvedValue(rawData);

    const result = await useCase.execute(2072);
    expect(result).toHaveLength(1);
    expect(result[0].routeId).toBe(8);
  });
});

4. Uruchomienie testów

W terminalu wykonaj:

npm test

Kompilacja i wdrożenie aplikacji: EAS Build oraz generowanie pliku instalacyjnego APK

Cel: Zrozumienie różnicy między kontenerem deweloperskim Expo Go a samodzielną aplikacją natywną (Standalone App) oraz automatyzacja procesu kompilacji instalatora .apk przy użyciu chmury Expo Application Services (EAS).

Dotychczasowe testowanie aplikacji odbywało się wewnątrz prekompilowanego kontenera Expo Go. Choć rozwiązanie to jest optymalne na etapie szybkiego prototypowania (obsługa Hot Reload / Fast Refresh), Expo Go nie jest docelowym produktem produkcyjnym:

  1. Współdzielone środowisko uruchomieniowe: Expo Go zawiera wbudowany, stały zestaw modułów natywnych i uruchamia kod w piaskownicy (sandbox). Nie pozwala na dołączanie niestandardowych bibliotek natywnych w C++/Javie/Kotlinie, bezpośrednią modyfikację manifestu AndroidManifest.xml czy zaawansowaną integrację usług systemowych.
  2. Brak tożsamości systemowej: Aplikacja uruchamiana w Expo Go nie posiada własnego unikalnego identyfikatora (applicationId / package name, np. pl.edu.pg.stopscout), unikalnej ikony na pulpicie telefonu ani dedykowanego, natywnego ekranu powitalnego (splash screen).
  3. Wymóg środowiska deweloperskiego: Expo Go wymaga obecności aktywnego serwera deweloperskiego lub publikacji w chmurze Expo. Prawdziwa aplikacja mobilna musi zostać skompilowana do postaci samodzielnego pliku binarnego:
    • Plik .apk (Android Package): Samodzielny instalator służący do bezpośredniej instalacji na telefonie (tzw. sideloading), testów wewnętrznych lub dystrybucji poza sklepem Google Play.
    • Plik .aab (Android App Bundle): Format wymagany do publikacji w sklepie Google Play, z którego sklep dynamicznie generuje zoptymalizowane paczki APK pod kątem konkretnych architektur procesorów urządzeń użytkowników.

Do wygenerowania gotowego instalatora .apk wykorzystamy oficjalną platformę EAS (Expo Application Services), która wykonuje proces kompilacji i linkowania natywnego w chmurowych kontenerach zoptymalizowanych pod systemy Android i iOS.

1. Konfiguracja identyfikatora w app.json

System Android wymaga, aby każda aplikacja posiadała unikalny identyfikator pakietu w notacji odwróconej domeny internetowej (tzw. Reverse Domain Name Notation).

Otwórz plik app.json i upewnij się, że w sekcji expo.android zdefiniowano unikalny klucz package:

{
  "expo": {
    "name": "Stop Scout",
    "slug": "stop-scout-mobile",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/images/icon.png",
    "scheme": "stopscout",
    "userInterfaceStyle": "automatic",
    "splash": {
      "image": "./assets/images/splash-icon.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "android": {
      "package": "pl.edu.pg.stopscout",
      "adaptiveIcon": {
        "foregroundImage": "./assets/images/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      }
    }
  }
}

2. Przygotowanie narzędzia EAS CLI i profilu budowania

  1. Zainstaluj globalnie oficjalne narzędzie wiersza poleceń EAS CLI:

    npm install -g eas-cli
  2. Zaloguj się na konto w serwisie Expo (jeśli go nie posiadasz, załóż darmowe konto na expo.dev):

    eas login
  3. Zainicjalizuj konfigurację budowania w katalogu projektu:

    eas build:configure

    Polecenie utworzy w głównym folderze plik eas.json.

  4. Domyślnie profil production buduje pakiet .aab dedykowany do Google Play. Aby wygenerować instalowalny bezpośrednio plik .apk, zmodyfikuj plik eas.json, definiując profil preview z parametrem "buildType": "apk":

{
  "cli": {
    "version": ">= 12.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      }
    },
    "production": {
      "android": {
        "buildType": "app-bundle"
      }
    }
  },
  "submit": {
    "production": {}
  }
}

3. Uruchomienie kompilacji w chmurze (EAS Cloud Build)

Aby zlecić budowanie instalatora .apk, wykonaj w terminalu:

eas build -p android --profile preview

Przebieg procesu:

  1. EAS CLI zapyta o wygenerowanie nowego klucza podpisu (Android Keystore) – wybierz opcję domyślną (Generate new keystore), chmura Expo automatycznie utworzy i bezpiecznie przechowa certyfikat podpisu aplikacji.
  2. Kod projektu (z wyłączeniem node_modules i plików zdefiniowanych w .gitignore) zostanie spakowany i przesłany do chmury Expo.
  3. W terminalu pojawi się link do śledzenia postępów na żywo w przeglądarce (np. https://expo.dev/accounts/.../builds/...).
  4. Na maszynie wirtualnej w chmurze zostanie uruchomiony proces gradlew assembleRelease, kompilacja silnika Hermes oraz spakowanie instalatora APK.
  5. Po zakończeniu kompilacji (zazwyczaj 5–10 minut) w terminalu wyświetli się bezpośredni odnośnik oraz kod QR pozwalający pobrać plik .apk wprost na Twój fizyczny telefon.
💡 Alternatywa: Kompilacja lokalna bez kolejki w chmurze (Kliknij, aby rozwinąć/zwinąć)

Darmowy plan chmury Expo posiada miesięczny limit darmowych kompilacji oraz współdzieloną kolejkę oczekiwania (FIFO). Jeśli wolisz zbudować aplikację całkowicie offline na własnym komputerze:

  1. Wymagania lokalne:

    • Zainstalowane środowisko Android Studio wraz z pakietem Android SDK i narzędziami wiersza poleceń (Command-line Tools).
    • Zainstalowany Java Development Kit (JDK 17).
    • Poprawnie zdefiniowane zmienne środowiskowe ANDROID_HOME oraz JAVA_HOME.
  2. Kompilacja lokalna przez EAS CLI:

    eas build -p android --profile preview --local

    Narzędzie wykorzysta lokalne środowisko Gradle i SDK do wyprodukowania pliku .apk bezpośrednio w folderze projektu.

  3. Alternatywa natywna (expo run:android): Możesz również wygenerować natywny katalog android/ poprzez proces prebuildingu i uruchomić natywną kompilację Gradle:

    npx expo run:android --variant release

    Gotowy plik .apk znajdziesz wówczas w podkatalogu:
    android/app/build/outputs/apk/release/app-release.apk.

4. Instalacja i weryfikacja na urządzeniu

  1. Pobierz wygenerowany plik .apk na telefon z systemem Android (ze strony pobierania EAS lub przesyłając plik kablem USB).
  2. Przy instalacji system Android wyświetli monit o zgodę na instalację aplikacji z nieznanych źródeł (dla przeglądarki lub menedżera plików) – zatwierdź uprawnienie.
  3. Po zainstalowaniu na pulpicie telefonu pojawi się niezależna ikona Stop Scout.
  4. Zwróć uwagę, że aplikacja uruchamia się błyskawicznie, ma własny ekran splash i działa w 100% autonomicznie – bez konieczności uruchamiania serwera na komputerze czy obecności aplikacji Expo Go.

Zadanie Laboratoryjne do samodzielnego wykonania

Celem zadania jest stworzenie kompletnej aplikacji mobilnej "Stop Scout" 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 (niestabilne połączenie lub brak sieci) dzięki wykorzystaniu lokalnej bazy danych oraz rygorystycznej separacji warstw Clean Architecture i taktycznego DDD.

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

💡 Wskazówki implementacyjne: Port IPasswordHasher, SQLite i Kontener DI (Kliknij, aby rozwinąć/zwinąć)

W React Native każdy formularz budujemy deklaratywnie, łącząc kontrolki <TextInput> i <TouchableOpacity> z obsługą stanu (useState).

Zgodnie z zasadami Clean Architecture, ani widoki, ani warstwa use-cases/ nie mogą zależeć bezpośrednio od biblioteki bcryptjs czy natywnego modułu expo-crypto. Rozdzielamy kod pomiędzy warstwy:

  1. Instalacja wymaganych bibliotek:
npx expo install expo-sqlite expo-crypto
npm install bcryptjs
npm install --save-dev @types/bcryptjs
  1. Warstwa Domeny (src/domain/):
    • entities/User.ts – encja użytkownika (id, username, passwordHash). Każdy model umieść w osobnym pliku w katalogu entities/, aby zapobiec błędom kompilacji ts(2307).
    • repositories/IAuthRepository.ts – interfejs kontraktu bazy użytkowników (findByUsername(username: string), createUser(user: User)).
    • repositories/IPasswordHasher.ts – abstrakcyjny port kryptograficzny uniezależniający przypadki użycia od konkretnego algorytmu hashowania:
// src/domain/repositories/IPasswordHasher.ts
export interface IPasswordHasher {
  hash(plainText: string): string;
  verify(plainText: string, hash: string): boolean;
}
  1. Warstwa Infrastruktury (src/infrastructure/):
    • Adapter kryptograficzny (src/infrastructure/security/BcryptPasswordHasher.ts): Silnik Hermes w React Native nie posiada wbudowanego modułu crypto. Wywołanie bcrypt.genSaltSync bez konfiguracji zgłosi błąd: Neither WebCryptoAPI nor a crypto module is available. Zamykamy ten szczegół techniczny całkowicie wewnątrz adaptera infrastruktury:
// src/infrastructure/security/BcryptPasswordHasher.ts
import bcrypt from 'bcryptjs';
import * as Crypto from 'expo-crypto';
import { IPasswordHasher } from '../../domain/repositories/IPasswordHasher';

// Rejestracja generatora liczb losowych dla silnika Hermes wewnątrz warstwy infrastruktury
bcrypt.setRandomFallback((len: number) => {
  const array = new Uint8Array(len);
  Crypto.getRandomValues(array);
  return Array.from(array);
});

export class BcryptPasswordHasher implements IPasswordHasher {
  private readonly saltRounds = 10;

  public hash(plainText: string): string {
    const salt = bcrypt.genSaltSync(this.saltRounds);
    return bcrypt.hashSync(plainText, salt);
  }

  public verify(plainText: string, hash: string): boolean {
    return bcrypt.compareSync(plainText, hash);
  }
}

⚠️ OSTRZEŻENIE ARCHITEKTONICZNE: ZAKAZ TOP-LEVEL await
W plikach modułów bazodanowych nie wolno wykonywać instrukcji await na poziomie głównym pliku (const db = await SQLite.openDatabaseAsync(...)). W silniku Hermes blokuje to ewaluację modułu, powodując, że importujące go pliki otrzymują undefined i zgłaszają błąd: TypeError: undefined cannot be used as a constructor. Otwarcie bazy zamykamy zawsze wewnątrz metody asynchronicznej.

// src/infrastructure/storage/SqliteAuthRepository.ts
import * as SQLite from 'expo-sqlite';
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { User } from '../../domain/entities/User';

export class SqliteAuthRepository implements IAuthRepository {
  private dbPromise: Promise<SQLite.SQLiteDatabase> | null = null;

  private async getDb(): Promise<SQLite.SQLiteDatabase> {
    if (!this.dbPromise) {
      this.dbPromise = (async () => {
        const db = await SQLite.openDatabaseAsync('stopscout.db');
        await db.execAsync(`
          PRAGMA journal_mode = WAL;
          CREATE TABLE IF NOT EXISTS users (
            id TEXT PRIMARY KEY NOT NULL,
            username TEXT UNIQUE NOT NULL,
            passwordHash TEXT NOT NULL
          );
        `);
        return db;
      })();
    }
    return this.dbPromise;
  }

  async findByUsername(username: string): Promise<User null |> {
    const db = await this.getDb();
    const user = await db.getFirstAsync<User>(
      'SELECT id, username, passwordHash FROM users WHERE username = ?',
      [username]
    );
    return user || null;
  }

  async createUser(user: User): Promise<void> {
    const db = await this.getDb();
    await db.runAsync(
      'INSERT INTO users (id, username, passwordHash) VALUES (?, ?, ?)',
      [user.id, user.username, user.passwordHash]
    );
  }
}
  1. Warstwa Przypadków Użycia (src/use-cases/): Przypadki użycia przyjmują przez konstruktor wyłącznie interfejsy domenowe (IAuthRepository oraz IPasswordHasher), dzięki czemu są w 100% niezależne od frameworka i łatwe do przetestowania w Jest:
// src/use-cases/RegisterUserUseCase.ts
import { IAuthRepository } from '../domain/repositories/IAuthRepository';
import { IPasswordHasher } from '../domain/repositories/IPasswordHasher';
import { User } from '../domain/entities/User';

export class RegisterUserUseCase {
  constructor(
    private readonly authRepository: IAuthRepository,
    private readonly passwordHasher: IPasswordHasher
  ) {}

  public async execute(username: string, plainPassword: string): Promise<User> {
    const existing = await this.authRepository.findByUsername(username.trim());
    if (existing) {
      throw new Error('Użytkownik o podanym loginie już istnieje.');
    }

    const passwordHash = this.passwordHasher.hash(plainPassword);
    const newUser: User = {
      id: Date.now().toString(),
      username: username.trim(),
      passwordHash,
    };

    await this.authRepository.createUser(newUser);
    return newUser;
  }
}
  1. Kontener Wstrzykiwania Zależności – Composition Root (src/di/container.ts): Zamiast tworzyć instancje repozytoriów ręcznie w każdym widoku, spinamy zależności w jednym centralnym kontenerze wykorzystującym leniwe gettery:
// src/di/container.ts
import { IAuthRepository } from '../domain/repositories/IAuthRepository';
import { IPasswordHasher } from '../domain/repositories/IPasswordHasher';
import { SqliteAuthRepository } from '../infrastructure/storage/SqliteAuthRepository';
import { BcryptPasswordHasher } from '../infrastructure/security/BcryptPasswordHasher';
import { RegisterUserUseCase } from '../use-cases/RegisterUserUseCase';
import { LoginUserUseCase } from '../use-cases/LoginUserUseCase';

class AppContainer {
  private _authRepo?: IAuthRepository;
  private _passwordHasher?: IPasswordHasher;

  public get authRepository(): IAuthRepository {
    if (!this._authRepo) this._authRepo = new SqliteAuthRepository();
    return this._authRepo;
  }

  public get passwordHasher(): IPasswordHasher {
    if (!this._passwordHasher) this._passwordHasher = new BcryptPasswordHasher();
    return this._passwordHasher;
  }

  public get registerUserUseCase(): RegisterUserUseCase {
    return new RegisterUserUseCase(this.authRepository, this.passwordHasher);
  }

  public get loginUserUseCase(): LoginUserUseCase {
    return new LoginUserUseCase(this.authRepository, this.passwordHasher);
  }
}

export const container = new AppContainer();
  1. Warstwa Prezentacji (app/(auth)/login.tsx): Widok importuje wyłącznie container oraz magazyn useStore, wywołując odpowiedni przypadek użycia po naciśnięciu przycisku:
import { useRouter } from 'expo-router';
import { container } from '../../src/di/container';
import { useStore } from '../../src/store/useStore';

// Wewnątrz komponentu ekranu logowania:
const router = useRouter();
const login = useStore((state) => state.login);

const handleRegister = async () => {
  const user = await container.registerUserUseCase.execute(username, password);
  login(user.username);
  router.replace('/(tabs)');
};

2. Pobieranie danych z API ZTM, Value Object RouteNumber i zarządzanie stanem

💡 Wskazówki implementacyjne: Clean Architecture, buforowanie API i Optimistic UI (Kliknij, aby rozwinąć/zwinąć)

Implementacja integracji z API oraz synchronizacji stanu zgodnie z Czystą Architekturą wymaga odseparowania pobierania danych sieciowych od warstwy interfejsu użytkownika:

  1. Warstwa Domeny (src/domain/):

    • entities/Stop.ts – encja przystanku (stopId, stopName, stopDesc, stopLat, stopLon).
    • entities/Departure.ts – encja odjazdu (routeId, headsign, theoreticalTime, estimatedTime, delayInSeconds).
    • value-objects/RouteNumber.ts – Obiekt Wartości odpowiadający za normalizację oznaczenia linii i identyfikację linii nocnych (isNightLine()).
    • repositories/IStopRepository.ts – interfejs operacji na przystankach (getAllStops(): Promise<Stop[]>, saveStops(stops: Stop[]): Promise<void>, getFavorites(): Promise<SavedStop[]>, saveFavorite(stop: SavedStop): Promise<void>).
    • repositories/IZtmApiRepository.ts – interfejs zapytań HTTP (fetchStopsFromApi(): Promise<Stop[]>, fetchDepartures(stopId: number): Promise<Departure[]>).
  2. Warstwa Infrastruktury (src/infrastructure/):

    • api/ZtmApiClient.ts – implementuje IZtmApiRepository przy użyciu wbudowanego fetch lub biblioteki axios.
      • Wskazówka ZTM: Plik stops.json zwraca strukturę z kluczem bieżącej daty (np. data[Object.keys(data)[0]].stops). Należy wyciągnąć właściwą tablicę obiektów i zmapować ją na domenowe encje Stop.
    • storage/SqliteStopRepository.ts – implementuje IStopRepository, wykonując zapytania do lokalnej bazy expo-sqlite (operacje INSERT INTO stops ... w transakcji oraz odczyt w trybie offline).

    ⚠️ SPECYFIKA DANYCH ZTM GDAŃSK I SYSTEMU TRISTAR:

    1. Prefiksy operatorskie TRISTAR w polu routeId:
      W endpointcie /departures pole routeId jest technicznym identyfikatorem trasy, a nie zawsze numerem widocznym dla pasażera. Dla linii obsługiwanych przez operatorów zewnętrznych (np. linia 177) system TRISTAR dodaje prefiks pakietu przewozowego – stąd w API pojawia się numer 10177 zamiast 177.
      Wymaganie: Wykorzystaj klasę Value Objectu RouteNumber (src/domain/value-objects/RouteNumber.ts), która w konstruktorze automatycznie normalizuje identyfikatory liczbowe (num >= 10000 ? String(num % 10000) : String(num)), zachowując oznaczenia alfanumeryczne (np. linie nocne "N1").

    2. Puste nazwy w stops.json a więzy integralności SQLite:
      Plik stops.json zawiera ponad 2500 słupków (w tym techniczne i wirtualne), z których część posiada pole stopName o wartości null lub undefined. Próba zapisu takiego rekordu do kolumny zdefiniowanej jako stopName TEXT NOT NULL zakończy się błędem:
      NativeStatement.finalizeAsync rejected: NOT NULL constraint failed: stops.stopName.
      Rozwiązanie: Podczas mapowania danych z API zawsze stosuj fallback:
      stopName: s.stopName || s.stopDesc || 'Przystanek #' + s.stopId.

    3. Format czasu ISO-8601 a reguła filtrowania 2 minut:
      Endpoint /departures zwraca czasy odjazdów w formacie ISO-8601 UTC (np. "2026-10-15T14:05:00Z"), a nie "HH:mm". Naiwne parsowanie timeString.split(':') zwróci NaN, przez co warunek odrzucania starych kursów odrzuci wszystkie pozycje (efekt: permanentny napis "Brak zaplanowanych kursów").
      Rozwiązanie: W GetLiveDeparturesUseCase sprawdzaj format (obsługując zarówno pełną datę ISO-8601 przez new Date(timeString), jak i format "HH:mm"), a na kafelku odjazdu formatuj go do lokalnej godziny pasażera (HH:mm).

  3. Warstwa Przypadków Użycia (src/use-cases/):

    • SyncStopsUseCase.ts – realizuje logikę Cache-First / Offline-First:
      1. Sprawdza, czy w lokalnej bazie SQLite znajdują się już przystanki.
      2. Jeśli tabela jest pusta, pobiera świeże dane z IZtmApiRepository i zapisuje je do SQLite.
      3. Zwraca przystanki z bazy lokalnej.
    • GetLiveDeparturesUseCase.ts – pobiera listę odjazdów dla wybranego słupka, normalizuje numer linii przez RouteNumber i aplikuje regułę biznesową filtrowania:
// Przykład reguły odfiltrowywania starych odjazdów (> 2 minuty temu) obsługującej ISO-8601 oraz HH:mm:
const now = Date.now();
const validDepartures = departures.filter((dep) => {
  const timeString = dep.estimatedTime || dep.theoreticalTime;
  if (!timeString) return true;

  let depTimestamp: number;
  if (timeString.includes('T')) {
    depTimestamp = new Date(timeString).getTime();
  } else {
    const [hours, minutes] = timeString.split(':').map(Number);
    const depDate = new Date();
    depDate.setHours(hours, minutes, 0, 0);
    depTimestamp = depDate.getTime();
  }

  const diffInMinutes = (depTimestamp - now) / (1000 * 60);
  return diffInMinutes >= -2;
});
  1. Wzorzec Optimistic UI w magazynie Zustand (src/store/useStore.ts): Technika Optimistic UI polega na natychmiastowej aktualizacji stanu w pamięci RAM aplikacji (UI reaguje bez opóźnień sieciowych/dyskowych), a operację bazodanową odpala się asynchronicznie w tle. W razie wystąpienia błędu w SQLite stan jest przywracany do poprzedniej wartości:
// Szkic akcji Optimistic UI w Zustand:
addFavoriteOptimistic: async (stop: SavedStop, stopRepository: IStopRepository) => {
  const previousStops = get().favoriteStops;

  // 1. Natychmiastowa aktualizacja interfejsu użytkownika (0ms opóźnienia)
  set({ favoriteStops: [...previousStops, stop] });

  try {
    // 2. Asynchroniczny zapis do bazy SQLite w tle
    await stopRepository.saveFavorite(stop);
  } catch (error) {
    // 3. Rollback stanu w przypadku awarii zapisu w bazie danych
    set({ favoriteStops: previousStops });
    console.error('Błąd zapisu do SQLite, cofnięto stan UI:', error);
  }
}

Pamiętaj o dodaniu drugiego argumentu get w definicji magazynu: create<AppState>((set, get) => ({ ... })) oraz o zarejestrowaniu stopRepository i ztmApiRepository w kontenerze src/di/container.ts.

3. Komponent wyróżniania opóźnień (DelayHighlight) i Value Object DepartureDelay

💡 Wskazówki implementacyjne: Value Object DepartureDelay oraz optymalne animacje natywne (Kliknij, aby rozwinąć/zwinąć)

Częstym błędem w aplikacjach React Native jest mieszanie czystej matematyki biznesowej (przeliczanie sekund na minuty, progi kategoryzacji opóźnień) z warstwą renderowania komponentu. Zgodnie z Clean Architecture i DDD, logikę decyzyjną zamykamy w niemutowalnym Obiekcie Wartości:

  1. Warstwa Domeny – Value Object (src/domain/value-objects/DepartureDelay.ts): Obiekt wartości enkapsulujący logikę opóźnienia, całkowicie niezależny od bibliotek UI:
// src/domain/value-objects/DepartureDelay.ts
export type DelaySeverity = 'ON_TIME' | 'MINOR' | 'MAJOR';

export class DepartureDelay {
  public readonly seconds: number;
  public readonly severity: DelaySeverity;
  public readonly formattedText: string;
  public readonly shouldAnimate: boolean;

  constructor(delayInSeconds: number | null | undefined) {
    this.seconds = delayInSeconds ?? 0;

    if (this.seconds <= 0) {
      this.severity = 'ON_TIME';
      this.formattedText = 'Na czas';
      this.shouldAnimate = false;
    } else if (this.seconds < 120) {
      this.severity = 'MINOR';
      this.formattedText = `+${this.seconds}s`;
      this.shouldAnimate = false;
    } else {
      const minutes = Math.floor(this.seconds / 60);
      this.severity = 'MAJOR';
      this.formattedText = `+${minutes} min`;
      this.shouldAnimate = true;
    }
  }

  public isDelayed(): boolean {
    return this.seconds > 0;
  }

  public equals(other: DepartureDelay | null | undefined): boolean {
    if (!other) return false;
    return this.seconds === other.seconds;
  }
}
  1. Warstwa Prezentacji i Animacja Natywna (components/DelayHighlight.tsx): Komponent tworzy instancję DepartureDelay. Aby animacja nie blokowała wątku JavaScriptu telefonu, zawsze stosujemy flagę useNativeDriver: true – dzięki temu kalkulacja klatek animacji przezroczystości (opacity) jest w całości zrzucona na dedykowany wątek renderowania systemu operacyjnego (UI Thread):
import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import { DepartureDelay } from '../src/domain/value-objects/DepartureDelay';

interface DelayHighlightProps {
  delayInSeconds: number | null | undefined;
}

export function DelayHighlight({ delayInSeconds }: DelayHighlightProps) {
  const { severity, formattedText, shouldAnimate } = new DepartureDelay(delayInSeconds);
  const opacityAnim = useRef(new Animated.Value(1)).current;

  useEffect(() => {
    if (shouldAnimate) {
      const animation = Animated.loop(
        Animated.sequence([
          Animated.timing(opacityAnim, {
            toValue: 0.25,
            duration: 700,
            useNativeDriver: true,
          }),
          Animated.timing(opacityAnim, {
            toValue: 1,
            duration: 700,
            useNativeDriver: true,
          }),
        ])
      );
      animation.start();

      return () => animation.stop();
    } else {
      opacityAnim.setValue(1);
    }
  }, [shouldAnimate, opacityAnim]);

  const colorStyle =
    severity === 'ON_TIME'
      ? styles.onTime
      : severity === 'MINOR'
      ? styles.minorDelay
      : styles.majorDelay;

  return (
    <Animated.Text colorStyle, opacity: opacityAnim style="{[styles.baseText," { }]}>
      {formattedText}
    </Animated.Text>
  );
}

const styles = StyleSheet.create({
  baseText: {
    fontSize: 12,
  },
  onTime: {
    color: '#16A34A',
    fontWeight: 'normal',
  },
  minorDelay: {
    color: '#D97706',
    fontWeight: 'normal',
  },
  majorDelay: {
    color: '#DC2626',
    fontWeight: 'bold',
  },
});

4. Interaktywna Mapa Przystanków i Value Object GeoBoundingBox

💡 Wskazówki implementacyjne: Value Object GeoBoundingBox, filtrowanie przestrzenne i Mostek WebView (Kliknij, aby rozwinąć/zwinąć)

Baza ZTM Gdańsk zawiera ponad 2500 słupków przystankowych. Wyrenderowanie ich wszystkich jednocześnie przy niskim przybliżeniu (widok całego Trójmiasta) natychmiast zamrozi interfejs telefonu lub spowoduje awarię pamięci (Out of Memory).

Poniżej znajduje się wzorcowy podział odpowiedzialności zgodnie z Clean Architecture i DDD:

  1. Warstwa Domeny – Value Object (src/domain/value-objects/GeoBoundingBox.ts): Obiekt wartości reprezentujący prostokątny wycinek mapy widoczny na ekranie. Pilnuje niezmiennika współrzędnych (min <= max) oraz definiuje regułę minimalnego przybliżenia (shouldRenderMarkers()):
// src/domain/value-objects/GeoBoundingBox.ts
import { Coordinates } from './Coordinates';

export class GeoBoundingBox {
  public static readonly MIN_ZOOM_FOR_MARKERS = 15;

  constructor(
    public readonly minLat: number,
    public readonly maxLat: number,
    public readonly minLon: number,
    public readonly maxLon: number,
    public readonly zoomLevel: number
  ) {
    if (minLat > maxLat) {
      throw new Error('Wartość minLat nie może być większa niż maxLat.');
    }
    if (minLon > maxLon) {
      throw new Error('Wartość minLon nie może być większa niż maxLon.');
    }
  }

  public shouldRenderMarkers(): boolean {
    return this.zoomLevel >= GeoBoundingBox.MIN_ZOOM_FOR_MARKERS;
  }

  public contains(coords: Coordinates): boolean {
    return (
      coords.latitude >= this.minLat &&
      coords.latitude <= this.maxLat &&
      coords.longitude >= this.minLon &&
      coords.longitude <= this.maxLon
    );
  }
}
  1. Warstwa Infrastruktury / Baza Danych (src/infrastructure/storage/SqliteStopRepository.ts): Zamiast ładować 2500 obiektów do pamięci JavaScriptu i filtrować je pętlą, wykorzystaj zapytanie zakresowe bezpośrednio w silniku SQLite:
SELECT stopId, stopName, stopDesc, stopLat, stopLon 
FROM stops 
WHERE stopLat BETWEEN ? AND ? 
  AND stopLon BETWEEN ? AND ?;
  1. Warstwa Przypadków Użycia (src/use-cases/GetVisibleStopsUseCase.ts):

    • Przyjmuje instancję GeoBoundingBox.
    • Jeśli !bbox.shouldRenderMarkers(), natychmiast zwraca pustą tablicę [] (użytkownik widzi czysty podkład mapy bez zacinania animacji).
    • Gdy użytkownik przybliży widok (zoomLevel >= 15), orkiestrator odpytuje repozytorium o punkty w widocznym prostokącie geograficznym.
  2. Warstwa Prezentacji i Komunikacja ze stanem Zustand: W zależności od wybranego silnika mapowego:

    • Wariant A: Silnik WebView (Leaflet / OpenStreetMap): Ponieważ kod HTML/JS wewnątrz WebView działa w osobnym procesie, kliknięcie przycisku w dymku HTML wysyła wiadomość do React Native za pomocą window.ReactNativeWebView.postMessage():
// Wewnątrz kodu JavaScript w szablonie HTML:
function handleFavoriteClick(stopId, name, lat, lon) {
  const payload = JSON.stringify({
    type: 'ADD_FAVORITE',
    stop: { stopId, name, lat, lon }
  });
  window.ReactNativeWebView.postMessage(payload);
}

// Szablon dymka (popup):
marker.bindPopup(`
  <b>${stop.stopName}</b> (#${stop.stopId})<br/>
  <button onclick="handleFavoriteClick(${stop.stopId}, '${stop.stopName}', ${stop.stopLat}, ${stop.stopLon})">
    ⭐ Dodaj do ulubionych
  </button>
`);

Odbiór zdarzenia w komponencie React Native:

<WebView html: mapHtml onMessage="{(event)" originWhitelist="{['*']}" source="{{" }}> {
    try {
      const data = JSON.parse(event.nativeEvent.data);
      if (data.type === 'ADD_FAVORITE') {
        useStore.getState().addStop(data.stop);
      }
    } catch (err) {
      console.error('Błąd parsowania wiadomości z WebView:', err);
    }
  }}
/>

5. Testy jednostkowe Obiektów Wartości i Przypadków Użycia (Jest)

6. Wdrożenie i kompilacja instalatora (.apk)


Harmonogram odbiorów i punkty kontrolne (Milestones)

Przedmiot obejmuje 16 godzin lekcyjnych podzielonych na 4 bloki laboratoryjne. Realizacja projektu została ustrukturyzowana w postaci trzech etapów wdrożeniowych (Kamieni milowych) rozliczanych na platformie Moodle przed kolejnymi zajęciami, co gwarantuje równomierny postęp prac bez kumulacji zadań pod koniec semestru:

Etap Termin rozliczenia (Moodle) Weryfikowany zakres prac Punkty
Etap I
(Milestone 1)
Przed zajęciami Lab 2
(do 5. godziny lekcyjnej)
Fundamenty UI, Taktyczne DDD & Animacje:
• Moduł 1: Szkielet Clean Architecture (domain/entities, domain/value-objects, use-cases), typowany komponent DepartureCard, lista FlatList na makietach oraz Value Object RouteNumber (normalizacja TRISTAR) (15 pkt)
• Moduł 2: Value Object DepartureDelay oraz komponent DelayHighlight z cykliczną animacją pulsowania Animated (useNativeDriver: true) (15 pkt)
(Weryfikacja: paczka ZIP bez node_modules + zrzuty ekranu z Expo Go).
30 pkt
Etap II
(Milestone 2)
Przed zajęciami Lab 3
(do 9. godziny lekcyjnej)
API ZTM, Stan Globalny, SQLite & Kontener DI:
• Moduł 3: Integracja z API ZTM Gdańsk (odjazdy live, ISO-8601 UTC, filtrowanie kursów > 2 min) oraz magazyn Zustand z obsługą Optimistic UI i rollbacku (20 pkt)
• Moduł 4: Uwierzytelnianie i SQLite – port IPasswordHasher, adapter BcryptPasswordHasher (bcryptjs + expo-crypto), baza expo-sqlite oraz kontener DI (src/di/container.ts) (15 pkt)
(Weryfikacja: paczka ZIP bez node_modules + zrzuty ekranu z Expo Go).
35 pkt
Etap III
(Milestone 3)
Przed zajęciami Lab 4
(do 13. godziny lekcyjnej)
Funkcje natywne, Jakość & Wdrożenie produkcyjne:
• Moduł 5: Interaktywna mapa (Leaflet/WebView), Value Objecty Coordinates i GeoBoundingBox z filtrowaniem przestrzennym w SQLite oraz integracja GPS z expo-location (15 pkt)
• Moduł 6: Pokrycie logiki testami jednostkowymi Jest (Value Objecty DepartureDelay, RouteNumber + use-case z mockami i zamrożonym zegarem) (10 pkt)
• Moduł 7: Konfiguracja app.json/eas.json, samodzielny instalator .apk działający bez serwera Metro oraz całościowa ocena Clean Architecture, DI i typowania TypeScript (10 pkt)
(Weryfikacja: paczka ZIP bez node_modules + testy Jest + instalator .apk).
35 pkt
SUMA 100 pkt

Procedura weryfikacji i obrona końcowa (Laboratorium 4)

Laboratorium 4 jest spotkaniem podsumowującym semestr. Zajęcia te są w całości przeznaczone na weryfikację projektów, obronę oraz ostateczne wystawienie ocen w protokole.

1. Tryb automatyczny (Ocena 3.0)

Student otrzymuje ocenę dostateczną (3.0) bez konieczności przystępowania do pytań sprawdzających, jeżeli spełni łącznie dwa warunki:

2. Tryb podwyższania oceny (Oceny 3.5 – 5.0)

Studenci, którzy terminowo oddali 3 etapy i chcą uzyskać ocenę wyższą, przystępują do obrony podczas Laboratorium 4 na udostępnionym ekranie komputera:

  1. Wymogi techniczne prezentacji:
    • Udostępnienie pełnego pulpitu komputera (IDE z kodem, terminal z procesem budowania lub testami npm test).
    • Podgląd aplikacji mobilnej: fizyczny telefon sklonowany na ekran za pomocą programu scrcpy (zalecane) lub emulator Android Studio / symulator iOS.
    • Włączona kamera internetowa oraz sprawny mikrofon.
  2. Weryfikacja samodzielności (Live Coding – 5–10 min):
    • Prowadzący zleca natychmiastowe wykonanie modyfikacji w kodzie programu (np. dopisanie nowej metody do Value Objectu RouteNumber lub DepartureDelay wraz z testem w Jest, dodanie nowego pola w encji lub zmiana sortowania w kwerendzie SQLite).
    • Sprawdzenie znajomości przepływu danych w Czystej Architekturze, roli kontenera DI (container.ts) oraz różnicy między Encją a Value Objectem.
  3. Autonomia aplikacji produkcyjnej:
    • Student zatrzymuje serwer deweloperski (Ctrl + C) i uruchamia na telefonie wygenerowany samodzielny plik .apk, dowodząc, że aplikacja działa autonomicznie bez wsparcia Metro Bundlera.
  4. Kryteria oceny:
    • Brak znajomości kodu lub niezdolność do wykonania prostej modyfikacji na żywo: ocena pozostaje na poziomie 3.0 (lub brak zaliczenia przy braku kompletnych etapów).
    • Sprawne wykonanie zadania i poprawne odpowiedzi na pytania architektoniczne stanowią podstawę do wystawienia ocen 3.5, 4.0, 4.5 lub 5.0.

⚠️ Zasady rygoru terminowego:
Nieterminowe oddanie któregokolwiek z 3 etapów na Moodle lub dołączenie do archiwum katalogu node_modules/ skutkuje natychmiastową utratą prawa do oceny 3.0 z automatu i nakłada obowiązek pełnej obrony projektu podczas Laboratorium 4.


Kryteria oceny projektu (100 pkt)

Moduł Etap Wymagania techniczne Punkty
1. UI, Lista & Value Object RouteNumber Etap I Reużywalny komponent karty DepartureCard, wydajne renderowanie FlatList na danych makietowych oraz Value Object RouteNumber normalizujący linie TRISTAR w warstwie domeny. 15 pkt
2. DelayHighlight & DepartureDelay Etap I Niemutowalny Value Object DepartureDelay kategoryzujący opóźnienia, cykliczna animacja pulsowania przezroczystości Animated (useNativeDriver: true) dla opóźnień ≥ 2 min. 15 pkt
3. API ZTM & Zustand Etap II Pobieranie odjazdów na żywo z API ZTM Gdańsk, parsowanie ISO-8601 UTC, reguła odrzucania kursów starszych niż 2 minuty, globalny magazyn Zustand z Optimistic UI i obsługą rollbacku. 20 pkt
4. Autoryzacja, SQLite & Kontener DI Etap II Port IPasswordHasher i adapter BcryptPasswordHasher (bcryptjs + expo-crypto), logowanie i rejestracja w bazie expo-sqlite oraz wstrzykiwanie zależności przez src/di/container.ts. 15 pkt
5. Mapa, GPS & GeoBoundingBox Etap III Mapa Leaflet w WebView z dymkami dodawania do ulubionych, Value Objecty Coordinates i GeoBoundingBox, kwerenda Bounding Box w SQLite oraz integracja GPS (expo-location). 15 pkt
6. Testy Jednostkowe (Jest) Etap III Pokrycie testami Value Objectów DepartureDelay i RouteNumber (wartości brzegowe, equals) oraz use-case'a GetLiveDeparturesUseCase (mock repozytorium, zamrożony czas zegara). 10 pkt
7. Wdrożenie APK & Clean Architecture Etap III Konfiguracja app.json/eas.json, samodzielny plik instalacyjny .apk działający bez serwera Metro oraz pełna separacja warstw domain, use-cases, infrastructure, di w TypeScript. 10 pkt