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/

Demo Aplikasi




Komentar

Postingan populer dari blog ini

Tugas PPB Pertemuan 12

Tugas PPB Pertemuan 1

Tugas PPB Pertemuan 7