ViewModel & State Dalam Compose dengan Aplikasi Unscramble
PPB I - Tugas 11
ViewModel & State Dalam Compose
dengan Aplikasi Unscramble
Halo semuanya, dalam tugas ini, digunakan aplikasi awal Unscramble, yaitu sebuah permainan di mana aplikasi menampilkan kata dengan huruf-huruf yang diacak dan pengguna harus menebak kata yang benar dari huruf-huruf tersebut. Jika pengguna menebak kata dengan benar, mereka akan mendapatkan poin, dan aplikasi juga memiliki fitur skip untuk mengubah soal. Penyelesaian tugas ini mengacu pada Google Codelab - ViewModel and State in Compose.
Adapun hasil implementasinya dapat dilihat dari kode berikut ini.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 | |
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() | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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) }, | |
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() | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.example.unscramble.ui | |
import android.util.Log | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.getValue | |
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) | |
} | |
} | |
Log.i("testing", userGuess) | |
Log.i("testing", currentWord) | |
// 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) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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.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" | |
) |
Komentar
Posting Komentar