Tugas 11 PPB

 

TUGAS 11 PPB

UNSCRAMBLE APPLICATION



Nama: Muhammad Lintang Panjerino

NRP: 5025201045

Kelas: PPB I

Tahun: 2024

Link Github: Tugas 11 - Unscramble Application (Viewmodel)


Pada Tugas 11 PPB kali ini, diberikan tugas untuk membuat proyek Unscramble Application (Viewmodel) dengan menggunakan Jetpack Compose pada Android Studio. Secara garis besar, proyek Unscramble Application ini adalah sebuah aplikasi game yang menampilkan kata acak dan kemudian tujuan game ini adalah menebak kata yang benar dan mendapatkan skor setiap kali berhasil menebak kata acak. Aplikasi ini menampilkan kata acak, nomor soal, dan jumlah skor yang diperoleh. Pada tiap nomor soal, terdapat kata acak yang harus ditebak, dan ada 2 pilihan, yaitu "Submit" untuk menjawab dan "Skip" untuk melewati soal. Ketika benar menajwab, skor akan bertambah, ketika melewati soal, skor akan tetap, dan pada akhir permainan akan ditampilkan skor total dari pemain. Proyek ini bertujuan untuk mempelajari lebih dalam tentang konsep Viewmodel, state, UI Layer, dan Data Layer pada Jetpack Compose.

Untuk memulai pengerjaan proyek, langkah awal yang dilakukan adalah mengunduh file zip dari repository github berikut https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble dan pilih branch dengan nama "starter". Kemudian ekstrak file zip tersebut pada folder lokal PC/laptop dan ubah nama folder hasil ekstrak menjadi "Unscramble Application". Pada Android Studio, pilih "File" > pilih "Open" > pilih file tempat folder projek diekstrak > pilih "OK".

Setelah folder proyek terbuka, masih ada proses gradle build. Proses ini akan memakan waktu beberapa menit. Berikut adalah tampilan awal pada file MainActivity.kt. File ini merupakan file utama dari aplikasi Unscramble Application. Setelah terbuka, klik "Run app" untuk langsung melihat tampilan awal aplikasi.

Aplikasi ini terbagi menjadi 6 bagian besar, yaitu .

1. Menambahkan ViewModel

Menambahkan dependency ViewModel

a. Buka build.gradle.kts (Module :app), scroll ke blok dependencies, lalu tambahkan dependensi untuk ViewModel -> implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"). Dependensi ini digunakan untuk menambahkan viewmodel berbasis siklus proses ke aplikasi.

b. Di package ui, buat class/file Kotlin bernama GameViewModel. Perluas dari class ViewModel

c. Dalam package ui, tambahkan class model untuk UI status dengan nama GameUiState. Jadikan class data dan tambahkan variabel untuk kata acak saat ini.

Stateflow dan properti pendukung

a. Di class GameViewModel, tambahkan properti _uiState

b. Di file GameViewModel.kt, tambahkan properti pendukung ke uiState yang bernama _uiState. Beri nama properti uiState dan berjenis StateFlow<GameUiState>

c. Setel uiState ke _uiState.asStateFlow()

Menampilkan kata acak tanpa pola

a. Di GameViewModel, tambahkan properti bernama currentWord dari jenis String untuk menyimpan kata acak saat ini

b. Tambahkan method helper untuk pilih kata acak dari daftar dan acaklah. Beri nama pickRandomWordAndShuffle() tanpa parameter input, lalu buat fungsi tersebut menampilkan String

c. Di GameViewModel, tambahkan properti berikut setelah properti currentWord agar berfungsi sebagai kumpulan yang dapat diubah untuk menyimpan kata yang telah digunakan dalam game

d. Tambahkan method helper lain untuk mengacak kata saat ini yang disebut shuffleCurrentWord() yang menggunakan String dan menampilkan String yang diacak

e. Tambahkan fungsi helper untuk melakukan inisialisasi game yang disebut resetGame(). Gunakan fungsi ini nanti untuk memulai dan memulai ulang game. Pada fungsi ini, hapus semua kata dalam kumpulan usedWords, lakukan inisialisasi _uiState. Pilih kata baru untuk currentScrambledWord menggunakan pickRandomWordAndShuffle()

f. Tambahkan blok init ke GameViewModel dan panggil resetGame() dari blok tersebut

2. Merancang UI Compose

Meneruskan data

a. Pada fungsi GameScreen, teruskan argumen kedua dari jenis GameViewModel dengan nilai default viewModel()

b. Pada fungsi GameScreen(), tambahkan variabel baru bernama gameUiState. Gunakan delegasi by dan panggil collectAsState() pada uiState

c. Teruskan gameUiState.currentScrambledWord ke composable GameLayout(). Abaikan error untuk saat ini karena akan ditambahkan argumen di langkah selanjutnya

d. Tambahkan currentScrambledWord sebagai parameter lain ke fungsi composable GameLayout()

e. Perbarui fungsi composable GameLayout() untuk menampilkan currentScrambledWord. Tetapkan parameter text kolom teks pertama di kolom ke currentScrambledWord

f. Lakukan 'Run app' dan sekarang akan terlihat kata yang ejaannya diacak

Menampilkan kata tebakan

a. Di file GameScreen.kt, dalam composable GameLayout(), setel onValueChange ke onUserGuessChanged dan onKeyboardDone() ke tindakan keyboard onDone

b. Pada fungsi composable GameLayout(), tambahkan dua argumen lagi: lambda onUserGuessChanged menggunakan argumen String dan tidak menampilkan apa pun, serta onKeyboardDone tidak mengambil apa pun dan tidak menampilkan apa pun

c. Pada panggilan fungsi GameLayout(), tambahkan argumen lambda untuk onUserGuessChanged dan onKeyboardDone

d. Di file GameViewModel.kt, tambahkan method bernama updateUserGuess() yang menggunakan argumen String, kata tebakan pengguna. Di dalam fungsi, perbarui userGuess dengan meneruskan guessedWord

e. Di file GameViewModel.kt, tambahkan properti var yang disebut userGuess. Gunakan mutableStateOf() agar Compose mengamati nilai ini dan menetapkan nilai awal ke ""

f. Di file GameScreen.kt, di dalam GameLayout(), tambahkan parameter String lain untuk userGuess. Tetapkan parameter value dari OutlinedTextField ke userGuess

g. Pada fungsi GameScreen, update panggilan fungsi GameLayout() untuk menyertakan parameter userGuess

h. Lakukan 'Run app', ketika user menebak dan masukkan kata, kolom teks dapat menampilkan tebakan pengguna

3. Verifikasi Kata Tebakan

Memverifikasi kata

a. Di class GameViewModel, tambahkan method lain bernama checkUserGuess()

b. Pada fungsi checkUserGuess(), tambahkan blok if-else untuk memverifikasi apakah tebakan user sama dengan currentWord. Reset userGuess ke string yang kosong

c. Jika tebakan user salah, tetapkan isGuessedWordWrong ke true. MutableStateFlow<T>. update() memperbarui MutableStateFlow.value menggunakan nilai yang ditentukan

d. Di class GameUiState, tambahkan Boolean yang disebut isGuessedWordWrong dan lakukan inisialisasi ke false

Error handling

a. Di file GameScreen.kt, di akhir fungsi composable GameScreen(), panggil gameViewModel.checkUserGuess() di dalam ekspresi lambda onClick dari tombol Submit

b. Pada fungsi composable GameScreen(), update panggilan fungsi GameLayout() untuk meneruskan gameViewModel.checkUserGuess() dalam ekspresi lambda onKeyboardDone

c. Pada fungsi composable GameLayout(), tambahkan parameter fungsi untuk Boolean, isGuessWrong. Tetapkan parameter isError dari OutlinedTextField ke isGuessWrong untuk menampilkan error di kolom teks jika tebakan pengguna salah

d. Pada fungsi composable GameScreen(), update panggilan fungsi GameLayout() untuk meneruskan isGuessWrong

e. Lakukan 'Run app', masukkan tebakan yang salah dan klik Submit. Perhatikan bahwa kolom teks berubah menjadi merah, yang menunjukkan error

f. Di file GameScreen.kt, dalam composable GameLayout(), perbarui parameter label kolom teks bergantung pada isGuessWrong

g. Di file strings.xml, tambahkan string ke label error

h. Lakukan 'Run app', masukkan tebakan yang salah dan klik Submit. Label error sudah menyesuaikan jika user salah menebak kata

4. Memperbarui Skor dan Jumlah Kata

Memperbarui skor

a. Di GameUiState, tambahkan variabel score dan lakukan inisialisasi ke nol

b. Untuk memperbarui nilai skor, di GameViewModel, pada fungsi checkUserGuess(), di dalam kondisi if untuk saat tebakan pengguna benar, tingkatkan nilai score

c. Di GameViewModel, tambahkan method lain bernama updateGameState untuk memperbarui skor, menambah jumlah kata saat ini, dan memilih kata baru dari file WordsData.kt. Tambahkan Int yang bernama updatedScore sebagai parameter

d. Pada fungsi checkUserGuess(), jika tebakan pengguna benar, lakukan panggilan ke updateGameState dengan skor yang telah diperbarui untuk menyiapkan game untuk putaran berikutnya

e. Tambahkan variabel lain untuk jumlah di GameUiState. Panggil currentWordCount dan lakukan inisialisasi ke 1

f. Di file GameViewModel.kt, pada fungsi updateGameState(), tingkatkan jumlah kata. Fungsi updateGameState() dipanggil untuk menyiapkan game untuk putaran berikutnya

Melewati soal

a. Di file GameScreen.kt, pada fungsi composable GameLayout(), tambahkan jumlah kata sebagai argumen dan teruskan argumen format wordCount ke elemen teks

b. Perbarui panggilan fungsi GameLayout() untuk menyertakan jumlah kata

c. Pada fungsi composable GameScreen(), perbarui panggilan fungsi GameStatus() untuk menyertakan parameter score. Teruskan skor dari gameUiState

d. Di file GameScreen.kt, pada fungsi composable GameScreen(), lakukan panggilan ke gameViewModel.skipWord() dalam ekspresi lambda onClick

e. Di GameViewModel, tambahkan method skipWord()

f. Di dalam fungsi skipWord(), lakukan panggilan ke updateGameState(), dengan meneruskan skor dan mereset tebakan pengguna

g. Lakukan 'Run app', game sudah dapat memperbarui skor dan jumlah kata, serta dapat melewati soal (skip)

5. Menangani Putaran Terakhir Game

Menentukan putaran terakhir game

a. Di GameViewModel, tambahkan blok if-else dan pindahkan isi fungsi yang ada sebelumnya ke dalam blok else

b. Tambahkan kondisi if untuk memastikan ukuran usedWords sama dengan MAX_NO_OF_WORDS

c. Di dalam blok if, tambahkan flag Boolean isGameOver dan setel flag ke true untuk menunjukkan akhir game

d. Perbarui score dan reset isGuessedWordWrong di dalam blok if

e. Di GameUiState, tambahkan variabel Boolean isGameOver dan tetapkan ke false

f. Lakukan 'Run app', sekarang game tidak bisa dimainkan lebih dari 10 kata

Menampilkan dialog akhir game

a. Di file GameScreen.kt, pada fungsi FinalScoreDialog(), perhatikan parameter skor untuk menampilkan skor game di dialog pemberitahuan

b. Dalam fungsi FinalScoreDialog(), perhatikan penggunaan ekspresi lambda parameter text untuk menggunakan score sebagai argumen format ke teks dialog

c. Di file GameScreen.kt, di akhir fungsi composable GameScreen(), setelah blok Column, tambahkan kondisi if untuk memeriksa gameUiState.isGameOver

d. Di blok if, tampilkan dialog pemberitahuan. Lakukan panggilan ke FinalScoreDialog() dengan meneruskan score dan gameViewModel.resetGame() untuk callback onPlayAgain

e. Dalam file GameViewModel.kt, panggil kembali fungsi resetGame(), lakukan inisialisasi _uiState, dan pilih kata baru

f. Lakukan 'Run app', ketika game dimainkan sampai selesai, akan muncul dialog pemberitahuan dengan opsi untuk Exit dari game atau Play Again

6. Status Dalam Rotasi Perangkat

Saat terjadi perubahan konfigurasi, Android akan memulai ulang aktivitas dari awal, kemudian menjalankan semua callback startup siklus proses.

ViewModel menyimpan data terkait aplikasi yang tidak dihancurkan saat framework Android menghancurkan dan membuat ulang aktivitas. Objek ViewModel secara otomatis dipertahankan dan tidak dihancurkan seperti instance aktivitas selama perubahan konfigurasi. Data yang disimpan segera tersedia setelah rekomposisi.

Di bawah ini adalah cara untuk memastikan bahwa aplikasi yang menggunakan ViewModel akan mempertahankan status UI selama perubahan konfigurasi.

a. Jalankan aplikasi dan putar beberapa kata. Ubah konfigurasi perangkat dari potrait ke landscape, atau sebaliknya

b. Perhatikan bahwa data yang disimpan di UI status ViewModel dipertahankan selama perubahan konfigurasi

Hasil Akhir

Berikut adalah tampilan hasil akhir dari aplikasi Unscramble Application.

  

Berikut adalah kode sumber Unscramble Application

GameScreen.kt

/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.example.unscramble.ui

import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
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.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.unscramble.R
import com.example.unscramble.ui.theme.UnscrambleTheme

@Composable
fun GameScreen(
    gameViewModel: GameViewModel = viewModel()
) {
    val gameUiState by gameViewModel.uiState.collectAsState()
    val mediumPadding = dimensionResource(R.dimen.padding_medium)

    Column(
        modifier = Modifier
            .statusBarsPadding()
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding()
            .padding(mediumPadding),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Text(
            text = stringResource(R.string.app_name),
            style = typography.titleLarge,
        )
        GameLayout(
            onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
            userGuess = gameViewModel.userGuess,
            isGuessWrong = gameUiState.isGuessedWordWrong,
            onKeyboardDone = { gameViewModel.checkUserGuess() },
            wordCount = gameUiState.currentWordCount,
            currentScrambledWord = gameUiState.currentScrambledWord,
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .padding(mediumPadding)
        )
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(mediumPadding),
            verticalArrangement = Arrangement.spacedBy(mediumPadding),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            Button(
                modifier = Modifier.fillMaxWidth(),
                onClick = { gameViewModel.checkUserGuess() }
            ) {
                Text(
                    text = stringResource(R.string.submit),
                    fontSize = 16.sp
                )
            }

            OutlinedButton(
                onClick = { gameViewModel.skipWord() },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(
                    text = stringResource(R.string.skip),
                    fontSize = 16.sp
                )
            }
        }

        GameStatus(score = gameUiState.score, modifier = Modifier.padding(20.dp))

        if (gameUiState.isGameOver) {
            FinalScoreDialog(
                score = gameUiState.score,
                onPlayAgain = { gameViewModel.resetGame() }
            )
        }
    }
}

@Composable
fun GameStatus(score: Int, modifier: Modifier = Modifier) {
    Card(
        modifier = modifier
    ) {
        Text(
            text = stringResource(R.string.score, score),
            style = typography.headlineMedium,
            modifier = Modifier.padding(8.dp)
        )
    }
}

@Composable
fun GameLayout(
    onUserGuessChanged: (String) -> Unit,
    userGuess: String,
    isGuessWrong: Boolean,
    onKeyboardDone: () -> Unit,
    wordCount: Int,
    currentScrambledWord: String,
    modifier: Modifier = Modifier,
) {
    val mediumPadding = dimensionResource(R.dimen.padding_medium)

    Card(
        modifier = modifier,
        elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
    ) {
        Column(
            verticalArrangement = Arrangement.spacedBy(mediumPadding),
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier.padding(mediumPadding)
        ) {
            Text(
                modifier = Modifier
                    .clip(shapes.medium)
                    .background(colorScheme.surfaceTint)
                    .padding(horizontal = 10.dp, vertical = 4.dp)
                    .align(alignment = Alignment.End),
                text = stringResource(R.string.word_count, wordCount),
                style = typography.titleMedium,
                color = colorScheme.onPrimary
            )
            Text(
                text = currentScrambledWord,
                style = typography.displayMedium
            )
            Text(
                text = stringResource(R.string.instructions),
                textAlign = TextAlign.Center,
                style = typography.titleMedium
            )
            OutlinedTextField(
                value = userGuess,
                singleLine = true,
                shape = shapes.large,
                modifier = Modifier.fillMaxWidth(),
                colors = TextFieldDefaults.colors(
                    focusedContainerColor = colorScheme.surface,
                    unfocusedContainerColor = colorScheme.surface,
                    disabledContainerColor = colorScheme.surface,
                ),
                onValueChange = onUserGuessChanged,
                label = {
                    if (isGuessWrong) {
                        Text(text = stringResource(R.string.wrong_guess))
                    } else {
                        Text(text = stringResource(R.string.enter_your_word))
                    }
                },
                isError = isGuessWrong,
                keyboardOptions = KeyboardOptions.Default.copy(
                    imeAction = ImeAction.Done
                ),
                keyboardActions = KeyboardActions(
                    onDone = { onKeyboardDone() }
                )
            )
        }
    }
}

/*
 * Creates and shows an AlertDialog with final score.
 */
@Composable
private fun FinalScoreDialog(
    score: Int,
    onPlayAgain: () -> Unit,
    modifier: Modifier = Modifier
) {
    val activity = (LocalContext.current as Activity)

    AlertDialog(
        onDismissRequest = {
            // Dismiss the dialog when the user clicks outside the dialog or on the back
            // button. If you want to disable that functionality, simply use an empty
            // onCloseRequest.
        },
        title = { Text(text = stringResource(R.string.congratulations)) },
        text = { Text(text = stringResource(R.string.you_scored, score)) },
        modifier = modifier,
        dismissButton = {
            TextButton(
                onClick = {
                    activity.finish()
                }
            ) {
                Text(text = stringResource(R.string.exit))
            }
        },
        confirmButton = {
            TextButton(onClick = onPlayAgain) {
                Text(text = stringResource(R.string.play_again))
            }
        }
    )
}

@Preview(showBackground = true)
@Composable
fun GameScreenPreview() {
    UnscrambleTheme {
        GameScreen()
    }
}

GameViewModel.kt

package com.example.unscramble.ui

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.example.unscramble.data.MAX_NO_OF_WORDS
import com.example.unscramble.data.SCORE_INCREASE
import com.example.unscramble.data.allWords
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

class GameViewModel : ViewModel() {
    // Game UI State
    private val _uiState = MutableStateFlow(GameUiState())
    val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

    var userGuess by mutableStateOf("")
        private set

    // Set of words used in the game
    private var usedWords: MutableSet<String> = mutableSetOf()
    private lateinit var currentWord: String

    init {
        resetGame()
    }

    fun resetGame() {
        usedWords.clear()
        _uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
    }

    fun updateUserGuess(guessedWord: String){
        userGuess = guessedWord
    }

    fun checkUserGuess() {
        if (userGuess.equals(currentWord, ignoreCase = true)) {
            // User's guess is correct, increase the score
            // and call updateGameState() to prepare the game for next round
            val updatedScore = _uiState.value.score.plus(SCORE_INCREASE)
            updateGameState(updatedScore)
        } else {
            // User's guess is wrong, show an error
            _uiState.update { currentState ->
                currentState.copy(isGuessedWordWrong = true)
            }
        }
        // Reset user guess
        updateUserGuess("")
    }

    fun skipWord() {
        updateGameState(_uiState.value.score)
        // Reset user guess
        updateUserGuess("")
    }

    private fun updateGameState(updatedScore: Int) {
        if (usedWords.size == MAX_NO_OF_WORDS) {
            // Last round in the game
            _uiState.update { currentState ->
                currentState.copy(
                    isGuessedWordWrong = false,
                    score = updatedScore,
                    isGameOver = true
                )
            }
        } else {
            // Normal round in the game
            _uiState.update { currentState ->
                currentState.copy(
                    isGuessedWordWrong = false,
                    currentScrambledWord = pickRandomWordAndShuffle(),
                    score = updatedScore,
                    currentWordCount = currentState.currentWordCount.inc()
                )
            }
        }
    }

    private fun shuffleCurrentWord(word: String): String {
        val tempWord = word.toCharArray()
        // Scramble the word
        tempWord.shuffle()
        while (String(tempWord).equals(word)) {
            tempWord.shuffle()
        }
        return String(tempWord)
    }

    private fun pickRandomWordAndShuffle(): String {
        // Continue picking up a new random word until you get one that hasn't been used before
        currentWord = allWords.random()
        if (usedWords.contains(currentWord)) {
            return pickRandomWordAndShuffle()
        } else {
            usedWords.add(currentWord)
            return shuffleCurrentWord(currentWord)
        }
    }
}

GameUiState.kt

package com.example.unscramble.ui

data class GameUiState(
    val currentScrambledWord: String = "",
    val isGuessedWordWrong: Boolean = false,
    val score: Int = 0,
    val currentWordCount: Int = 1,
    val isGameOver: Boolean = false
)

Berikut adalah link video demo Unscramble Application

https://youtu.be/Qdob3JrBzA8

Referensi

https://developer.android.com/codelabs/basic-android-kotlin-compose-viewmodel-and-state?hl=id#0


Komentar

Postingan populer dari blog ini

Tugas 2 PPB

Tugas 3 PPB

Tugas 4 PPB