Tugas 10 PPB A 2025 - Unscramble MVVM

Nama     : Rayhan Arvianta Bayuputra

NRP        : 5025211217

Kelas      : PPB A

TA            : 2024 (Genap)


Model-View-ViewModel - Unscramble Game

https://developer.android.com/codelabs/basic-android-kotlin-compose-viewmodel-and-state#1

Pada pertemuan kali ini, kami membuat sebuah game sederhana bernama Unscramble menggunakan arsitektur MVVM.


Berikut source codenya:

MainActivity.kt


package com.example.unscramble

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.unscramble.ui.GameScreen
import com.example.unscramble.ui.theme.UnscrambleTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
UnscrambleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
) {
GameScreen()
}
}
}
}
}


GameScreen.kt

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) },
wordCount = gameUiState.currentWordCount,
userGuess = gameViewModel.userGuess,
onKeyboardDone = { gameViewModel.checkUserGuess() },
currentScrambledWord = gameUiState.currentScrambledWord,
isGuessWrong = gameUiState.isGuessedWordWrong,
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(
currentScrambledWord: String,
wordCount: Int,
isGuessWrong: Boolean,
userGuess: String,
onUserGuessChanged: (String) -> Unit,
onKeyboardDone: () -> Unit,
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(stringResource(R.string.wrong_guess))
} else {
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()
}
}


GameUiState

package com.example.unscramble.ui

/**
* Data class that represents the game UI state
*/
data class GameUiState(
val currentScrambledWord: String = "",
val currentWordCount: Int = 1,
val score: Int = 0,
val isGuessedWordWrong: Boolean = false,
val isGameOver: Boolean = false
)


GameViewModel

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

/**
* ViewModel containing the app data and methods to process the data
*/
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()
}

/*
* Re-initializes the game data to restart the game.
*/
fun resetGame() {
usedWords.clear()
_uiState.value = GameUiState(currentScrambledWord = pickRandomWordAndShuffle())
}

/*
* Update the user's guess
*/
fun updateUserGuess(guessedWord: String){
userGuess = guessedWord
}

/*
* Checks if the user's guess is correct.
* Increases the score accordingly.
*/
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("")
}

/*
* Skip to next word
*/
fun skipWord() {
updateGameState(_uiState.value.score)
// Reset user guess
updateUserGuess("")
}

/*
* Picks a new currentWord and currentScrambledWord and updates UiState according to
* current game state.
*/
private fun updateGameState(updatedScore: Int) {
if (usedWords.size == MAX_NO_OF_WORDS){
//Last round in the game, update isGameOver to true, don't pick a new word
_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(),
currentWordCount = currentState.currentWordCount.inc(),
score = updatedScore
)
}
}
}

private fun shuffleCurrentWord(word: String): String {
val tempWord = word.toCharArray()
// Scramble the word
tempWord.shuffle()
while (String(tempWord) == 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()
return if (usedWords.contains(currentWord)) {
pickRandomWordAndShuffle()
} else {
usedWords.add(currentWord)
shuffleCurrentWord(currentWord)
}
}
}


WordsData
package com.example.unscramble.data

const val MAX_NO_OF_WORDS = 10
const val SCORE_INCREASE = 20

// Set with all the words for the Game
val allWords: Set<String> =
setOf(
"animal",
"auto",
"anecdote",
"alphabet",
"all",
"awesome",
"arise",
"balloon",
"basket",
"bench",
"best",
"birthday",
"book",
"briefcase",
"camera",
"camping",
"candle",
"cat",
"cauliflower",
"chat",
"children",
"class",
"classic",
"classroom",
"coffee",
"colorful",
"cookie",
"creative",
"cruise",
"dance",
"daytime",
"dinosaur",
"doorknob",
"dine",
"dream",
"dusk",
"eating",
"elephant",
"emerald",
"eerie",
"electric",
"finish",
"flowers",
"follow",
"fox",
"frame",
"free",
"frequent",
"funnel",
"green",
"guitar",
"grocery",
"glass",
"great",
"giggle",
"haircut",
"half",
"homemade",
"happen",
"honey",
"hurry",
"hundred",
"ice",
"igloo",
"invest",
"invite",
"icon",
"introduce",
"joke",
"jovial",
"journal",
"jump",
"join",
"kangaroo",
"keyboard",
"kitchen",
"koala",
"kind",
"kaleidoscope",
"landscape",
"late",
"laugh",
"learning",
"lemon",
"letter",
"lily",
"magazine",
"marine",
"marshmallow",
"maze",
"meditate",
"melody",
"minute",
"monument",
"moon",
"motorcycle",
"mountain",
"music",
"north",
"nose",
"night",
"name",
"never",
"negotiate",
"number",
"opposite",
"octopus",
"oak",
"order",
"open",
"polar",
"pack",
"painting",
"person",
"picnic",
"pillow",
"pizza",
"podcast",
"presentation",
"puppy",
"puzzle",
"recipe",
"release",
"restaurant",
"revolve",
"rewind",
"room",
"run",
"secret",
"seed",
"ship",
"shirt",
"should",
"small",
"spaceship",
"stargazing",
"skill",
"street",
"style",
"sunrise",
"taxi",
"tidy",
"timer",
"together",
"tooth",
"tourist",
"travel",
"truck",
"under",
"useful",
"unicorn",
"unique",
"uplift",
"uniform",
"vase",
"violin",
"visitor",
"vision",
"volume",
"view",
"walrus",
"wander",
"world",
"winter",
"well",
"whirlwind",
"x-ray",
"xylophone",
"yoga",
"yogurt",
"yoyo",
"you",
"year",
"yummy",
"zebra",
"zigzag",
"zoology",
"zone",
"zeal"
)

MainActivity Class

Kelas MainActivity merupakan entry point dari aplikasi Unscramble. Kelas ini merupakan turunan dari ComponentActivity. Di dalam metode onCreate(), fungsi setContent {} digunakan untuk menampilkan antarmuka pengguna dengan menggunakan Jetpack Compose. Seluruh tampilan dibungkus menggunakan UnscrambleTheme dan Surface.

Fungsi utama yang dipanggil dalam MainActivity adalah GameScreen(), yang merupakan titik awal tampilan permainan tebak kata.

GameScreen Function

Fungsi GameScreen() bertanggung jawab sebagai tampilan utama permainan yang memanfaatkan pola arsitektur MVVM. Fungsi ini menghubungkan View (UI Compose) dengan ViewModel (GameViewModel) dan State (GameUiState).

Elemen-elemen utama dalam GameScreen():
  • State Management: Menggunakan collectAsState() untuk mengambil nilai state terbaru dari GameViewModel.
  • GameLayout(): Menampilkan kata acak yang perlu ditebak oleh pengguna, disertai dengan input teks, validasi jawaban, dan instruksi permainan.
  • GameStatus(): Menampilkan skor pengguna secara real-time.
  • FinalScoreDialog(): Ditampilkan saat permainan berakhir, menunjukkan skor akhir dan opsi untuk bermain kembali atau keluar dari aplikasi.
Tombol interaksi:
  • Submit → Mengecek apakah jawaban pengguna benar.
  • Skip → Melewati kata saat ini dan melanjutkan ke kata berikutnya.


GameLayout Function

Fungsi GameLayout() bertugas menampilkan elemen-elemen interaktif utama dari permainan:

  • Scrambled Word: Kata yang sudah diacak dan harus ditebak oleh pengguna.
  • Word Count: Menampilkan urutan keberapa pengguna sedang bermain dari total jumlah soal.
  • OutlinedTextField: Kolom input untuk menuliskan tebakan. Jika jawaban salah, akan muncul indikator error menggunakan isError = true dan label akan berubah menjadi "Wrong guess".

Keyboard diatur menggunakan imeAction = Done agar pengguna bisa mengirimkan jawaban langsung dari keyboard.


GameStatus Function

Fungsi ini hanya menampilkan skor pengguna dalam sebuah Card, dengan menggunakan style dari MaterialTheme.typography.headlineMedium. Ini memberikan umpan balik langsung terhadap keberhasilan pengguna dalam menjawab soal.


FinalScoreDialog Function

Merupakan sebuah AlertDialog yang muncul saat permainan telah selesai. Dialog ini menampilkan skor akhir pengguna dan memberikan dua opsi:

  • Exit: Menutup aplikasi.
  • Play Again: Memulai ulang permainan dari awal menggunakan resetGame() pada GameViewModel.


GameViewModel Class

GameViewModel adalah pusat logika dan data dalam aplikasi ini, sesuai prinsip MVVM. ViewModel ini:

  • Menyimpan state permainan dalam MutableStateFlow<GameUiState>.
  • Menyediakan fungsi-fungsi utama seperti:
    • resetGame() – Mengatur ulang permainan ke kondisi awal.
    • updateUserGuess() – Memperbarui tebakan pengguna.
    • checkUserGuess() – Mengecek apakah tebakan sesuai dengan kata asli, memperbarui skor, dan memperbarui state.
    • skipWord() – Melewati kata saat ini tanpa menambah skor.

ViewModel ini memastikan UI tidak menyimpan logika bisnis, dan seluruh perubahan state terjadi melalui alur yang terpisah dan terkontrol.


GameUiState Data Class

GameUiState adalah kelas data yang merepresentasikan status tampilan permainan. Properti utamanya meliputi:

  • currentScrambledWord: Kata acak yang ditampilkan ke pengguna.
  • currentWordCount: Indeks kata ke-berapa dalam sesi permainan.
  • score: Skor total pengguna saat ini.
  • isGuessedWordWrong: Status apakah tebakan pengguna salah.
  • isGameOver: Penanda bahwa permainan telah selesai.

State ini akan dimodifikasi oleh GameViewModel dan dibaca secara reaktif oleh UI (GameScreen).


WordsData Object

WordsData menyimpan seluruh sumber kata yang digunakan dalam permainan. Elemen pentingnya:

MAX_NO_OF_WORDS: Batas maksimal kata yang akan ditampilkan per sesi permainan (default 10).

SCORE_INCREASE: Poin yang ditambahkan untuk setiap jawaban yang benar (default 20).

allWords: Sekumpulan kata-kata bahasa Inggris umum yang akan dipilih secara acak, diacak urutannya, lalu diberikan kepada pengguna untuk ditebak.


Arsitektur MVVM (Model-View-ViewModel)

Aplikasi Unscramble ini menerapkan arsitektur MVVM secara eksplisit:

  • Model → GameUiState dan data kata (WordsData)
  • View → GameScreen, GameLayout, GameStatus, dan FinalScoreDialog sebagai komponen UI berbasis Jetpack Compose
  • ViewModel → GameViewModel, yang bertanggung jawab mengelola state permainan dan menghubungkan logika dengan tampilan
Demo Aplikasi


Comments

Popular posts from this blog

ETS PPB A 2025 - My Money Notes App

Tugas 8 PPB A 2025 - Water Bottle Material Design

EAS PPB A 2025 - NoBoros Money App