Tugas PPB Pertemuan 14
Nathanael Valen Susilo
5025231099
News App dengan REST API
News App adalah aplikasi Android modern untuk menampilkan berita terbaru dari NewsAPI v2. Aplikasi ini dibuat dengan Kotlin, Jetpack Compose, Retrofit, StateFlow, Room, dan arsitektur MVVM. Tujuan aplikasi ini bukan hanya mengambil data berita dari internet, tetapi juga menunjukkan bagaimana sebuah aplikasi Android bisa disusun secara rapi: tampilan dipisahkan dari logika, data API dipisahkan dari database lokal, dan setiap fitur memiliki tanggung jawab yang jelas.
Saat aplikasi dibuka, pengguna akan melihat daftar berita terbaru. Setiap halaman berisi maksimal 10 berita. Pengguna dapat mencari berita berdasarkan kata kunci, membuka detail berita, menyimpan berita ke bookmark, dan membaca artikel lengkap melalui tombol menuju sumber aslinya. Karena NewsAPI tidak menyediakan isi artikel penuh, halaman detail hanya menampilkan data yang tersedia dari API: gambar, judul, sumber, penulis, tanggal, deskripsi, dan potongan isi artikel. Untuk membaca artikel lengkap, pengguna diarahkan ke website asli berita tersebut.
Fitur Utama
- Menampilkan berita terbaru dari endpoint `top-headlines`.
- Mencari berita melalui endpoint `everything`.
- Pagination manual dengan tombol `Previous` dan `Next`.
- Setiap halaman memuat 10 berita.
- Bookmark berita secara lokal menggunakan Room Database.
- Snackbar muncul saat artikel disimpan atau dihapus dari bookmark.
- Halaman detail berita dengan tombol `Open original article`.
Struktur Project
Project ini tetap memakai satu module utama, yaitu `app`, tetapi isi kodenya dipisah ke beberapa package agar lebih modular.
com.example.newsapp
|-- core
| |-- common // constants dan error helper
| `-- util // formatter tanggal, cleaner teks, helper URL
|-- data
| |-- remote // Retrofit API, DTO, mapper API
| |-- local // Room database, DAO, entity bookmark
| `-- repository // implementasi repository
|-- domain
| |-- model // model Article dan NewsPage
| |-- repository // kontrak repository
| `-- usecase // aturan/use case aplikasi
|-- presentation
| |-- navigation // route dan NavHost
| |-- home // HomeScreen, HomeViewModel, HomeUiState
| |-- detail // ArticleDetailScreen
| `-- components // komponen UI reusable
|-- di // AppContainer untuk dependency sederhana
`-- MainActivity.kt
Arsitektur MVVM
Aplikasi ini menggunakan pola MVVM dengan alur data seperti berikut:
Compose Screen
-> ViewModel
-> UseCase
-> Repository
-> Retrofit API / Room Database
Penjelasan:
- `Compose Screen` menampilkan UI dan menerima aksi pengguna.
- `ViewModel` mengatur state seperti loading, daftar berita, query search, halaman aktif, error, dan bookmark.
- `UseCase` berisi aksi utama seperti mengambil headline, mencari berita, dan toggle bookmark.
- `Repository` menghubungkan aplikasi dengan sumber data.
- `Retrofit API` mengambil berita dari NewsAPI.
- `Room Database` menyimpan bookmark di perangkat.
State Halaman Utama
State utama halaman home disimpan dalam `HomeUiState`. State ini menjadi sumber data yang dibaca oleh Compose.
Potongan kode:
```kotlin
data class HomeUiState(
val articles: List<Article> = emptyList(),
val query: String = "",
val selectedTab: HomeTab = HomeTab.Latest,
val isLoadingInitial: Boolean = false,
val isRefreshing: Boolean = false,
val isLoadingNextPage: Boolean = false,
val errorMessage: String? = null,
val page: Int = 1,
val canLoadMore: Boolean = true,
)
```
Dengan state ini, UI tidak perlu tahu proses detail dari API atau database. UI cukup membaca nilai state lalu menampilkan loading, data, error, atau empty state sesuai kondisi.
Mengambil Berita dari API
Repository bertugas mengambil berita dari NewsAPI. Untuk berita terbaru, aplikasi memakai endpoint `/v2/top-headlines`. Untuk pencarian, aplikasi memakai endpoint `/v2/everything`.
Potongan kode service Retrofit:
```kotlin
@GET("v2/top-headlines")
suspend fun getTopHeadlines(
@Query("country") country: String,
@Query("pageSize") pageSize: Int,
@Query("page") page: Int,
@Query("apiKey") apiKey: String,
): Response<NewsResponseDto>
@GET("v2/everything")
suspend fun searchNews(
@Query("q") query: String,
@Query("sortBy") sortBy: String,
@Query("language") language: String,
@Query("pageSize") pageSize: Int,
@Query("page") page: Int,
@Query("apiKey") apiKey: String,
): Response<NewsResponseDto>
```
Setelah data diterima, DTO dari API diubah menjadi model domain `Article`. Bagian ini penting agar UI tidak bergantung langsung pada struktur JSON dari API.
Potongan kode mapping:
```kotlin
fun ArticleDto.toDomain(isBookmarked: Boolean): Article? {
val articleUrl = url?.takeIf { it.isNotBlank() } ?: return null
val articleTitle = cleanNewsText(title) ?: return null
return Article(
sourceId = source?.id,
sourceName = cleanNewsText(source?.name) ?: "Unknown Source",
author = cleanNewsText(author),
title = articleTitle,
description = cleanNewsText(description),
url = articleUrl,
imageUrl = urlToImage?.takeIf { it.isNotBlank() },
publishedAt = publishedAt,
content = cleanNewsText(content),
isBookmarked = isBookmarked,
)
}
```
Pagination
Pagination dibuat per halaman, bukan infinite scroll. Satu halaman mengambil 10 berita dari API.
Konstanta page size:
```kotlin
object Constants {
const val DEFAULT_PAGE_SIZE = 10
}
```
Saat tombol `Next` ditekan, aplikasi mengambil halaman berikutnya. Saat `Previous` ditekan, aplikasi mengambil halaman sebelumnya.
Potongan kode:
```kotlin
fun loadNextPage() {
val state = _uiState.value
if (
state.selectedTab == HomeTab.Bookmarks ||
state.isLoadingInitial ||
state.isRefreshing ||
state.isLoadingNextPage ||
!state.canLoadMore
) {
return
}
loadRemotePage(targetPage = state.page + 1)
}
fun loadPreviousPage() {
val state = _uiState.value
if (
state.selectedTab == HomeTab.Bookmarks ||
state.isLoadingInitial ||
state.isRefreshing ||
state.isLoadingNextPage ||
state.page <= 1
) {
return
}
loadRemotePage(targetPage = state.page - 1)
}
```
Data lama tidak ditumpuk terus-menerus. Saat pindah halaman, daftar berita diganti dengan data halaman baru agar jumlah berita tetap sesuai batas 10 item per halaman.
Search Berita
Search dilakukan dari `HomeViewModel`. Saat pengguna mengetik query, aplikasi menyimpan query ke state, menunggu sebentar dengan debounce, lalu mengambil ulang data dari halaman pertama.
Potongan kode:
```kotlin
fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query, errorMessage = null) }
if (_uiState.value.selectedTab == HomeTab.Bookmarks) {
publishVisibleArticles()
return
}
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(450)
loadFirstPage()
}
}
```
Jika query kosong, aplikasi mengambil top headlines. Jika query berisi teks, aplikasi memakai endpoint search.
Potongan kode:
```kotlin
private suspend fun loadPage(page: Int) =
if (_uiState.value.query.isBlank()) {
getTopHeadlinesUseCase(page = page, pageSize = Constants.DEFAULT_PAGE_SIZE)
} else {
searchNewsUseCase(
query = _uiState.value.query.trim(),
page = page,
pageSize = Constants.DEFAULT_PAGE_SIZE,
)
}
```
Bookmark
Bookmark disimpan di database lokal menggunakan Room. Artikel disimpan berdasarkan `url`, karena URL artikel dianggap unik.
Entity bookmark:
```kotlin
@Entity(tableName = "bookmarks")
data class BookmarkEntity(
@PrimaryKey val url: String,
val sourceId: String?,
val sourceName: String,
val author: String?,
val title: String,
val description: String?,
val imageUrl: String?,
val publishedAt: String?,
val content: String?,
)
```
Saat ikon bookmark ditekan, repository mengecek apakah artikel sudah tersimpan. Jika sudah, artikel dihapus. Jika belum, artikel disimpan.
Potongan kode:
```kotlin
override suspend fun toggleBookmark(article: Article) {
if (bookmarkDao.isBookmarked(article.url)) {
bookmarkDao.deleteBookmark(article.url)
} else {
bookmarkDao.upsertBookmark(article.toBookmarkEntity())
}
}
```
Setelah aksi bookmark, UI menampilkan snackbar agar pengguna mendapat feedback.
Potongan kode:
```kotlin
snackbarHostState.showSnackbar(
if (article.isBookmarked) {
"Removed from bookmarks"
} else {
"Saved to bookmarks"
},
)
```
Halaman Detail
Halaman detail menampilkan informasi artikel yang dipilih. Data artikel berasal dari daftar berita atau bookmark yang sudah dimuat sebelumnya.
Bagian yang ditampilkan:
- Gambar artikel.
- Judul berita.
- Nama sumber.
- Penulis jika tersedia.
- Tanggal publikasi.
- Potongan isi atau deskripsi.
- Tombol untuk membuka artikel asli.
- Ikon bookmark.
Potongan kode isi detail:
```kotlin
Text(
text = article.title,
fontFamily = FontFamily.Serif,
fontSize = 30.sp,
lineHeight = 36.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = article.content
?: article.description
?: "Open the original article to continue reading.",
)
```
Perlu dicatat bahwa `content` dari NewsAPI bukan isi artikel penuh. Biasanya API hanya mengirim preview pendek. Karena itu aplikasi menyediakan tombol:
```kotlin
Button(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(article.url))
context.startActivity(intent)
},
) {
Text("Open original article")
}
```
Membersihkan Teks dari API
Beberapa artikel dari API bisa membawa teks HTML atau tanda seperti `[+2726 chars]`. Karena itu aplikasi membersihkan teks sebelum ditampilkan.
Potongan kode:
```kotlin
fun cleanNewsText(value: String?): String? {
if (value.isNullOrBlank()) return null
val decoded = Html.fromHtml(value, Html.FROM_HTML_MODE_LEGACY).toString()
return decoded
.replace(truncatedMarkerRegex, "")
.replace(repeatedWhitespaceRegex, " ")
.replace(excessiveNewLinesRegex, "\n\n")
.trim()
.takeIf { it.isNotBlank() && it != "." }
}
```
Konfigurasi API Key
API key disimpan di file `local.properties`.
```properties
NEWS_API_KEY=isi_api_key_di_sini
```
Nilai ini dibaca ke aplikasi melalui `BuildConfig.NEWS_API_KEY`. Dengan cara ini, API key tidak perlu ditulis langsung di file Kotlin. API key bisa didapatkan melalui https://newsapi.org/
Komentar
Posting Komentar