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 komunikatNo package found matching input criteria, odśwież bazę pakietów komendą:winget source update winget install --id Genymobile.scrcpy -eAlternatywa manualna: Zawsze możesz pobrać gotową paczkę ZIP bezpośrednio z oficjalnego wydania: GitHub Genymobile/scrcpy Releases (pobierz plik
scrcpy-win64-*.zipi 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:
- Na telefonie wejdź w Ustawienia → Informacje o telefonie → Informacje o oprogramowaniu.
- Kliknij 7 razy z rzędu w pozycję „Numer kompilacji” (Build number), aż pojawi się komunikat informujący, że jesteś programistą.
- Wróć do menu głównego Ustawień i przejdź do nowej sekcji Opcje programisty (Developer options).
- Znajdź i włącz przełącznik Debugowanie USB (USB debugging).
Krok 3: Połączenie i uruchomienie podglądu
- Podłącz telefon do komputera kablem USB.
- Na ekranie smartfona pojawi się komunikat: „Zezwalać na debugowanie USB?”. Zaznacz opcję „Zawsze zezwalaj z tego komputera” i stuknij Zezwól.
- 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):
scrcpy --turn-screen-off– wygasza ekran fizycznego telefonu, oszczędzając baterię (obraz widać tylko na monitorze komputera).scrcpy -m 1024– ogranicza rozdzielczość strumienia (przydatne przy wolniejszych portach USB).
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):
- Zawieszenie na 99% / pętla "Reloading": Pasek postępu staje na dole ekranu i aplikacja nie startuje.
- Aplikacja-Zombie: Twój kod jest w 100% poprawny, a aplikacja wciąż zgłasza błąd (np.
undefineddla istniejącego komponentu) albo całkowicie ignoruje nowe zmiany (nawet celowe błędy składniowe). Telefon "zatrzasnął się" na starej wersji.
Sposoby rozwiązania:
- 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).
- 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.
- Zatrzymaj serwer deweloperski w terminalu
(
- 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:
(Przy pierwszym uruchomieniu zatwierdź instalację pakietu tunnelera wpisującnpx expo start --tunnely).
- Jeśli powyższe kroki nie pomogą, router może izolować klientów (tzw.
AP Isolation). Uruchom serwer w trybie tunelu:
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ę:
- Pobierz aplikację Expo Go ze sklepu Google Play (Android) lub App Store (iOS).
- Zeskanuj kod QR (na iOS użyj systemowej aplikacji Aparat, na Androidzie użyj skanera wbudowanego w aplikację Expo Go).
🧠 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:
- Metro Bundler (uruchomiony komendą
npm run start) analizuje cały Twój kod oraz pliki bibliotek zewnętrznych (node_modules).- Następnie transpiluje go (zamienia TypeScript i nowoczesny JS na kod zrozumiały dla silnika JavaScript w telefonie) i pakuje (bundle) w jedną paczkę.
- Paczka jest przesyłana przez lokalne Wi-Fi do aplikacji Expo Go, która na jej podstawie wywołuje natywne komponenty systemu Android lub iOS.
- Czas budowania (np. 7163ms): Pierwsze przetwarzanie trwa zazwyczaj od kilku do kilkudziesięciu sekund, ponieważ system buduje pamięć podręczną (cache). Każda kolejna zmiana zapisana w kodzie pojawi się na ekranie telefonu w ułamku sekundy dzięki mechanizmowi Fast Refresh (HMR).
💡 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:
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.
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
- Czasami po aktualizacji aplikacji serwer deweloperski pamięta stary
cache. Zatrzymaj proces w terminalu (
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:
- Expo Tools (
expo.vscode-expo-tools) – oficjalne wsparcie dla plikówapp.json, nawigacji Expo Router oraz autouzupełniania stałych systemowych. - React Native Tools
(
msjsdiag.vscode-react-native) – rozszerzenie od firmy Microsoft umożliwiające debugowanie kodu z poziomu panelu Run & Debug w VS Code. - ESLint oraz Prettier - Code
formatter – automatyczne formatowanie kodu i pilnowanie
poprawności klamer w znacznikach JSX przy każdym zapisie
(
Ctrl + S). - Auto Rename Tag – automatycznie zmienia znacznik zamykający przy edycji znacznika otwierającego w komponentach React Native.
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:
- Oczyszczony folder projektu – upewnij się, że
zawiera pliki konfiguracyjne (
package.json,app.json,tsconfig.json) oraz katalogi źródłowe (app/,components/,src/). - Katalog ze zrzutami ekranu (screenshotami) – dokumentujący kluczowe widoki poprawnie działającej aplikacji na fizycznym smartfonie.
- 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ą:
app/– katalog Expo Routera. Każdy plik w tym folderze automatycznie staje się ekranem w aplikacji (koncepcja File-based routing).- Uwaga na foldery w nawiasach
(tabs): Nawiasy okrągłe oznaczają tzw. grupę routingu (Route Group). Służą wyłącznie do logicznej organizacji plików i współdzielenia layoutu (np. paska zakładek), ale sam człon(tabs)jest pomijany w ścieżce URL aplikacji (ekran z plikuapp/(tabs)/index.tsxma adres/, a nie/(tabs)/).
- Uwaga na foldery w nawiasach
components/– reużywalne komponenty interfejsu (np. przyciski, karty).constants/– stałe konfiguracyjne (np. definicje kolorów, linki API).assets/– zasoby statyczne (ikony, czcionki).
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:
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 numerze2072.Departure(id,routeId,headsign,theoreticalTime,delayInSeconds) – pojedynczy kurs pojazdu w rozkładzie.
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 obiektyCoordinates(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
Coordinatesz szerokością geograficzną150°– konstruktor natychmiast rzuci wyjątek.
- Obiekty pozbawione tożsamości (brak pola
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:
- Przechowuje pojedyncze instancje (Singletony) repozytoriów i serwisów infrastruktury.
- Automatycznie wstrzykuje je do konstruktorów Przypadków Użycia (Constructor Injection).
- 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:
Rozszerzenie
.tsxoraz składnia JSX:
Pliki.tsxłączą język TypeScript ze składnią JSX (JavaScript XML). Pozwala to opisywać wygląd ekranu bezpośrednio wewnątrz funkcji JS. Każdy komponent funkcyjny w React Native to zwykła funkcja zwracająca drzewo widoków JSX. Nawiasy klamrowe{ }w kodzie JSX oznaczają przejście do czystego JavaScriptu (np. odczytanie zmiennej, warunek logiczny lub wstawienie obiektu styli).Interfejsy TypeScript (
interface DepartureCardProps):
W standardowym JS właściwości (props) nie mają wymuszonego typu. W TypeScript deklarujemy kontrakt danych za pomocą słowainterface, precyzując dozwolone typy:routeId: number | string;– unia typów (Union Type), która akceptuje zarówno liczbę (9), jak i tekst ("N1").delayInSeconds: number | null;– pole musi przyjąć wartość liczbową lubnull(gdy brak danych telemetrycznych GPS).- Zaleta: Jeśli w innym pliku użyjesz
<DepartureCard/>bez przekazania wymaganego parametruheadsign, kompilator natychmiast wskaże błąd w edytorze.
Destrukturyzacja właściwości (Props Destructuring):
Zamiast przypisywać właściwości pojedynczo z obiektu argumentów (const routeId = props.routeId), wyciągamy je bezpośrednio w nagłówku funkcji:
export default function DepartureCard({ routeId, headsign, theoreticalTime, delayInSeconds }: DepartureCardProps)
Zmienne stają się natychmiast dostępne w ciele funkcji z przypisanym typem z interfejsu.Rygorystyczna separacja tekstu (
<View>vs<Text>):<View>odpowiada webowemu znacznikowi<div>– pełni rolę kontenera blokowego, w którym nie wolno umieszczać bezpośrednio tekstu.<Text>to jedyny komponent renderujący napisy (odpowiednik<p>lub<span>). Wstawienie surowego ciągu znaków wprost do<View>wywoła błąd krytyczny aplikacji (Invariant Violation).
Renderowanie warunkowe (
&&):
Wyrażenie{isDelayed && <Text ...> (+{delayInSeconds}s)</Text>}wykorzystuje mechanizm zwarciowy operatora logicznego AND. Prawy człon jest ewaluowany i renderowany tylko wtedy, gdyisDelayedma wartośćtrue.Stylowanie za pomocą
StyleSheet.create(Mobilny CSS-in-JS):
W aplikacjach mobilnych nie stosuje się zewnętrznych arkuszy.css. Wszystkie reguły definiujemy w obiektach JavaScript:- camelCase zamiast myślników: właściwości CSS
zapisujemy bez myślników (np.
backgroundColorzamiastbackground-color,marginBottomzamiastmargin-bottom). - Brak jednostek miary (
px): wartości liczbowe (np.padding: 16,fontSize: 16) to punkty niezależne od gęstości ekranu (dp – Density-independent Pixels), które system automatycznie skaluje do fizycznej matrycy smartfona. - Domyślny Flexbox w pionie: na stronach WWW
kontenery flex układają elementy w wierszu. W React Native komponent
<View>domyślnie układa elementy w kolumnie (flexDirection: 'column'). Aby umieścić etykietę linii obok nazwy przystanku, wymagane jest jawne ustawienieflexDirection: 'row'.
- camelCase zamiast myślników: właściwości CSS
zapisujemy bez myślników (np.
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śliitem.notesma 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.
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:
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.tsxwe właściwościachoptions={{ title: ... }}.
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
DepartureCardma zdefiniowany systemowy cień (elevation: 3dla Androida). Zwróć uwagę, że system operacyjny sam dba o prawidłowe renderowanie tego efektu w przestrzeni 3D.
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:
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.
- Gdy serwer deweloperski działa w konsoli
(
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.
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').
- 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ą
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.
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).
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.
- 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 |>;
}
- 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
);
}
}
- 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?
- Przy pierwszym kliknięciu przycisku „📍 Zlokalizuj mnie” Android lub iOS wyświetli systemowy komunikat autoryzacyjny (Runtime Permission).
- Po wyrażeniu zgody biblioteka
expo-locationpobiera odczyt z odbiornika GPS/modułu lokalizacji urządzenia.- Metoda
injectJavaScriptprzekazuje 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:
- Obiekty Wartości (Value Objects) (np.
DepartureDelay,RouteNumber,Coordinates) same pilnują swoich niezmienników – weryfikujemy ich konstruktory, metody biznesowe oraz równość strukturalną (equals) dla danych brzegowych. - Przypadki użycia (np.
GetLiveDeparturesUseCase,RegisterUserUseCase) testujemy, podstawiając atrapy (mocki) interfejsów repozytoriów i portów (jest.fn()).
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:
- 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.xmlczy zaawansowaną integrację usług systemowych. - 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). - 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.
- Plik
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
Zainstaluj globalnie oficjalne narzędzie wiersza poleceń EAS CLI:
npm install -g eas-cliZaloguj się na konto w serwisie Expo (jeśli go nie posiadasz, załóż darmowe konto na expo.dev):
eas loginZainicjalizuj konfigurację budowania w katalogu projektu:
eas build:configurePolecenie utworzy w głównym folderze plik
eas.json.Domyślnie profil
productionbuduje pakiet.aabdedykowany do Google Play. Aby wygenerować instalowalny bezpośrednio plik.apk, zmodyfikuj plikeas.json, definiując profilpreviewz 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:
- 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. - Kod projektu (z wyłączeniem
node_modulesi plików zdefiniowanych w.gitignore) zostanie spakowany i przesłany do chmury Expo. - W terminalu pojawi się link do śledzenia postępów na żywo w
przeglądarce (np.
https://expo.dev/accounts/.../builds/...). - Na maszynie wirtualnej w chmurze zostanie uruchomiony proces
gradlew assembleRelease, kompilacja silnika Hermes oraz spakowanie instalatora APK. - Po zakończeniu kompilacji (zazwyczaj 5–10 minut) w terminalu
wyświetli się bezpośredni odnośnik oraz kod QR pozwalający pobrać plik
.apkwprost 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:
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_HOMEorazJAVA_HOME.
Kompilacja lokalna przez EAS CLI:
eas build -p android --profile preview --localNarzędzie wykorzysta lokalne środowisko Gradle i SDK do wyprodukowania pliku
.apkbezpośrednio w folderze projektu.Alternatywa natywna (
expo run:android): Możesz również wygenerować natywny katalogandroid/poprzez proces prebuildingu i uruchomić natywną kompilację Gradle:npx expo run:android --variant releaseGotowy plik
.apkznajdziesz wówczas w podkatalogu:
android/app/build/outputs/apk/release/app-release.apk.
4. Instalacja i weryfikacja na urządzeniu
- Pobierz wygenerowany plik
.apkna telefon z systemem Android (ze strony pobierania EAS lub przesyłając plik kablem USB). - 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.
- Po zainstalowaniu na pulpicie telefonu pojawi się niezależna ikona Stop Scout.
- 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
- Zaimplementuj ekran rejestracji i logowania użytkownika. Hasło
użytkownika musi być bezpiecznie hashowane za pośrednictwem portu
domenowego
IPasswordHasheroraz adaptera infrastruktury wykorzystującegobcryptjsz modułemexpo-crypto. - Do trwałego przechowywania danych na telefonie wykorzystaj lokalną
bazę relacyjną SQLite (
expo-sqlite). - W bazie przechowuj konta użytkowników, zbuforowaną listę wszystkich przystanków oraz przypisane do danego profilu ulubione przystanki wraz z opcjonalną notatką tekstową.
- Skonfiguruj kontener wstrzykiwania zależności
(
src/di/container.ts), aby komponenty widoków (app/) komunikowały się wyłącznie z przypadkami użycia, nie importując bezpośrednio klas z kataloguinfrastructure/.
💡 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:
- Instalacja wymaganych bibliotek:
npx expo install expo-sqlite expo-crypto
npm install bcryptjs
npm install --save-dev @types/bcryptjs
- Warstwa Domeny (
src/domain/):entities/User.ts– encja użytkownika (id,username,passwordHash). Każdy model umieść w osobnym pliku w kataloguentities/, aby zapobiec błędom kompilacjits(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;
}
- Warstwa Infrastruktury
(
src/infrastructure/):- Adapter kryptograficzny
(
src/infrastructure/security/BcryptPasswordHasher.ts): Silnik Hermes w React Native nie posiada wbudowanego modułucrypto. Wywołaniebcrypt.genSaltSyncbez konfiguracji zgłosi błąd:Neither WebCryptoAPI nor a crypto module is available. Zamykamy ten szczegół techniczny całkowicie wewnątrz adaptera infrastruktury:
- Adapter kryptograficzny
(
// 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);
}
}
- Repozytorium SQLite
(
src/infrastructure/storage/SqliteAuthRepository.ts): ImplementacjaIAuthRepositorywykonująca zapytania SQL (SELECT,INSERT) za pośrednictwem bibliotekiexpo-sqlite.
⚠️ OSTRZEŻENIE ARCHITEKTONICZNE: ZAKAZ TOP-LEVEL
await
W plikach modułów bazodanowych nie wolno wykonywać instrukcjiawaitna 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ąundefinedi 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]
);
}
}
- Warstwa Przypadków Użycia
(
src/use-cases/): Przypadki użycia przyjmują przez konstruktor wyłącznie interfejsy domenowe (IAuthRepositoryorazIPasswordHasher), 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;
}
}
- 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();
- Warstwa Prezentacji
(
app/(auth)/login.tsx): Widok importuje wyłączniecontaineroraz magazynuseStore, 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
- Lista wszystkich przystanków (do pobrania i zbuforowania
lokalnie):
https://ckan.multimediagdansk.pl/.../stops.json - Estymowane czasy przyjazdów dla danego słupka przystankowego
(
stopId):- Szablon zapytania:
https://ckan2.multimediagdansk.pl/departures?stopId={stopId} - Przykładowy działający odnośnik (Dworzec Główny,
stopId=2072): https://ckan2.multimediagdansk.pl/departures?stopId=2072
- Szablon zapytania:
- Normalizacja TRISTAR (
RouteNumber): Wykorzystaj Value Objectsrc/domain/value-objects/RouteNumber.tsdo normalizacji technicznych numerów linii z systemu TRISTAR (np.10177→"177"). - Filtrowanie odjazdów: Na liście odjazdów nie
wyświetlaj pojazdów, które odjechały dawniej niż 2 minuty temu względem
aktualnego czasu (do weryfikacji użyj pól
estimatedTimelubtheoreticalTime). - Do zarządzania stanem aplikacji (np. listą ulubionych przystanków) wykorzystaj bibliotekę Zustand. Zastosuj technikę Optimistic UI – dodanie lub usunięcie przystanku oraz zmiana notatki ma natychmiast aktualizować interfejs użytkownika, podczas gdy zapis do SQLite odbywa się w tle.
💡 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:
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[]>).
Warstwa Infrastruktury (
src/infrastructure/):api/ZtmApiClient.ts– implementujeIZtmApiRepositoryprzy użyciu wbudowanegofetchlub bibliotekiaxios.- Wskazówka ZTM: Plik
stops.jsonzwraca 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 encjeStop.
- Wskazówka ZTM: Plik
storage/SqliteStopRepository.ts– implementujeIStopRepository, wykonując zapytania do lokalnej bazyexpo-sqlite(operacjeINSERT INTO stops ...w transakcji oraz odczyt w trybie offline).
⚠️ SPECYFIKA DANYCH ZTM GDAŃSK I SYSTEMU TRISTAR:
Prefiksy operatorskie TRISTAR w polu
routeId:
W endpointcie/departurespolerouteIdjest 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ę numer10177zamiast177.
Wymaganie: Wykorzystaj klasę Value ObjectuRouteNumber(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").Puste nazwy w
stops.jsona więzy integralności SQLite:
Plikstops.jsonzawiera ponad 2500 słupków (w tym techniczne i wirtualne), z których część posiada polestopNameo wartościnulllubundefined. Próba zapisu takiego rekordu do kolumny zdefiniowanej jakostopName TEXT NOT NULLzakoń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.Format czasu ISO-8601 a reguła filtrowania 2 minut:
Endpoint/departureszwraca czasy odjazdów w formacie ISO-8601 UTC (np."2026-10-15T14:05:00Z"), a nie"HH:mm". Naiwne parsowanietimeString.split(':')zwróciNaN, przez co warunek odrzucania starych kursów odrzuci wszystkie pozycje (efekt: permanentny napis "Brak zaplanowanych kursów").
Rozwiązanie: WGetLiveDeparturesUseCasesprawdzaj format (obsługując zarówno pełną datę ISO-8601 przeznew Date(timeString), jak i format"HH:mm"), a na kafelku odjazdu formatuj go do lokalnej godziny pasażera (HH:mm).
Warstwa Przypadków Użycia (
src/use-cases/):SyncStopsUseCase.ts– realizuje logikę Cache-First / Offline-First:- Sprawdza, czy w lokalnej bazie SQLite znajdują się już przystanki.
- Jeśli tabela jest pusta, pobiera świeże dane z
IZtmApiRepositoryi zapisuje je do SQLite. - Zwraca przystanki z bazy lokalnej.
GetLiveDeparturesUseCase.ts– pobiera listę odjazdów dla wybranego słupka, normalizuje numer linii przezRouteNumberi 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;
});
- 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
- Stwórz Value Object
src/domain/value-objects/DepartureDelay.tsoraz dedykowany komponentcomponents/DelayHighlight.tsxwizualizujący status opóźnienia na podstawie wartości poladelayInSeconds:- Wartość
0lub ujemna (brak opóźnienia / przed czasem): zielony napis "Na czas". - Opóźnienie poniżej 2 minut (
< 120s): żółty napis z liczbą sekund, np. "+90s". - Opóźnienie 2 minuty lub większe: czerwony, pogrubiony napis z przeliczoną liczbą minut (np. "+3 min").
- Wartość
- Animacja ostrzegawcza: Dla opóźnień wynoszących co
najmniej 2 minuty tekst powinien cyklicznie pulsować. Zaimplementuj
płynną, zapętloną animację przezroczystości (
opacity) przy użyciu systemowego modułuAnimatedz bibliotekireact-native.
💡 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:
- 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;
}
}
- 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
- W osobnej zakładce aplikacji umieść pełnoekranową mapę wyśrodkowaną na Gdańsk.
- Nanieś na mapę przystanki w postaci pinezek. Ze względów
wydajnościowych renderuj pinezki dopiero po odpowiednim przybliżeniu
widoku przez użytkownika, wykorzystując Value Object
src/domain/value-objects/GeoBoundingBox.ts. - Kliknięcie pinezki powinno otwierać dymek informacyjny zawierający nazwę przystanku, numer słupka oraz przycisk dodający ten punkt do listy ulubionych.
💡 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:
- 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
);
}
}
- 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 ?;
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.
- Przyjmuje instancję
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
WebViewdziała w osobnym procesie, kliknięcie przycisku w dymku HTML wysyła wiadomość do React Native za pomocąwindow.ReactNativeWebView.postMessage():
- Wariant A: Silnik WebView (Leaflet /
OpenStreetMap): Ponieważ kod HTML/JS wewnątrz
// 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);
}
}}
/>
- Wariant B: Natywny
react-native-maps: Wykorzystaj zdarzenieonRegionChangeComplete={(region) => ...}do utworzenianew GeoBoundingBox(...)na podstawielatitudeDeltailongitudeDelta. Przycisk dodawania do ulubionych umieść wewnątrz komponentu<Callout tooltip={false}>.
5. Testy jednostkowe Obiektów Wartości i Przypadków Użycia (Jest)
- Skonfiguruj środowisko testowe Jest z presetem
jest-expow plikupackage.json. - Test Value Objectu
DepartureDelay(DepartureDelay.test.ts):- Przetestuj poprawność kategoryzacji (
ON_TIME,MINOR,MAJOR). - Uwzględnij wartości brzegowe (
0, liczby ujemne,119,120,null,undefined), poprawność flagishouldAnimateoraz metodę porównaniaequals().
- Przetestuj poprawność kategoryzacji (
- Test Value Objectu
RouteNumber(RouteNumber.test.ts):- Przetestuj obcinanie prefiksów TRISTAR dla linii operatorskich
(
10177→"177"), brak modyfikacji dla standardowych linii GAiT (9,199), poprawne zachowanie dla linii alfanumerycznych (np."N1","T8"), metodęisNightLine()oraz bezpieczną obsługę wartości pustych (null,undefined,""→"?").
- Przetestuj obcinanie prefiksów TRISTAR dla linii operatorskich
(
- Test przypadku użycia
GetLiveDeparturesUseCase(GetLiveDeparturesUseCase.test.ts):- Zweryfikuj działanie logiki biznesowej z zamockowanym repozytorium
API (
jest.fn()). - Użyj fałszywego zegara (
jest.useFakeTimers()ijest.setSystemTime()), aby jednoznacznie przetestować odrzucanie kursów odjechanych dawniej niż 2 minuty temu oraz wyższy priorytet czasu estymowanego (estimatedTime) nad rozkładowym (theoreticalTime).
- Zweryfikuj działanie logiki biznesowej z zamockowanym repozytorium
API (
- Wszystkie testy muszą przechodzić poprawnie (
npm test) bez błędów kompilatora TypeScript.
6. Wdrożenie i kompilacja instalatora (.apk)
- Skonfiguruj unikalny identyfikator pakietu w pliku
app.json(w sekcjiexpo.android.package, np.pl.edu.pg.student.<nr_indeksu>.stopscout). - Skonfiguruj profil
previeww plikueas.jsonz parametrem"buildType": "apk". - Przeprowadź z sukcesem proces budowania paczki instalacyjnej (w
chmurze Expo EAS Build za pomocą
eas build -p android --profile previewlub lokalnie z flagą--local/npx expo run:android). - Weryfikacja: Wynikiem tego etapu ma być w pełni
funkcjonalny, skompilowany plik
.apk, zainstalowany i przetestowany na fizycznym smartfonie z systemem Android (lub emulatorze) bez udziału aktywnego serwera deweloperskiego Metro i bez aplikacji Expo Go. - Do archiwum projektu (Etap III) należy dołączyć:
- Plik konfiguracyjny
eas.json. - Zrzut ekranu z pomyślnego zakończenia procesu budowania (z konsoli terminala lub panelu webowego Expo EAS).
- Działający odnośnik do pobrania wygenerowanego pliku instalacyjnego
.apk(np. w pliku tekstowymlink_do_apk.txtwskazującym na artefakt w chmurze Expo EAS) lub sam plik.apk.
- Plik konfiguracyjny
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:
- Udokumentowana, aktywna obecność na wszystkich spotkaniach (rejestrowana w module Frekwencja na Moodle w 3-minutowym oknie czasowym).
- Terminowe przesłanie na Moodle wszystkich trzech poprawnych
archiwów ZIP (oczyszczonych z
node_modulesi.expo) wraz z kompletem zrzutów ekranu potwierdzających działanie aplikacji na fizycznym smartfonie.
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:
- 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.
- Udostępnienie pełnego pulpitu komputera (IDE z kodem, terminal z
procesem budowania lub testami
- Weryfikacja samodzielności (Live Coding – 5–10
min):
- Prowadzący zleca natychmiastowe wykonanie modyfikacji w kodzie
programu (np. dopisanie nowej metody do Value Objectu
RouteNumberlubDepartureDelaywraz 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.
- Prowadzący zleca natychmiastowe wykonanie modyfikacji w kodzie
programu (np. dopisanie nowej metody do Value Objectu
- 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.
- Student zatrzymuje serwer deweloperski (
- 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 katalogunode_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 |