Panduan langkah demi langkah untuk pemula dalam membuat aplikasi Story dengan fitur login/register menggunakan Retrofit dan arsitektur first-offline
Buka build.gradle.kts (Module: app) dan pastikan
dependencies berikut sudah ada:
// Jetpack Compose - untuk membuat UI
implementation("androidx.activity:activity-compose:1.8.2") // Activity Compose
implementation("androidx.compose.ui:ui:1.6.0") // Compose UI
implementation("androidx.compose.ui:ui-graphics:1.6.0") // Graphics
implementation("androidx.compose.material3:material3:1.2.0") // Material Design 3
// Navigation - untuk perpindahan antar halaman
implementation("androidx.navigation:navigation-compose:2.7.6")
// ViewModel - untuk state management
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Room - untuk database lokal
implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
// Retrofit - untuk network calls
implementation("com.squareup.retrofit2:retrofit:2.9.0") // Library utama
implementation("com.squareup.retrofit2:converter-gson:2.9.0") // Konverter JSON
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") // Logging
// DataStore - untuk penyimpanan kecil seperti token
implementation("androidx.datastore:datastore-preferences:1.0.0")
// WorkManager - untuk background tasks
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Hilt - untuk dependency injection
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
Alternatif: Menggunakan Version Catalog
Android Studio mungkin menyarankan untuk "Replace with new library catalog". Ini adalah pendekatan terbaru dan direkomendasikan untuk mengelola dependensi di proyek Android.
Manfaat menggunakan Version Catalog:
Selain itu, perlu diperhatikan untuk annotation processor (kapt/ksp) juga perlu dikonfigurasi:
kapt - Kotlin Annotation Processing Tool, digunakan
oleh Hilt untuk menghasilkan kode
ksp - Kotlin Symbol Processing, pengganti kapt yang
lebih cepat, digunakan oleh Room
Lalu, pada file build.gradle.kts (module: app), Anda
dapat menggunakan dependensi dengan sintaks yang lebih ringkas:
dependencies {
// Compose
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.material3)
// Navigation
implementation(libs.androidx.navigation.compose)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) // Perhatikan penggunaan ksp
// Hilt
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
kapt(libs.hilt.compiler) // Perhatikan penggunaan kapt
// Dan seterusnya...
}
Penting: Jika menggunakan KSP dan KAPT, pastikan plugin sudah ditambahkan di file build.gradle.kts level module:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp") version "1.9.22-1.0.17" // Untuk Room
id("kotlin-kapt") // Untuk Hilt
}
Setelah menambahkan atau mengubah dependencies, Anda perlu melakukan sinkronisasi Gradle agar perubahan diterapkan:
Ctrl+Shift+O (Windows)
atau Cmd+Shift+O (Mac)
Proses sinkronisasi akan mengunduh semua dependencies yang dibutuhkan dan melakukan konfigurasi project. Tunggu hingga proses ini selesai sebelum melanjutkan ke langkah berikutnya.
Catatan Penting:
Alt+Enter (Windows/Linux) atau
Option+Enter (Mac) pada kode yang bertanda merah
untuk mengimpor class yang diperlukan.
androidx.compose...).
Buat package berikut:
data/remote - API service, response modelsdata/local - Room database, entities, DAOsdata/repository - Repository pattern implementation
di - Dependency injection modulesui/screen - Compose screensui/navigation - Navigation setupui/theme - Theme configurationviewmodel - ViewModelsdata/helper - Utility classes
Buat file Result.kt di package data/helper:
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()
}
Penting: Ketika menggunakan kelas
Result ini di class lain, pastikan untuk mengimpor
kelas ini dan bukan kelas Result dari Kotlin standard
library. Gunakan import yang spesifik seperti
import com.example.storyapp.data.helper.Result
(sesuaikan dengan nama package project Anda).
Buat file LoginRequest.kt di
data/remote/model:
data class LoginRequest(
val email: String,
val password: String
)
Buat file LoginResponse.kt:
data class LoginResponse(
val error: Boolean,
val message: String,
val loginResult: LoginResult
)
data class LoginResult(
val userId: String,
val name: String,
val token: String
)
Buat file ApiService.kt di data/remote:
interface ApiService {
@POST("login")
suspend fun login(@Body loginRequest: LoginRequest): LoginResponse
// Tambahkan endpoint lain di sini
}
Catatan: Untuk code di atas, Anda perlu mengimport
class dari Retrofit seperti POST dan Body.
Gunakan Alt+Enter untuk menambahkan import yang diperlukan.
Buat file UserEntity.kt di
data/local/entity:
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey
val userId: String,
val name: String,
val token: String
)
Catatan: Untuk Entity di atas, Anda perlu
mengimport annotation dari Room seperti Entity dan
PrimaryKey.
Buat file UserDao.kt di data/local/dao:
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity)
@Query("SELECT * FROM users WHERE userId = :userId")
fun getUserById(userId: String): Flow<UserEntity?>
@Query("DELETE FROM users")
suspend fun deleteAll()
}
Catatan: Untuk DAO di atas, Anda perlu mengimport
annotation dari Room dan juga Flow dari
kotlinx.coroutines.
Buat file StoryDatabase.kt di data/local:
@Database(entities = [UserEntity::class], version = 1, exportSchema = false)
abstract class StoryDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Buat file NetworkModule.kt di di:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_URL) // Menggunakan URL dari BuildConfig
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
Catatan: Pada kode di atas, kita menggunakan
BuildConfig.API_URL untuk URL API daripada hardcoded
URL. Ini memungkinkan kita untuk menggunakan URL yang berbeda di
lingkungan development dan production.
Penting tentang kelas Result: Saat menggunakan
kelas Result yang kita buat di
data/helper, pastikan untuk mengimpor kelas tersebut
dan bukan kelas Result dari Kotlin stdlib. Gunakan
import yang spesifik seperti
import com.example.storyapp.data.helper.Result
(sesuaikan dengan package project Anda).
Beberapa tempat yang menggunakan kelas Result:
Flow<Result<UserEntity>>
MutableStateFlow<Result<UserEntity>?
when (val result = loginState) { is Result.Success -> ...
}
Untuk mengelola URL API dan konfigurasi sensitif secara aman, gunakan
file .env:
.env di root project:# URL untuk API
API_URL_DEBUG=https://api-dev.example.com/v1/
API_URL_RELEASE=https://api.example.com/v1/
# Konfigurasi lain yang diperlukan
API_KEY=YOUR_API_KEY
Untuk menggunakan file .env, Anda perlu menambahkan library tambahan
di build.gradle.kts (project):
buildscript {
dependencies {
// ...existing code...
classpath("io.github.cdimascio:dotenv-kotlin:6.4.1")
}
}
Lalu, baca environment variables di
build.gradle.kts (module: app):
// Impor dotenv
import io.github.cdimascio.dotenv.dotenv
// Baca .env file
val dotenv = dotenv {
directory = rootProject.projectDir.absolutePath
ignoreIfMissing = true
}
android {
// ...existing code...
buildTypes {
debug {
buildConfigField("String", "API_URL", "\"${dotenv["API_URL_DEBUG"] ?: "https://api-dev.example.com/v1/"}\"")
}
release {
buildConfigField("String", "API_URL", "\"${dotenv["API_URL_RELEASE"] ?: "https://api.example.com/v1/"}\"")
// Konfigurasi release lainnya
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
Penting:
.env ke
.gitignore agar tidak tercommit ke repository:
# .gitignore
.env
.env.example sebagai template yang
bisa di-commit:
# .env.example
API_URL_DEBUG=https://your-dev-api.com/v1/
API_URL_RELEASE=https://your-prod-api.com/v1/
API_KEY=YOUR_API_KEY
Buat file DatabaseModule.kt di di:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): StoryDatabase {
return Room.databaseBuilder(
context,
StoryDatabase::class.java,
"story_database"
).fallbackToDestructiveMigration().build()
}
@Provides
fun provideUserDao(database: StoryDatabase): UserDao {
return database.userDao()
}
}
Buat file StoryApplication.kt di package root:
@HiltAndroidApp
class StoryApplication : Application()
Buat file AuthRepository.kt di
data/repository:
@Singleton
class AuthRepository @Inject constructor(
private val apiService: ApiService,
private val userDao: UserDao
) {
suspend fun login(email: String, password: String): Flow<Result<UserEntity>> = flow {
emit(Result.Loading)
try {
val response = apiService.login(LoginRequest(email, password))
if (response.error) {
emit(Result.Error(response.message))
} else {
val user = UserEntity(
userId = response.loginResult.userId,
name = response.loginResult.name,
token = response.loginResult.token
)
userDao.insertUser(user)
emit(Result.Success(user))
}
} catch (e: Exception) {
emit(Result.Error(e.message ?: "Terjadi kesalahan"))
}
}
}
Catatan: Pada kode di atas, pastikan untuk
mengimpor kelas Result dari package
data/helper yang telah kita buat sebelumnya, bukan dari
Kotlin standard library. Perhatikan penggunaan
Result.Loading, Result.Error, dan
Result.Success.
Buat AuthViewModel.kt di package viewmodel:
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _loginState = MutableStateFlow<Result<UserEntity>?>(null)
val loginState: StateFlow<Result<UserEntity>?> = _loginState
fun login(email: String, password: String) {
viewModelScope.launch {
authRepository.login(email, password).collect { result ->
_loginState.value = result
}
}
}
}
Catatan: Dalam ViewModel ini, perhatikan penggunaan
kelas Result dalam tipe MutableStateFlow.
Pastikan untuk mengimpor kelas Result dari package
helper Anda, bukan dari Kotlin standard library.
Buat file LoginScreen.kt di package
ui/screen:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(viewModel: AuthViewModel, onSuccess: () -> Unit) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isEmailError by remember { mutableStateOf(false) }
var isPasswordError by remember { mutableStateOf(false) }
val loginState by viewModel.loginState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Login",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
isEmailError = false
},
label = { Text("Email") },
isError = isEmailError,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
if (isEmailError) {
Text(
text = "Email tidak boleh kosong",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Start)
)
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = {
password = it
isPasswordError = false
},
label = { Text("Password") },
isError = isPasswordError,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
if (isPasswordError) {
Text(
text = "Password tidak boleh kosong",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Start)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (email.isEmpty()) {
isEmailError = true
} else if (password.isEmpty()) {
isPasswordError = true
} else {
viewModel.login(email, password)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Login")
}
Spacer(modifier = Modifier.height(16.dp))
when (val result = loginState) {
is Result.Success -> {
LaunchedEffect(result) {
onSuccess()
}
}
is Result.Error -> {
Text(
text = "Login gagal: ${result.message}",
color = MaterialTheme.colorScheme.error
)
}
is Result.Loading -> {
CircularProgressIndicator()
}
null -> {
// Initial state, do nothing
}
}
}
}
Catatan: Dalam LoginScreen, perhatikan penggunaan
pattern matching dengan when untuk kelas
Result. Pastikan untuk mengimpor kelas
Result dari package helper Anda agar pattern matching
berfungsi dengan benar.
Buat file StoryApp.kt di package
ui/navigation:
@Composable
fun StoryApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "login") {
composable("login") {
val viewModel: AuthViewModel = hiltViewModel()
LoginScreen(
viewModel = viewModel,
onSuccess = {
navController.navigate("home") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("home") {
// Tambahkan HomeScreen di sini nanti
}
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
StoryAppTheme {
StoryApp()
}
}
}
}
Tambahkan izin internet dan konfigurasi aplikasi di
AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Tambahkan permission internet -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".StoryApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.StoryApp">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.StoryApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Catatan: Yang perlu ditambahkan pada AndroidManifest.xml adalah:
<uses-permission android:name="android.permission.INTERNET"
/>
android:name=".StoryApplication" pada tag application
Bagian lain seperti activity, icon, label, dan theme biasanya sudah ada secara default saat membuat project baru.
Dengan mengikuti langkah-langkah di atas, Anda akan memiliki aplikasi Story dengan fitur login yang menggunakan Retrofit untuk API calls dan Room untuk penyimpanan lokal, mengikuti arsitektur first-offline.