Pertemuan 14 - News App
Nama: Kadek Fajar Pramartha Yasodana
Terdapat beberapa hal yang perlu dipersiapkan terlebih dahulu agar aplikasi bisa dijalankan dengan lancar menggunakan dependenciesnya.
NRP: 5025231185
Kelas: PPB (C)
Pertemuan 14 - News App
Dalam tugas ini saya membuat sebuah applikasi News App yang menggunakan data asli dari API yang didapatkan dari News API. Penggunaan api call menggunakan library retrofit yang menyediakan http call dengan interface. Jika kita membandingkan dengan pembuatan aplikasi pada web, terdapat sedikit hal yang berbeda jika dilihat dari cara fetching, dengan retrofit menggunakan interface dan web biasanya menggunakan axios. Struktur aplikasi adalah sebagai berikut
Dalam tugas ini saya membuat sebuah applikasi News App yang menggunakan data asli dari API yang didapatkan dari News API. Penggunaan api call menggunakan library retrofit yang menyediakan http call dengan interface. Jika kita membandingkan dengan pembuatan aplikasi pada web, terdapat sedikit hal yang berbeda jika dilihat dari cara fetching, dengan retrofit menggunakan interface dan web biasanya menggunakan axios. Struktur aplikasi adalah sebagai berikut
Terdapat beberapa hal yang perlu dipersiapkan terlebih dahulu agar aplikasi bisa dijalankan dengan lancar menggunakan dependenciesnya.
Tambahkan hal hal berikut Pada
AndroidManifest.xml di <manifest/>
<uses-permission android:name="android.permission.INTERNET" />
local.properties
NEWS_API_KEY=API_KEY_DARI_REGISTRASI
build.gradle.kts Module:app pada dependencies
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
build.gradle.kts Module:app di awal
import java.util.Properties
val localProps = Properties().apply {
val f = rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
build.gradle.kts Module:app di defaultConfig
buildConfigField("String", "NEWS_API_KEY", "\"${localProps.getProperty("NEWS_API_KEY", "")}\"")
buildFeatures {
compose = true
buildConfig = true
}
Setelah hal ini sudah di setup, maka kita bisa melanjutkan untuk mengimplementasi aplikasi
RetrofitInstance.kt
package com.fajary.newsapp.data.api
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitInstance {
private const val BASE_URL = "https://newsapi.org/"
private val client by lazy {
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
}
val api: ApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
File ini bertanggung jawab untuk melakukan konfigurasi koneksi ke layanan News API. Objek ini menyimpan alamat dasar API (BASE_URL) serta membangun OkHttpClient yang dilengkapi dengan HttpLoggingInterceptor untuk membantu proses debugging dengan menampilkan detail request dan response. Selanjutnya, objek Retrofit dibentuk menggunakan converter Gson agar data JSON yang diterima dapat langsung diubah menjadi objek Kotlin. Dengan adanya file ini, seluruh bagian aplikasi dapat menggunakan konfigurasi jaringan yang sama sehingga kode menjadi lebih terpusat dan mudah dipelihara. Pada pengembangan web, konsep ini mirip dengan konfigurasi global axios beserta pengaturan baseURL.
ApiService.kt
package com.fajary.newsapp.data.api
import com.fajary.newsapp.BuildConfig
import com.fajary.newsapp.data.model.NewsResponse
import retrofit2.http.GET
import retrofit2.http.Query
interface ApiService {
@GET("v2/top-headlines")
suspend fun getTopHeadlines(
@Query("country") country: String = "us",
@Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY
): NewsResponse
@GET("v2/everything")
suspend fun searchNews(
@Query("q") query: String,
@Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY
): NewsResponse
}
File ini berisi definisi endpoint yang digunakan aplikasi untuk berkomunikasi dengan News API. Interface ini memanfaatkan Retrofit sehingga setiap fungsi secara otomatis akan diterjemahkan menjadi request HTTP yang sesuai. Fungsi getTopHeadlines() digunakan untuk mengambil berita utama berdasarkan negara tertentu, sedangkan searchNews() digunakan untuk mencari artikel berdasarkan kata kunci yang dimasukkan pengguna. Penggunaan parameter bawaan juga membuat API key dan negara default dapat digunakan tanpa harus selalu diberikan secara manual. Pada pengembangan web, bagian ini serupa dengan kumpulan fungsi fetch() atau axios yang digunakan untuk melakukan request ke backend.
Article.kt
package com.fajary.newsapp.data.model
data class Article(
val source: Source?,
val author: String?,
val title: String,
val description: String?,
val url: String,
val urlToImage: String?,
val publishedAt: String?,
val content: String?
)
File ini mendefinisikan model data yang merepresentasikan sebuah artikel berita. Setiap atribut di dalamnya menggambarkan informasi yang dapat diterima dari News API, seperti judul berita, nama penulis, deskripsi, gambar, tanggal publikasi, dan isi artikel. Dengan menggunakan data class, proses pemetaan data dari format JSON menjadi objek Kotlin dapat dilakukan secara otomatis oleh Retrofit dan Gson. Struktur ini juga memudahkan bagian UI untuk mengakses informasi yang diperlukan tanpa harus memproses data mentah dari server secara langsung. Pada pengembangan web, file ini memiliki peran yang mirip dengan interface atau tipe data pada TypeScript.
NewsResponse.kt
package com.fajary.newsapp.data.model
data class NewsResponse(
val status: String,
val totalResults: Int,
val articles: List<Article>
)
File ini digunakan untuk merepresentasikan keseluruhan struktur respons yang diberikan oleh News API. Objek ini menyimpan informasi mengenai status permintaan, jumlah total artikel yang tersedia, serta daftar artikel yang berhasil diperoleh. Dengan memisahkan struktur respons dari model artikel, aplikasi dapat membaca hasil request secara lebih terorganisir dan sesuai dengan format yang disediakan oleh server. Hal ini juga memudahkan proses pengolahan data sebelum ditampilkan ke antarmuka pengguna. Pada pengembangan web, konsep ini serupa dengan objek hasil response dari request API.
Source.kt
package com.fajary.newsapp.data.model
data class Source(
val id: String?,
val name: String?
)
File ini mendefinisikan model data yang digunakan untuk menyimpan informasi mengenai sumber berita. Data yang disimpan meliputi identitas sumber dan nama media yang menerbitkan artikel tersebut. Pemisahan model sumber berita dari model artikel dilakukan untuk menjaga struktur data tetap modular dan mudah dikembangkan apabila di kemudian hari diperlukan informasi tambahan mengenai media yang bersangkutan. Dengan demikian, setiap artikel dapat memiliki informasi sumber yang tersusun secara lebih rapi. Pada pengembangan web, struktur seperti ini mirip dengan nested object pada JSON atau interface yang saling berhubungan.
NewsRepository.kt
package com.fajary.newsapp.data.repository
import com.fajary.newsapp.data.api.ApiService
import com.fajary.newsapp.data.api.RetrofitInstance
import com.fajary.newsapp.data.model.NewsResponse
class NewsRepository(
private val api: ApiService = RetrofitInstance.api
) {
suspend fun getTopHeadlines(country: String = "us"): NewsResponse =
api.getTopHeadlines(country = country)
suspend fun searchNews(query: String): NewsResponse =
api.searchNews(query = query)
}
File ini berfungsi sebagai lapisan penghubung antara ViewModel dan layanan API. Repository bertanggung jawab untuk mengambil data dari ApiService dan menyediakan fungsi-fungsi yang lebih sederhana agar dapat digunakan oleh ViewModel. Dengan adanya lapisan ini, logika pengambilan data dipisahkan dari logika antarmuka pengguna sehingga struktur aplikasi menjadi lebih bersih dan mengikuti prinsip separation of concerns. Selain meningkatkan keterbacaan kode, pendekatan ini juga mempermudah proses pengujian dan pemeliharaan aplikasi di masa mendatang. Pada pengembangan web, bagian ini serupa dengan service layer yang digunakan untuk memisahkan akses data dari komponen tampilan.
NewsViewModelInterface.kt
package com.fajary.newsapp.viewmodel
import com.fajary.newsapp.data.model.Article
import kotlinx.coroutines.flow.StateFlow
interface NewsViewModelInterface {
val mainPageType: StateFlow<String>
val selectedArticleDetail: StateFlow<Article?>
val newsHeadlineLoadingState: StateFlow<String>
val newsHeadlines: StateFlow<List<Article>>
val newsSearchLoadingState: StateFlow<String>
val newsSearches: StateFlow<List<Article>>
fun setMainPageType(type: String)
fun searchNews(query: String)
fun selectArticleDetail(article: Article)
}
File ini mendefinisikan kumpulan state dan fungsi yang digunakan oleh halaman-halaman pada aplikasi. Interface ini dibuat agar komponen UI tidak bergantung langsung pada implementasi NewsViewModel, sehingga sumber data dapat diganti sesuai kebutuhan. Salah satu alasan utama penggunaan interface ini adalah karena fitur Preview pada Jetpack Compose tidak dapat menggunakan ViewModel yang sesungguhnya, sehingga diperlukan implementasi lain yang lebih sederhana untuk menyediakan data contoh. Dengan demikian, baik NewsViewModel maupun NewsViewModelMock dapat digunakan oleh UI melalui kontrak yang sama tanpa perlu mengubah kode tampilan. Pada pengembangan web, konsep ini mirip dengan penggunaan interface atau contract yang memungkinkan komponen React menerima sumber data yang berbeda, misalnya antara data asli dan mock data.
NewsViewModelMock.kt
package com.fajary.newsapp.viewmodel
import com.fajary.newsapp.data.model.Article
import com.fajary.newsapp.data.model.Source
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class NewsViewModelMock : NewsViewModelInterface {
private val sampleArticles = listOf(
Article(
source = Source(id = "bbc-news", name = "BBC News"),
author = "John Doe",
title = "Breaking: Major Event Happens Around the World",
description = "A significant event has occurred that is affecting millions of people globally.",
url = "https://bbc.com/news/sample",
urlToImage = "https://via.placeholder.com/400x200",
publishedAt = "2024-01-15T10:30:00Z",
content = "Full article content goes here. This is a longer text that represents the body of the news article."
),
Article(
source = Source(id = "cnn", name = "CNN"),
author = "Jane Smith",
title = "Technology Giants Announce New AI Breakthrough",
description = "Leading tech companies reveal groundbreaking advances in artificial intelligence.",
url = "https://cnn.com/tech/sample",
urlToImage = "https://via.placeholder.com/400x200",
publishedAt = "2024-01-15T08:00:00Z",
content = "The technology industry has seen remarkable progress this year."
),
Article(
source = Source(id = "reuters", name = "Reuters"),
author = "Alice Johnson",
title = "Markets React to Global Economic Changes",
description = "Stock markets around the world respond to the latest economic data.",
url = "https://reuters.com/markets/sample",
urlToImage = null,
publishedAt = "2024-01-14T16:45:00Z",
content = "Global markets showed mixed reactions today."
)
)
override val mainPageType: StateFlow<String> = MutableStateFlow("home")
override val selectedArticleDetail: StateFlow<Article?> = MutableStateFlow(sampleArticles.first())
override val newsHeadlineLoadingState: StateFlow<String> = MutableStateFlow("success")
override val newsHeadlines: StateFlow<List<Article>> = MutableStateFlow(sampleArticles)
override val newsSearchLoadingState: StateFlow<String> = MutableStateFlow("success")
override val newsSearches: StateFlow<List<Article>> = MutableStateFlow(sampleArticles.take(2))
override fun setMainPageType(type: String) {}
override fun searchNews(query: String) {}
override fun selectArticleDetail(article: Article) {}
}
File ini merupakan implementasi sederhana dari NewsViewModelInterface yang menggunakan data statis sebagai pengganti data dari API. Kelas ini dibuat terutama untuk mendukung fitur Preview pada Jetpack Compose, karena Preview tidak dapat menginisialisasi ViewModel yang sebenarnya beserta dependensinya. Dengan menyediakan data contoh, pengembang dapat melihat dan mengembangkan tampilan aplikasi secara langsung tanpa perlu menjalankan aplikasi atau melakukan request ke internet. Pendekatan ini juga membantu memisahkan proses pengembangan antarmuka dari proses pengambilan data yang sebenarnya. Pada pengembangan web, konsep ini serupa dengan penggunaan mock data atau dummy service saat membangun dan menguji komponen React sebelum terhubung dengan backend.
NewsViewModel.kt
package com.fajary.newsapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fajary.newsapp.data.model.Article
import com.fajary.newsapp.data.repository.NewsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class NewsViewModel(private val repository: NewsRepository) : ViewModel(), NewsViewModelInterface {
private val _mainPageType = MutableStateFlow("home")
override val mainPageType: StateFlow<String> = _mainPageType
private val _selectedArticleDetail = MutableStateFlow<Article?>(null)
override val selectedArticleDetail: StateFlow<Article?> = _selectedArticleDetail
private val _newsHeadlineLoadingState = MutableStateFlow("idle") // idle | loading | success | error
override val newsHeadlineLoadingState: StateFlow<String> = _newsHeadlineLoadingState
private val _newsHeadlines = MutableStateFlow<List<Article>>(emptyList())
override val newsHeadlines: StateFlow<List<Article>> = _newsHeadlines
private val _newsSearchLoadingState = MutableStateFlow("idle")
override val newsSearchLoadingState: StateFlow<String> = _newsSearchLoadingState
private val _newsSearches = MutableStateFlow<List<Article>>(emptyList())
override val newsSearches: StateFlow<List<Article>> = _newsSearches
init {
loadTopHeadlines()
}
private fun loadTopHeadlines() {
viewModelScope.launch {
_newsHeadlineLoadingState.value = "loading"
try {
val response = repository.getTopHeadlines()
_newsHeadlines.value = response.articles.filter { it.title != "[Removed]" }
_newsHeadlineLoadingState.value = "success"
} catch (e: Exception) {
_newsHeadlineLoadingState.value = "error"
}
}
}
override fun setMainPageType(type: String) {
_mainPageType.value = type
}
override fun searchNews(query: String) {
if (query.isBlank()) return
viewModelScope.launch {
_newsSearchLoadingState.value = "loading"
try {
val response = repository.searchNews(query)
_newsSearches.value = response.articles.filter { it.title != "[Removed]" }
_newsSearchLoadingState.value = "success"
} catch (e: Exception) {
_newsSearchLoadingState.value = "error"
}
}
}
override fun selectArticleDetail(article: Article) {
_selectedArticleDetail.value = article
}
}
File ini merupakan pusat pengelolaan state dan logika bisnis dari aplikasi. ViewModel bertugas mengatur halaman yang sedang aktif, menyimpan artikel yang dipilih pengguna, memuat headline berita dari repository, serta menangani proses pencarian artikel. Seluruh data yang dapat berubah disimpan menggunakan MutableStateFlow, sedangkan UI mengaksesnya melalui StateFlow agar perubahan data dapat diamati secara aman. Penggunaan coroutine melalui viewModelScope memungkinkan proses request ke internet berjalan secara asynchronous tanpa menghambat thread utama. Dengan memisahkan logika bisnis dari tampilan, kode menjadi lebih mudah dipelihara dan mengikuti pola arsitektur MVVM. Pada pengembangan web, perannya mirip dengan state management yang terdapat pada React.
NewsCard.kt
package com.fajary.newsapp.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.fajary.newsapp.data.model.Article
@Composable
fun NewsCard(
article: Article,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
article.urlToImage?.let { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = article.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
)
}
Column(modifier = Modifier.padding(12.dp)) {
article.source?.name?.let { sourceName ->
Text(
text = sourceName.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = article.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
article.description?.let { desc ->
Spacer(modifier = Modifier.height(6.dp))
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
article.publishedAt?.let { date ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = date.take(10),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
File ini berisi komponen antarmuka yang digunakan untuk menampilkan informasi singkat dari sebuah artikel dalam bentuk kartu. Komponen ini menampilkan gambar berita, nama sumber, judul artikel, deskripsi singkat, dan tanggal publikasi. Karena dibuat dalam bentuk composable yang terpisah, komponen ini dapat digunakan kembali pada berbagai halaman tanpa perlu menduplikasi kode yang sama. Desain seperti ini membantu menjaga konsistensi tampilan sekaligus meningkatkan modularitas aplikasi. Pada pengembangan web, komponen ini memiliki fungsi yang mirip dengan reusable component pada React.
HomeScreen.kt
package com.fajary.newsapp.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fajary.newsapp.ui.component.NewsCard
import com.fajary.newsapp.viewmodel.NewsViewModelInterface
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
vm: NewsViewModelInterface,
) {
val headlines by vm.newsHeadlines.collectAsState()
val loadingState by vm.newsHeadlineLoadingState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Top Headlines", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { vm.setMainPageType("search") }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
Box(modifier = Modifier.fillMaxSize()) {
when (loadingState) {
"loading" -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
"error" -> Text(
text = "Failed to load news. Please try again.",
modifier = Modifier.align(Alignment.Center).padding(16.dp),
color = MaterialTheme.colorScheme.error
)
else -> {
if (headlines.isEmpty()) {
Text("No headlines available.", modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(headlines) { article ->
NewsCard(
article = article,
onClick = {
vm.selectArticleDetail(article)
vm.setMainPageType("detail")
}
)
}
}
}
}
}
}
}
}
File ini merupakan halaman utama aplikasi yang digunakan untuk menampilkan daftar berita terkini. Saat aplikasi pertama kali dijalankan, halaman ini akan mengambil data headline dari ViewModel dan menampilkannya dalam bentuk daftar menggunakan LazyColumn. Selain itu, halaman ini juga menangani berbagai kondisi seperti proses loading, kegagalan memuat data, maupun keadaan ketika tidak terdapat artikel yang tersedia. Setiap item berita ditampilkan menggunakan komponen NewsCard, dan ketika dipilih pengguna akan diarahkan menuju halaman detail. Pada pengembangan web, halaman ini dapat dianalogikan sebagai halaman utama yang menampilkan daftar data menggunakan mekanisme rendering list.
SearchScreen.kt
package com.fajary.newsapp.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fajary.newsapp.ui.component.NewsCard
import com.fajary.newsapp.viewmodel.NewsViewModelInterface
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
vm: NewsViewModelInterface,
) {
val searchResults by vm.newsSearches.collectAsState()
val loadingState by vm.newsSearchLoadingState.collectAsState()
var query by remember { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
fun doSearch() {
keyboardController?.hide()
vm.searchNews(query)
}
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = { vm.setMainPageType("home") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
title = {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search news...") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { doSearch() }),
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
},
windowInsets = WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
Box(modifier = Modifier.fillMaxSize()) {
when {
loadingState == "loading" ->
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
loadingState == "error" ->
Text(
text = "Search failed. Please try again.",
modifier = Modifier.align(Alignment.Center).padding(16.dp),
color = MaterialTheme.colorScheme.error
)
loadingState == "idle" ->
Text(
text = "Type something to search for news.",
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
searchResults.isEmpty() ->
Text(
text = "No results found for \"$query\".",
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
else ->
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(searchResults) { article ->
NewsCard(
article = article,
onClick = {
vm.selectArticleDetail(article)
vm.setMainPageType("detail")
}
)
}
}
}
}
}
}
File ini menyediakan fitur pencarian berita berdasarkan kata kunci yang dimasukkan pengguna. Halaman ini memanfaatkan OutlinedTextField sebagai tempat input pencarian dan akan menjalankan fungsi pencarian ketika pengguna menekan tombol search pada keyboard. Selain menampilkan hasil pencarian, halaman ini juga menangani kondisi loading, error, serta keadaan ketika belum ada kata kunci yang dimasukkan atau tidak ditemukan artikel yang sesuai. Seluruh hasil pencarian ditampilkan menggunakan komponen NewsCard sehingga tampilan tetap konsisten dengan halaman utama. Pada pengembangan web, halaman ini memiliki konsep yang mirip dengan fitur search yang memanfaatkan state dan event pada form.
DetailScreen.kt
package com.fajary.newsapp.ui.screen
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.fajary.newsapp.viewmodel.NewsViewModelInterface
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(
vm: NewsViewModelInterface,
) {
val article by vm.selectedArticleDetail.collectAsState()
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = { vm.setMainPageType("home") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
title = {
Text(
text = article?.source?.name ?: "Article",
style = MaterialTheme.typography.titleMedium
)
},
actions = {
article?.url?.let { url ->
IconButton(onClick = {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, url)
}
context.startActivity(Intent.createChooser(intent, "Share article"))
}) {
Icon(Icons.Default.Share, contentDescription = "Share")
}
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
article?.let { a ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
a.urlToImage?.let { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = a.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp))
)
}
Column(modifier = Modifier.padding(16.dp)) {
a.source?.name?.let { sourceName ->
Text(
text = sourceName.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(6.dp))
}
Text(
text = a.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
a.author?.let { author ->
Text(
text = "By $author",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
a.publishedAt?.let { date ->
Text(
text = "· ${date.take(10)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
a.description?.let { desc ->
Text(
text = desc,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
}
a.content?.let { content ->
Text(
text = content.substringBefore("[+"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
}
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(a.url))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Read Full Article")
}
}
}
}
}
}
File ini bertanggung jawab untuk menampilkan informasi lengkap dari artikel yang dipilih oleh pengguna. Halaman ini menampilkan gambar utama, nama sumber berita, judul artikel, penulis, tanggal publikasi, deskripsi, serta isi artikel. Selain itu, terdapat tombol untuk membagikan tautan berita kepada aplikasi lain dan tombol untuk membuka artikel asli melalui browser. Penggunaan collectAsState() memungkinkan perubahan data pada ViewModel langsung tercermin pada tampilan secara otomatis. Dengan demikian, halaman detail dapat menampilkan informasi secara dinamis sesuai artikel yang sedang dipilih. Pada pengembangan web, halaman ini memiliki fungsi yang serupa dengan halaman detail atau detail page pada aplikasi React.
MainActivity.kt
package com.fajary.newsapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.fajary.newsapp.data.repository.NewsRepository
import com.fajary.newsapp.ui.screen.DetailScreen
import com.fajary.newsapp.ui.screen.HomeScreen
import com.fajary.newsapp.ui.screen.SearchScreen
import com.fajary.newsapp.ui.theme.NewsAppTheme
import com.fajary.newsapp.viewmodel.NewsViewModel
import com.fajary.newsapp.viewmodel.NewsViewModelInterface
import com.fajary.newsapp.viewmodel.NewsViewModelMock
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NewsAppTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val repository = remember { NewsRepository() }
val viewModel = remember { NewsViewModel(repository) }
NewsApp(
modifier = Modifier.fillMaxSize().padding(innerPadding),
vm = viewModel
)
}
}
}
}
}
@Composable
fun NewsApp(modifier: Modifier = Modifier, vm: NewsViewModelInterface) {
val pageType by vm.mainPageType.collectAsState()
Box(
modifier = modifier
)
{
when (pageType) {
"search" -> SearchScreen(vm = vm)
"detail" -> DetailScreen(vm = vm)
else -> HomeScreen(vm = vm)
}
}
}
@Preview(showBackground = true)
@Composable
fun NewsAppPreview() {
NewsAppTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val viewModel = remember { NewsViewModelMock() }
NewsApp(modifier = Modifier.fillMaxSize().padding(innerPadding), vm = viewModel)
}
}
}
File ini merupakan titik masuk utama dari aplikasi Android. Pada saat aplikasi dijalankan, MainActivity akan menginisialisasi tema aplikasi, membuat instance repository dan ViewModel, kemudian memanggil composable NewsApp() sebagai akar dari seluruh tampilan. Fungsi NewsApp() bertugas mengatur perpindahan halaman berdasarkan nilai mainPageType, sehingga pengguna dapat berpindah antara halaman utama, pencarian, dan detail artikel. Selain itu, file ini juga menyediakan fungsi preview dengan memanfaatkan NewsViewModelMock agar antarmuka dapat diuji tanpa harus menjalankan aplikasi secara penuh. Pada pengembangan web, file ini memiliki peran yang serupa dengan App.tsx atau root component pada React.
Comments
Post a Comment