Visitor Count

Membuat Aplikasi Story dengan Arsitektur First-Offline

Panduan langkah demi langkah untuk pemula dalam membuat aplikasi Story dengan fitur login/register menggunakan Retrofit dan arsitektur first-offline

Daftar Isi

  1. Setup Project dan Memahami Dependencies
  2. Menyiapkan Struktur Project
  3. Membuat Helper Classes
  4. Setup Data Layer - Remote
  5. Setup Data Layer - Local
  6. Setup Dependency Injection (Hilt)
  7. Repository Implementation
  8. ViewModel Setup
  9. UI Screen Implementation
  10. Update AndroidManifest.xml
  11. Testing

Step 1: Setup Project dan Memahami Dependencies

1.1 Membuat Project Baru

  1. Buka Android Studio
  2. Pilih "New Project" > "Empty Activity"
  3. Selecting New Project
  4. Isi nama aplikasi: "StoryApp"
  5. Pilih bahasa: Kotlin
  6. Minimum SDK: API 24

1.2 Memahami Dependencies

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:

  • Menyederhanakan manajemen versi library di satu tempat
  • Memudahkan pembaruan versi untuk semua modul secara konsisten
  • Mengurangi duplikasi deklarasi versi di beberapa modul
  • Sintaks yang lebih ringkas dan lebih mudah dibaca
Replace with new library 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
}

Sinkronisasi Gradle

Setelah menambahkan atau mengubah dependencies, Anda perlu melakukan sinkronisasi Gradle agar perubahan diterapkan:

  1. Klik tombol "Sync Now" yang muncul di pojok kanan atas editor
  2. Atau, klik ikon Elephant (logo Gradle) di toolbar sebelah kanan
  3. Bisa juga dengan shortcut: Ctrl+Shift+O (Windows) atau Cmd+Shift+O (Mac)
Gradle Sync Button

Proses sinkronisasi akan mengunduh semua dependencies yang dibutuhkan dan melakukan konfigurasi project. Tunggu hingga proses ini selesai sebelum melanjutkan ke langkah berikutnya.

Catatan Penting:

  • Nama package dalam kode contoh seharusnya disesuaikan dengan package project Anda. Misalnya, jika nama package Anda adalah "com.example.storyapp" maka semua contoh kode harus menggunakan package tersebut.
  • Import statements tidak ditampilkan dalam contoh kode untuk menjaga kode tetap ringkas. Android Studio akan menandai kode yang memerlukan import dengan warna merah.
  • Untuk mengatasi error import, gunakan shortcut Alt+Enter (Windows/Linux) atau Option+Enter (Mac) pada kode yang bertanda merah untuk mengimpor class yang diperlukan.
  • Pastikan Anda mengimpor class yang benar, khususnya untuk komponen Compose (pilih import dari androidx.compose...).

Step 2: Menyiapkan Struktur Project

Buat package berikut:

  1. data/remote - API service, response models
  2. data/local - Room database, entities, DAOs
  3. data/repository - Repository pattern implementation
  4. di - Dependency injection modules
  5. ui/screen - Compose screens
  6. ui/navigation - Navigation setup
  7. ui/theme - Theme configuration
  8. viewmodel - ViewModels
  9. data/helper - Utility classes

Step 3: Membuat Helper Classes

3.1 Result Helper

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).

Step 4: Setup Data Layer - Remote

4.1 API Models

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
)

4.2 API Service

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.

Step 5: Setup Data Layer - Local

5.1 User Entity

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.

5.2 User DAO

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.

5.3 App Database

Buat file StoryDatabase.kt di data/local:

@Database(entities = [UserEntity::class], version = 1, exportSchema = false)
abstract class StoryDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

Step 6: Setup Dependency Injection (Hilt)

6.1 Module untuk Network

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:

  • AuthRepository.kt - Perhatikan return type Flow<Result<UserEntity>>
  • AuthViewModel.kt - Perhatikan MutableStateFlow<Result<UserEntity>?
  • LoginScreen.kt - Perhatikan pattern matching when (val result = loginState) { is Result.Success -> ... }

6.1.1 Konfigurasi Environment dengan .env File

Untuk mengelola URL API dan konfigurasi sensitif secara aman, gunakan file .env:

  1. Buat file .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:

  1. Pastikan untuk menambahkan file .env ke .gitignore agar tidak tercommit ke repository:
  2. # .gitignore
    .env
  3. Sediakan juga file .env.example sebagai template yang bisa di-commit:
  4. # .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
  5. Untuk pembelajaran, Anda bisa menggunakan service seperti MockAPI (mockapi.io) atau JSON Server untuk membuat API palsu

6.2 Module untuk Database

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()
    }
}

6.3 Application Class

Buat file StoryApplication.kt di package root:

@HiltAndroidApp
class StoryApplication : Application()

Step 7: Repository Implementation

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.

Step 8: ViewModel Setup

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.

Step 9: UI Screen Implementation

9.1 Buat LoginScreen.kt

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.

9.2 Buat StoryApp.kt untuk Navigation

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
        }
    }
}

9.3 Update MainActivity

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            StoryAppTheme {
                StoryApp()
            }
        }
    }
}

Step 10: Update AndroidManifest.xml

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:

  1. Permission Internet dengan tag <uses-permission android:name="android.permission.INTERNET" />
  2. Nama aplikasi kustom dengan atribut android:name=".StoryApplication" pada tag application

Bagian lain seperti activity, icon, label, dan theme biasanya sudah ada secara default saat membuat project baru.

Step 11: Testing

  1. Build dan jalankan aplikasi
  2. Test login dengan:
    • Email: email@example.com
    • Password: password123
  3. Verifikasi bahwa aplikasi menampilkan loading state ketika menunggu respons server
  4. Cek handling error jika server mengembalikan error
  5. Verifikasi navigasi ke halaman home ketika login berhasil

Troubleshooting

  1. Jika terjadi error dengan Hilt, pastikan:
    • Annotation processor sudah aktif (kapt enabled)
    • Semua class yang menggunakan @Inject memiliki constructor public
    • MainActivity memiliki @AndroidEntryPoint
  2. Jika terjadi error jaringan:
    • Periksa URL di NetworkModule
    • Pastikan device/emulator terhubung ke internet
    • Verifikasi format request/response sesuai dengan API
  3. Jika Room error:
    • Pastikan semua entity memiliki @PrimaryKey
    • Verifikasi versi database dan migrations

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.