1. Treści programowe:
Programowanie deklaratywne, tworzenie interfejsu UI – Jetpack Compose, Retrofit, API, http, Kotlin
2. Cel zajęć:
Celem zajęć jest zrozumienie zasad komunikacji
aplikacji z innymi serwerami poprzez protokół http. Poznanie biblioteki Retrofit oraz jej możliwości komunikacji z zewnętrznym
API.
3. Materiały dydaktyczne
I. LazyColumn
Przykład 1. Lista dynamiczna LazyColumn -
prosta lista
LazyColumn w Jetpack Compose
to odpowiednik tradycyjnego RecyclerView ze starszego widoku Androida. Służy do wyświetlania
pionowych list, które mogą mieć tysiące elementów.
Słowo Lazy (leniwa) oznacza, że komponent nie renderuje całej listy na raz. Tworzy na ekranie tylko te
elementy, które są aktualnie widoczne dla użytkownika (plus mały zapas). Gdy
przewijasz listę, niewidoczne elementy są niszczone, a nowe są tworzone w
locie, co zapewnia niesamowitą płynność i oszczędność pamięci.

@Composable
fun ProstaListaUserow() {
// 1.
Przygotowanie przykładowych danych (lista 100 stringów)
val listaImion = List(100) { index -> "Użytkownik numer ${index + 1}" }
//
2. Główny kontener listy
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
) {
// 3. Nagłówek listy (pojedynczy element)
item {
Text(
text = "Moja Lista Kontaktów",
modifier = Modifier.padding(bottom = 16.dp)
)
}
// 4. Dynamiczne generowanie elementów na podstawie danych
items(listaImion)
{ imie ->
Text(
text = imie,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
Aby LazyColumn działało poprawnie, używa
się wewnątrz niego specjalnego języka (DSL). Nie możesz tam wrzucić zwykłych komponentów @Composable bezpośrednio – musisz je opakować
w dedykowane funkcje, takie jak item lub items.
1. Kontener LazyColumn { ... }
To jest baza. Wszystko, co
znajdzie się wewnątrz tych klamer, staje się częścią przewijanej listy. Posiada
on parametry takie jak modifier, contentPadding (wewnętrzny margines listy) czy verticalArrangement (odstępy między elementami).
2. Funkcja item { ... }
Służy do dodania jednego,
konkretnego elementu do listy.
3. Funkcja items(kolekcja) { element -> ... }
To serce dynamicznych list.
Przyjmuje listę danych (np. List<String>,
List<User>) i dla każdego obiektu z
tej listy automatycznie generuje osobny wiersz.
Przydatna wskazówka
na przyszłość (dobre praktyki)
Gdy Twoja lista stanie się
bardziej skomplikowana (elementy będą usuwane, przesuwane lub zmieniane), warto
dodać parametr key wewnątrz funkcji items. Pomaga to Jetpack Compose
zrozumieć, który element jest który, bez ponownego
przerysowywania całej listy:
items(
items = listaImion,
key = { imie
-> imie } //
Unikalny klucz dla każdego wiersza
) { imie ->
Text(text = imie)
}
Przykład 2. Rozbudowana lista

// 1. Model danych
reprezentujący użytkownika
data class User(val id: Int, val name: String, val status: String)
@Composable
fun RozbudowanaListaUserow() {
//
Generujemy listę 50 unikalnych użytkowników
val users = List(50) { index ->
User(id = index, name = "Użytkownik ${index + 1}", status = "Dostępny")
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), // Margines wokół CAŁEJ listy
verticalArrangement = Arrangement.spacedBy(12.dp) // Automatyczne odstępy
między kartami
) {
items(
items = users,
key = { user -> user.id } // Unikalny klucz dla
wydajności
)
{ user ->
// 2. Wykorzystanie komponentu Card jako wiersza
Card(
modifier = Modifier.fillMaxWidth(),
elevation
= CardDefaults.cardElevation(defaultElevation = 4.dp) // Efekt cienia
) {
// 3. Układ poziomy wewnątrz karty (Avatar + Teksty)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 4. "Avatar" czyli kółko z ikoną faceta
Box(
modifier = Modifier
.size(48.dp)
.background(MaterialTheme.colorScheme.primaryContainer, shape = CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector
= Icons.Default.Person,
contentDescription = "Ikona użytkownika",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(16.dp)) // Odstęp między avatarem a tekstem
// 5. Układ pionowy
na dwa teksty (Imię + Status)
Column {
Text(
text = user.name,
fontSize = 18.sp,
fontWeight
= FontWeight.Bold
)
Text(
text = user.status,
fontSize = 14.sp,
color = Color.Gray
)
}
}
}
}
}
}
Opis nowych elementów,
które tu wskoczyły:
Przykład 3. Pobieranie danych w czasie kliknięcia

data class
Produkt(val id: Int,
val nazwa: String)
@Composable
fun ListaProduktowZKlikaniem() {
val context = LocalContext.current // Potrzebne tylko do wyświetlenia Toastu
val produkty = listOf(
Produkt(id = 101, nazwa = "Chleb"),
Produkt(id = 102, nazwa = "Mleko"),
Produkt(id = 103, nazwa = "Masło")
)
LazyColumn {
item { Spacer(modifier = Modifier.height(33.dp)) }
items(
items = produkty,
key = { produkt -> produkt.id } // 1. Systemowy klucz dla Compose (ID)
)
{ produkt ->
// 2. Tutaj mamy bezpośredni
dostęp do całego obiektu
Text(
fontSize = 24.sp,
text = produkt.nazwa,
modifier = Modifier
.clickable {
// 3. BEZPOŚREDNIE UŻYCIE: Pobieramy ID lub nazwę klikniętego
elementu
Toast.makeText(
context,
"Kliknięto produkt o ID: ${produkt.id} o nazwie ${produkt.nazwa}",
Toast.LENGTH_SHORT
).show()
}
)
}
}
}
Przykład 4. Obsługa podwójnego kliknięcia oraz usuwanie
elementów listy LazyColumn
data class
Produkt(val id: Int,
val nazwa: String)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListaJednoObiektowa() {
val context = LocalContext.current
// JEDNA
LISTA: Łączy w sobie źródło danych i stan Compose
val produkty = remember {
mutableStateListOf(
Produkt(id = 101, nazwa = "Chleb"),
Produkt(id = 102, nazwa = "Mleko"),
Produkt(id = 103, nazwa = "Masło")
)
}
LazyColumn {
items(
items = produkty,
key = { produkt -> produkt.id }
) { produkt ->
Text(
text = produkt.nazwa,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
Toast.makeText(context, "Kliknięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
},
onDoubleClick
= {
// Usuwasz tylko raz, z jednej listy!
produkty.remove(produkt)
Toast.makeText(
context,
"W liście zostało: ${produkty.size} elementów",
Toast.LENGTH_SHORT
).show()
}
)
.padding(16.dp)
)
}
}
}
Funkcja mutableStateListOf() tworzy obiekt typu SnapshotStateList. Jest to specjalna odmiana zwykłej listy z Kotlina
(MutableList), którą inżynierowie z Google wyposażyli w
"uszy".
Działa ona dualnie:
Przykład 5. Obsługa podwójnego kliknięcia oraz usuwanie na stałe
elementów listy.
Przykład pokazuje
jak przekazać listę i funkcje obsługi zdarzenia jako parametr
data class
Produkt(val id: Int,
val nazwa: String)
@Composable
fun EkranZakupowZObrotem() {
val context = LocalContext.current
//
Używamy rememberSaveable zamiast zwykłego remember.
// Korzystamy z delegata 'by', co
daje nam bezpośredni dostęp do listy.
var listaProduktow by rememberSaveable {
mutableStateOf(
listOf(
Produkt(id = 101, nazwa = "Chleb"),
Produkt(id = 102, nazwa = "Mleko"),
Produkt(id = 103, nazwa = "Masło")
)
)
}
ListaProduktowKomponent(
produkty
= listaProduktow,
onUsunProdukt = { produktDoUsuniecia ->
// Aby Compose zauważył zmianę w mutableStateOf,
// musimy przypisać NOWĄ
listę bez usuniętego elementu.
listaProduktow = listaProduktow.filter { it.id != produktDoUsuniecia.id }
Toast.makeText(context, "Usunięto: ${produktDoUsuniecia.nazwa}", Toast.LENGTH_SHORT).show()
}
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListaProduktowKomponent(
produkty: List<Produkt>,
onUsunProdukt:
(Produkt) -> Unit
) {
val context = LocalContext.current
LazyColumn {
items(items = produkty, key = { it.id }) { produkt ->
Text(
text = produkt.nazwa,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { Toast.makeText(context, "Kliknięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
},
onDoubleClick
= { onUsunProdukt(produkt)
}
)
.padding(16.dp)
)
}
}
}
Przykład 6. Przykład pokazuje
jak najprościej wykorzystać obsługę listy z architektury ViewModel
data class
Produkt(val id: Int,
val nazwa: String)
// 2. ViewModel - prosty
i odporny na obroty ekranu
class ProstyZakupyViewModel : ViewModel() {
//
Zapamiętywana lista wewnątrz ViewModelu
val listaProduktow = mutableStateListOf<Produkt>()
//
Funkcja ładująca dane z bazy/serwera
fun zaladujDanePoczatkowe(produkty:
List<Produkt>) {
if (listaProduktow.isEmpty()) {
listaProduktow.addAll(produkty)
}
}
//
Intuicyjne usuwanie
fun usunProdukt(produkt:
Produkt) {
listaProduktow.remove(produkt)
}
}
// 3. Główny Ekran
@Composable
fun EkranUproszczony(
produktyZSerwera:
List<Produkt>,
viewModel: ProstyZakupyViewModel = viewModel()
) {
val context = LocalContext.current
//
NAPRAWA: LaunchedEffect dba o to, aby dane załadowały
się TYLKO RAZ przy starcie ekranu.
// Obrót telefonu nie uruchomi tej
funkcji ponownie.
LaunchedEffect(Unit) {
viewModel.zaladujDanePoczatkowe(produktyZSerwera)
}
ListaProduktowKomponent(
produkty
= viewModel.listaProduktow,
onUsunProdukt = { produkt ->
viewModel.usunProdukt(produkt)
Toast.makeText(context, "Usunięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
}
)
}
// 4. Komponent wyświetlający listę
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListaProduktowKomponent(
produkty: List<Produkt>,
onUsunProdukt:
(Produkt) -> Unit
) {
val context = LocalContext.current
LazyColumn {
items(items = produkty, key = { it.id }) { produkt ->
Text(
text = produkt.nazwa,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
Toast.makeText(context, "Kliknięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
},
onDoubleClick
= {
onUsunProdukt(produkt)
}
)
.padding(16.dp)
)
}
}
}
Wywołanie:
class MainActivity : ComponentActivity() {
override fun
onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//
1. Przygotowujemy dane startowe (np. symulacja pobrania z bazy danych/sieci)
val produktyNaStart = listOf(
Produkt(id = 1, nazwa = "Aparat fotograficzny"),
Produkt(id = 2, nazwa = "Słuchawki bezprzewodowe"),
Produkt(id = 3, nazwa = "Smartwatch")
)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// EkranNowoczesny(produktyZSerwera = produktyNaStart)
EkranUproszczony(produktyNaStart)
}
}
}
}
}
1.
Model danych (Produkt)
2.
Centrum Zarządzania Dane i Pamięcią (ProstyZakupyViewModel)
3.
Ekran Główny i Kontrola Cyklu Życia (EkranUproszczony)
4.
Niezależny Wygląd Listy (ListaProduktowKomponent)
o Pojedyncze
kliknięcie (onClick): Wyświetla szybki dymek z informacją (Toast).
o Podwójne
kliknięcie (onDoubleClick): Nie usuwa nic
samodzielnie, tylko wysyła sygnał w górę (onUsunProdukt(produkt)),
prosząc ViewModel o
zajęcie się sprawą.
Przykład 7. Przykład pokazuje jak wykorzystać obsługę listy z użyciem StateFlaw – architektura ViewModel
Oto kompletny, gotowy do uruchomienia przykład
implementacji z użyciem StateFlow. Jest to obecnie
oficjalnie zalecany przez Google standard budowania aplikacji na Androida.
Struktura ta opiera się na architekturze
reaktywnej: ViewModel wystawia strumień danych
(jak stacja radiowa), a interfejs w Compose
"słucha" tego strumienia i natychmiast przerysowuje się, gdy pojawią
się nowe wiadomości.
data class
Produkt(val id: Int,
val nazwa: String)
// 2. ViewModel
obsługujący StateFlow
class NowoczesnyZakupyViewModel(poczatkowaLista: List<Produkt>)
: ViewModel() {
//
Prywatny strumień mutowalny (wewnętrzny)
private val
_uiState = MutableStateFlow<List<Produkt>>(poczatkowaLista)
//
Publiczny strumień tylko do odczytu (zewnętrzny dla Compose)
val uiState: StateFlow<List<Produkt>> = _uiState.asStateFlow()
fun usunProdukt(produktDoUsuniecia: Produkt) {
//
Bezpieczna aktualizacja stanu poprzez filtrowanie
_uiState.update { aktualnaLista ->
aktualnaLista.filter { it.id != produktDoUsuniecia.id }
}
}
// Fabryka
do przekazywania parametrów na start
companion object
{
fun Factory(produkty:
List<Produkt>): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel>
create(modelClass: Class<T>): T {
return NowoczesnyZakupyViewModel(produkty) as T
}
}
}
}
// 3. Główny Ekran (Kontener)
@Composable
fun EkranNowoczesny(produktyZSerwera:
List<Produkt>) {
val context = LocalContext.current
//
Inicjalizacja ViewModelu z fabryką
val mojViewModel: NowoczesnyZakupyViewModel = viewModel(
factory = NowoczesnyZakupyViewModel.Factory(produktyZSerwera)
)
//
Konwersja strumienia StateFlow na stan zrozumiały dla
Jetpack Compose
val listaProduktow by mojViewModel.uiState.collectAsStateWithLifecycle()
ListaProduktowKomponent(
produkty
= listaProduktow,
onUsunProdukt = { produkt ->
mojViewModel.usunProdukt(produkt)
Toast.makeText(context, "Usunięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
}
)
}
// 4. Komponent interfejsu (czysty i niezależny)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListaProduktowKomponent(
produkty: List<Produkt>,
onUsunProdukt:
(Produkt) -> Unit
) {
val context = LocalContext.current
LazyColumn {
items(items = produkty, key = { it.id }) { produkt ->
Text(
text = produkt.nazwa,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { Toast.makeText(context, "Kliknięto: ${produkt.nazwa}", Toast.LENGTH_SHORT).show()
},
onDoubleClick
= { onUsunProdukt(produkt)
}
)
.padding(16.dp)
)
}
}
}
Wywołanie:
val produktyNaStart = listOf(
Produkt(id = 1, nazwa = "Aparat fotograficzny"),
Produkt(id = 2, nazwa = "Słuchawki bezprzewodowe"),
Produkt(id = 3, nazwa = "Smartwatch")
)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
EkranNowoczesny(produktyZSerwera = produktyNaStart)
}
}
}
}
}
Aby zrozumieć StateFlow, najlepiej wyobrazić sobie wzorzec projektowy Obserwator
(Publikacja-Subskrypcja). ViewModel posiada dane i rozgłasza ich zmianę, a interfejs
graficzny reaguje na te powiadomienia.
1.
Podział na _uiState oraz uiState
(Enkapsulacja)
W klasie ViewModel widzisz dwie zmienne o niemal
identycznej nazwie:
2.
Metoda _uiState.update { ... }
W tradycyjnym programowaniu usunąłbyś element z
listy poprzez .remove(). W świecie StateFlow listy są niezmodyfikowalne (Immutable).
Gdy usuwasz produkt, funkcja .update bierze aktualną listę, za pomocą .filter tworzy zupełnie nową listę (bez
klikniętego produktu) i "wpycha" tę nową listę do strumienia. StateFlow natychmiast zauważa, że
pojawił się zupełnie nowy obiekt listy i alarmuje subskrybentów.
3.
collectAsStateWithLifecycle()
To najważniejsza linijka po stronie Compose. StateFlow sam w sobie jest narzędziem czystego Kotlina (nie
wie, co to jest Android czy Compose). Funkcja collectAsStateWithLifecycle() to pomost. Mówi ona do Compose: "Zacznij słuchać tego
strumienia. Ilekroć ViewModel wrzuci tam nową listę,
przechwyć ją, zamień na stan i odśwież ekran".
Dodatkowo człon WithLifecycle dba o bezpieczeństwo pamięci –
jeśli użytkownik zminimalizuje aplikację i przejdzie do ekranu głównego
telefonu, ta funkcja automatycznie przestanie słuchać strumienia, oszczędzając
baterię i procesor.
Podsumowanie
przepływu danych (Unidirectional
Data Flow)
Dzięki temu dane krążą tylko w jedną stronę, co
drastycznie zmniejsza liczbę błędów w aplikacji.
II. Retrofit
Retrofit to biblioteka typu Type-Safe HTTP client dla systemów Android i języka Java/Kotlin,
rozwijana przez firmę Square. Jej głównym zadaniem
jest translacja interfejsu napisanego w Kotlinie na zapytania HTTP za pomocą
adnotacji (np. @GET, @POST).
W nowoczesnym programowaniu
na platformę Android standardem jest wykonywanie operacji sieciowych w sposób
asynchroniczny i nieblokujący głównego wątku (UI Thread). Wykorzystujemy do tego Kotlin
Coroutines oraz strukturę asynchronicznych strumieni danych Flow.
Uprawnienia AndroidManifest i konfiguracja build.gradle.kts
Do poprawnego działania
aplikacji wymagane jest zezwolenie na komunikację z siecią Internet oraz
dodanie odpowiednich zależności w pliku modułu aplikacji (build.gradle.kts).
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
//
Wtyczka wymagana do kompilacji klas serializowalnych KotlinX
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0"
}
W sekcji dependencies
dodaj najnowsze wersje bibliotek Retrofit, OkHttp oraz konwertera JSON:
dependencies {
// Retrofit 3.x core + OkHttp 4.12+ (z pełnym natywnym wsparciem dla Kotlina)
implementation("com.squareup.retrofit2:retrofit:3.0.0")
//
Nowoczesny i rekomendowany przez Google konwerter JSON
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")
}
Dodaj uprawnienie w pliku AndroidManifest.xml (wewnątrz znacznika <manifest>, przed <application>):
<uses-permission
android:name="android.permission.INTERNET" />
· Przykład 1.

1. Model Danych (Data
Transfer Object – DTO)
@Serializable
data class SimplePost(
@SerialName("userId") val userId: Int,
@SerialName("id") val id: Int,
@SerialName("title") val title: String,
@SerialName("body") val body: String
)
Ta część odpowiada za odzwierciedlenie
struktury danych, jakie zwraca serwer (w formacie JSON), na obiekt
zrozumiały dla języka Kotlin.
2. Interfejs API (Deklaracja kontraktu)
interface SimpleApiService {
@GET("posts")
suspend fun
getPosts(): List<SimplePost>
}
W tym miejscu programista
nie pisze kodu wykonawczego. Definiuje jedynie kontrakt – czyli to, jak
mają wyglądać zapytania do serwera. Właściwą implementację (kod, który naprawdę
łączy się z Internetem) Retrofit wygeneruje sam.
3. Konfiguracja
Klienta HTTP (Wzorzec Singleton)
object SimpleRetrofitClient {
private const val BASE_URL = "https://jsonplaceholder.typicode.com/"
private val
jsonConfig = Json
{
ignoreUnknownKeys = true
}
private val
contentType = "application/json".toMediaType()
val api: SimpleApiService
by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(jsonConfig.asConverterFactory(contentType))
.build()
.create(SimpleApiService::class.java)
}
}
Ta część odpowiada za
powołanie do życia i konfigurację mechanizmu sieciowego. Użycie słowa
kluczowego object implementuje wzorzec Singleton – gwarantuje, że w całej aplikacji będzie istniała
tylko jedna, współdzielona instancja klienta HTTP (co oszczędza pamięć RAM urządzenia).
4. Interfejs Użytkownika i Asynchroniczność (Jetpack Compose)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MinimalRetrofitScreen() {
var postsList by remember { mutableStateOf<List<SimplePost>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
//
Pobranie danych asynchronicznie przy uruchomieniu ekranu
LaunchedEffect(Unit) {
try {
val result = withContext(Dispatchers.IO) {
SimpleRetrofitClient.api.getPosts()
}
postsList
= result
isLoading
= false
}
catch (e: Exception)
{
errorMessage
= "Błąd: ${e.localizedMessage}"
isLoading = false
}
}
Scaffold(
topBar = { TopAppBar(title = { Text("Retrofit 3 + Serialization") }) }
) { paddingValues
->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
contentAlignment = Alignment.Center
)
{
if (isLoading) {
CircularProgressIndicator()
} else if
(errorMessage != null) {
Text(text = errorMessage!!,
color = MaterialTheme.colorScheme.error)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(postsList) { item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "${item.id}. ${item.title.take(20)}...",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = item.body,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
}
}
Ten fragment kodu to serce
interfejsu użytkownika (UI) i logiki asynchronicznej. W nowoczesnym frameworku Jetpack Compose kod odpowiedzialny za wygląd ekranu ściśle
współpracuje z kodem zarządzającym danymi.
1.
Adnotacja
i definicja stanu (Zmienne reaktywne)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MinimalRetrofitScreen() {
var postsList by remember { mutableStateOf<List<SimplePost>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
@OptIn(ExperimentalMaterial3Api::class): Niektóre zaawansowane komponenty wizualne z
biblioteki Material 3 (w tym przypadku TopAppBar) są oznaczone przez Google jako eksperymentalne. Ta
adnotacja to formalna zgoda programisty na ich użycie – bez niej kompilator
zgłosi błąd.
@Composable: Informuje kompilator, że ta funkcja służy do
rysowania interfejsu użytkownika. Funkcje kompozycjonalne
mogą być wywoływane tylko z poziomu innych funkcji @Composable.
mutableStateOf(...): Tworzy tzw. Stan
(State). W Compose interfejs
automatycznie nasłuchuje zmian tych zmiennych. Jeśli zmienisz wartość isLoading z true
na false, Compose natychmiast przerysuje (zrekomponuje)
odpowiednie fragmenty ekranu.
remember
{ ... }: Smartfony przerysowują ekran wielokrotnie (np. przy obrocie ekranu lub
animacji). Bez słowa remember, przy każdym
przerysowaniu zmienne resetowałyby się do wartości początkowych (np. lista
znowu robiłaby się pusta). remember nakazuje pamięci podręcznej telefonu zachować
aktualną wartość zmiennej pomiędzy przerysowaniami.
by: Słowo kluczowe w Kotlinie służące do delegacji
właściwości. Dzięki niemu możemy używać zmiennej postsList jak zwykłej listy (np. postsList = nowaLista), zamiast pisać dłuższego .value
(postsList.value = nowaLista).
2.
Efekt
uboczny i współbieżność (Asynchroniczne pobieranie danych)
LaunchedEffect(Unit) {
try {
val result = withContext(Dispatchers.IO) {
SimpleRetrofitClient.api.getPosts()
}
postsList
= result
isLoading
= false
} catch (e: Exception) {
errorMessage
= "Błąd: ${e.localizedMessage}"
isLoading = false
}
}
Ta sekcja odpowiada za
bezpieczne, jednorazowe pobranie danych w tle.
3. Szkielet ekranu i kontener (Scaffold
& Box)
Scaffold(
topBar = { TopAppBar(title = { Text("Retrofit
3 + Serialization")
}) }
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
4. Drzewo decyzyjne UI (Warunkowe renderowanie
widoku)
if (isLoading)
{
CircularProgressIndicator()
} else if (errorMessage != null) {
Text(text = errorMessage!!,
color = MaterialTheme.colorScheme.error)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(postsList)
{ item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = "${item.id}. ${item.title.take(20)}...", style =
MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = item.body,
style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
W Jetpack
Compose nie ukrywamy widoków za pomocą
właściwości typu view.visibility = View.GONE. Po prostu używamy standardowych instrukcji
warunkowych języka Kotlin (if-else).
Przykład 2.

package com.example.jpc_nav2
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
// 1. Model danych
@Serializable
data class Post(
@SerialName("userId") val uzytkownikId: Int,
@SerialName("id") val identyfikator: Int,
@SerialName("title") val tytul: String,
@SerialName("body") val tresc: String
)
// 2. Interfejs API
interface JsonPlaceholderApi {
@GET("posts/{id}")
suspend fun
getPostById(@Path("id") id: Int): Post
@GET("posts")
suspend fun
getAllPosts(): List<Post>
@PUT("posts/{id}")
suspend fun
updatePost(@Path("id") id: Int, @Body post: Post): Post
}
// 3. Osobny OBIEKT dla Klienta (Singleton)
object ApiClient {
private const
val BASE_URL = "https://jsonplaceholder.typicode.com/"
private val
jsonConfig = Json
{
ignoreUnknownKeys = true
}
private val
contentType = "application/json".toMediaType()
//
Leniwa (lazy) inicjalizacja interfejsu API
val api: JsonPlaceholderApi
by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(jsonConfig.asConverterFactory(contentType))
.build()
.create(JsonPlaceholderApi::class.java)
}
}
class MainActivity : ComponentActivity() {
override fun
onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
JsonPlaceholderScreen()
}
}
}
}
}
@Composable
fun JsonPlaceholderScreen() {
var postIdInput by remember { mutableStateOf("") }
var postsList by remember { mutableStateOf(listOf<Post>()) }
var errorMessage by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
OutlinedTextField(
value = postIdInput,
onValueChange = { postIdInput = it },
label = { Text("Wpisz ID posta (np. 1)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
//
1. GET - Pojedynczy post
Button(
onClick = {
if (postIdInput.isBlank())
{
errorMessage
= "Błąd: Wpisz najpierw ID!"
return@Button
}
coroutineScope.launch {
try {
errorMessage = ""
// Wywołanie z dedykowanego obiektu ApiClient
val post = ApiClient.api.getPostById(postIdInput.toInt())
postsList = listOf(post)
} catch (e: Exception) {
errorMessage = "Błąd
GET: ${e.localizedMessage}"
postsList
= emptyList()
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Pobierz jeden")
}
// 2. GET - Wszystkie posty
Button(
onClick = {
coroutineScope.launch {
try {
errorMessage = ""
// Wywołanie z dedykowanego obiektu ApiClient
val posts = ApiClient.api.getAllPosts()
postsList = posts
} catch (e: Exception) {
errorMessage = "Błąd
GET (Wszystkie): ${e.localizedMessage}"
postsList
= emptyList()
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Pobierz wszystkie")
}
// 3. PUT - Aktualizacja posta
Button(
onClick = {
val id = postIdInput.toIntOrNull()
?: 1
coroutineScope.launch {
try {
errorMessage = ""
val updatedPostData = Post(
uzytkownikId
= 42,
identyfikator = id,
tytul = "Tytuł zmieniony przez PUT i własny obiekt klienta",
tresc = "Architektura kodu staje się lepsza!"
)
// Wywołanie z dedykowanego obiektu ApiClient
val response = ApiClient.api.updatePost(id,
updatedPostData)
postsList = listOf(response)
} catch (e: Exception) {
errorMessage = "Błąd
PUT: ${e.localizedMessage}"
postsList
= emptyList()
}
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Test metody PUT")
}
Spacer(modifier = Modifier.height(16.dp))
if (errorMessage.isNotEmpty())
{
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
//
Lista kart z wynikami
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(postsList) { post ->
PostItemCard(post = post)
}
}
}
}
@Composable
fun PostItemCard(post: Post) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(
text = "ID: ${post.identyfikator} (User ID: ${post.uzytkownikId})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = post.tytul,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = post.tresc,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Przykład 3. Wykorzystanie ViewModel
Przeniosłem listy danych, błędy oraz całą logikę sieciową (coroutineScope) do nowo utworzonej klasy JsonPlaceholderViewModel. Wykorzystuje ona bezpieczny viewModelScope, a stan interfejsu jest zarządzany przez mutableStateOf bezpośrednio w ViewModelu.
Pamiętaj, aby w pliku build.gradle.kts upewnić się, że masz dodaną zależność:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") (lub nowszą).
package com.example.jpc_nav2
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
// 1. Model danych
@Serializable
data class Post(
@SerialName("userId") val uzytkownikId: Int,
@SerialName("id") val identyfikator: Int,
@SerialName("title") val tytul: String,
@SerialName("body") val tresc: String
)
// 2. Interfejs API
interface JsonPlaceholderApi {
@GET("posts/{id}")
suspend fun
getPostById(@Path("id") id: Int): Post
@GET("posts")
suspend fun
getAllPosts(): List<Post>
@PUT("posts/{id}")
suspend fun
updatePost(@Path("id") id: Int, @Body post: Post): Post
}
// 3. Osobny OBIEKT dla Klienta (Singleton)
object ApiClient {
private const
val BASE_URL = "https://jsonplaceholder.typicode.com/"
private val
jsonConfig = Json
{
ignoreUnknownKeys = true
}
private val
contentType = "application/json".toMediaType()
val api: JsonPlaceholderApi
by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(jsonConfig.asConverterFactory(contentType))
.build()
.create(JsonPlaceholderApi::class.java)
}
}
// 4. NOWOŚĆ: Klasa ViewModel
zarządzająca stanem i logiką sieciową
class JsonPlaceholderViewModel : ViewModel() {
// Pola
stanu komponentów Compose wystawione jako "read-only" dla widoku
var postsList by mutableStateOf(listOf<Post>())
private set
var errorMessage by mutableStateOf("")
private set
// GET -
Pojedynczy post
fun getPostById(idText: String) {
if (idText.isBlank())
{
errorMessage = "Błąd: Wpisz najpierw
ID!"
return
}
viewModelScope.launch {
try {
errorMessage = ""
val post = ApiClient.api.getPostById(idText.toInt())
postsList = listOf(post)
} catch (e: Exception) {
errorMessage = "Błąd GET: ${e.localizedMessage}"
postsList = emptyList()
}
}
}
// GET -
Wszystkie posty
fun getAllPosts() {
viewModelScope.launch {
try {
errorMessage = ""
val posts = ApiClient.api.getAllPosts()
postsList = posts
} catch (e: Exception) {
errorMessage = "Błąd GET (Wszystkie): ${e.localizedMessage}"
postsList = emptyList()
}
}
}
// PUT -
Aktualizacja posta
fun updatePost(idText: String) {
val id = idText.toIntOrNull()
?: 1
viewModelScope.launch {
try {
errorMessage = ""
val updatedPostData = Post(
uzytkownikId
= 42,
identyfikator = id,
tytul = "Tytuł zmieniony przez PUT i własny obiekt klienta",
tresc = "Architektura kodu staje się lepsza!"
)
val response = ApiClient.api.updatePost(id,
updatedPostData)
postsList = listOf(response)
} catch (e: Exception) {
errorMessage = "Błąd PUT: ${e.localizedMessage}"
postsList = emptyList()
}
}
}
}
class MainActivity : ComponentActivity() {
override fun
onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
JsonPlaceholderScreen()
}
}
}
}
}
@Composable
fun JsonPlaceholderScreen(viewModel: JsonPlaceholderViewModel
= viewModel()) {
// Stan
pola tekstowego zostaje lokalnie w widoku, bo dotyczy tylko wpisywania na tym
ekranie
var postIdInput by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
OutlinedTextField(
value = postIdInput,
onValueChange = { postIdInput = it },
label = { Text("Wpisz ID posta (np. 1)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
//
1. GET - Pojedynczy post
Button(
onClick = { viewModel.getPostById(postIdInput) },
modifier = Modifier.fillMaxWidth()
) {
Text("Pobierz jeden")
}
// 2. GET - Wszystkie posty
Button(
onClick = { viewModel.getAllPosts() },
modifier = Modifier.fillMaxWidth()
) {
Text("Pobierz wszystkie")
}
// 3. PUT - Aktualizacja posta
Button(
onClick = { viewModel.updatePost(postIdInput) },
modifier = Modifier.fillMaxWidth()
) {
Text("Test metody PUT")
}
Spacer(modifier = Modifier.height(16.dp))
//
Odczyt błędu bezpośrednio z ViewModelu
if (viewModel.errorMessage.isNotEmpty()) {
Text(
text = viewModel.errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
//
Lista kart z wynikami powiązana ze stanem z ViewModelu
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(viewModel.postsList) {
post ->
PostItemCard(post = post)
}
}
}
}
@Composable
fun PostItemCard(post: Post) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(
text = "ID: ${post.identyfikator} (User ID: ${post.uzytkownikId})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = post.tytul,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = post.tresc,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Przykład 4. Aplikacja komunikująca się z układem ESP8266
Program dla płytki:
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
const char* ssid = "PANS-KROSNO";
const char* password = "haslo";
const int pinOut = D5;
ESP8266WebServer server(80);
void handleGet() {
server.send(200, "text/plain", "To jest
GET!");
}
void handleGetPar() {
String state = server.arg("state");
if (state == "on") {
digitalWrite(pinOut, HIGH);
server.send(200, "text/plain", "1"); // sygnaĹ: wĹÄ�czone
} else if (state == "off") {
digitalWrite(pinOut, LOW);
server.send(200, "text/plain", "0"); // sygnaĹ: wyĹÄ�czone
} else {
server.send(400, "text/plain", "Nieznany
parametr: powinno być: state=on lub state=off");
}
}
void handlePost() {
String body
= server.arg("state");
server.send(200, "text/plain", "Odebrano
POST: " + body);
}
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
delay(500);
pinMode(pinOut, OUTPUT);
digitalWrite(pinOut, LOW); // startowo wyĹÄ�czone
server.on("/get", HTTP_GET, handleGet);
server.on("/getpar", HTTP_GET, handleGetPar);
server.on("/post", HTTP_POST, handlePost);
server.begin();
}
void loop() {
server.handleClient();
}
Program na urządzenie
mobilne – Android:

Wymagana zależność:
TA LINIA JEST KLUCZOWA -
dedykowany konwerter dla String/typów prostych
// implementation("com.squareup.retrofit2:converter-scalars:2.11.0")
package com.example.jpc_nav2
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
// 1. Model danych z adnotacjami @Serializable i
@SerialName
@Serializable
data class EspResponse(
@SerialName("ledState") val stanDiody: String,
@SerialName("message") val komunikat: String
)
// 2. Interfejs API (odbiera surowy String, aby
nie wywalić programu na kodzie Arduino)
interface EspApi {
@GET("getpar")
suspend fun
controlLed(@Query("state") state: String): String
}
// 3. Singleton dla Klienta - tutaj mapujemy tekst
na nasz serylizowalny model danych
object EspClient {
private const
val BASE_URL = "http://192.168.1.122/"
private val
apiService: EspApi by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(ScalarsConverterFactory.create())
.build()
.create(EspApi::class.java)
}
//
Funkcja pośrednicząca, która zwraca ładny obiekt zgodny z Twoją nową strukturą
suspend fun
sendCommand(state: String): EspResponse
{
val rawResponse = apiService.controlLed(state)
return
when (rawResponse)
{
"1" -> EspResponse(stanDiody = "WŁĄCZONA", komunikat = "Dioda została
pomyślnie włączona!")
"0" -> EspResponse(stanDiody = "WYŁĄCZONA", komunikat = "Dioda została
pomyślnie wyłączona!")
else -> EspResponse(stanDiody
= "NIEZNANY",
komunikat = rawResponse) // np. komunikat o błędzie z bloku 'else'
w Arduino
}
}
}
class MainActivity : ComponentActivity() {
override fun
onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
EspControlScreen()
}
}
}
}
}
@Composable
fun EspControlScreen() {
var ledStateText by remember { mutableStateOf("Nieznany (Pobierz
stan)") }
var statusMessage by remember { mutableStateOf("Oczekiwanie na
akcję...") }
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Nagłówek wizualny ze stanem diody
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp),
colors = CardDefaults.cardColors(
containerColor = if (ledStateText == "WŁĄCZONA")
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "STAN DIODY", style = MaterialTheme.typography.labelLarge)
Text(
text = ledStateText,
style = MaterialTheme.typography.headlineMedium,
color = if (ledStateText == "WŁĄCZONA")
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// PRZYCISK 1: Włącz diodę (?state=on)
Button(
onClick = {
coroutineScope.launch {
try {
statusMessage = "Wysyłanie
sygnału: ON..."
// Wywołanie korzystające z nowego modelu danych EspResponse
val response: EspResponse = EspClient.sendCommand("on")
ledStateText = response.stanDiody
statusMessage
= response.komunikat
} catch (e: Exception)
{
statusMessage = "Błąd
połączenia: ${e.localizedMessage}"
}
}
},
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
) {
Text("Włącz diodę")
}
// PRZYCISK 2: Wyłącz diodę (?state=off)
Button(
onClick = {
coroutineScope.launch {
try {
statusMessage = "Wysyłanie
sygnału: OFF..."
// Wywołanie korzystające z nowego modelu danych EspResponse
val response: EspResponse = EspClient.sendCommand("off")
ledStateText = response.stanDiody
statusMessage
= response.komunikat
} catch (e: Exception)
{
statusMessage = "Błąd
połączenia: ${e.localizedMessage}"
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
) {
Text("Wyłącz diodę")
}
// PRZYCISK 3: Sprawdź stan
Button(
onClick = {
coroutineScope.launch {
try {
statusMessage = "Sprawdzanie
połączenia..."
// Wywołanie korzystające z nowego modelu danych EspResponse
val response: EspResponse = EspClient.sendCommand("status")
statusMessage = "Serwer
ESP odpowiada: ${response.komunikat}"
} catch (e: Exception)
{
statusMessage = "Błąd:
Brak odpowiedzi z ESP (${e.localizedMessage})"
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)
) {
Text("Sprawdź łączność z ESP")
}
// Log tekstowy na dole ekranu informujący o operacjach
Text(
text = statusMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}