Pemrograman Perangkat Beregerak - Tugas Pertemuan 13 - Timothy Hosia

MVVM World Unscramble

Nama: Timothy Hosia Budianto

NRP: 5025211098

Kelas: PPB - A

MVVM World Unscramble


Pada pertemuan kali ini ditugaskan untuk aplikasi World Unscrambpe dengan arsitektur MVVM World Unscramble



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

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
/*
* 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()
}
}
GameUiState.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

/**
* 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.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 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.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.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"
)

Struktur Kode Unscramble Game App

1. MainActivity

  • Activity utama menggunakan Jetpack Compose dengan ComponentActivity
  • Mengaktifkan enableEdgeToEdge() untuk full screen experience
  • Set theme dengan UnscrambleTheme dan surface menggunakan Modifier.fillMaxSize()
  • Entry point menuju GameScreen() sebagai komponen utama aplikasi
  • Menggunakan Material Design 3 dengan Surface sebagai container utama

2. GameScreen Composable (UI Controller)

  • State Management: Menggunakan GameViewModel dengan dependency injection viewModel()
  • State Collection: gameUiState dikumpulkan dengan collectAsState() dari StateFlow
  • Layout Structure: Column dengan verticalScroll, statusBarsPadding, dan safeDrawingPadding
  • UI Hierarchy: App title → Game layout → Button controls → Score display → Final dialog
  • Conditional Rendering: FinalScoreDialog muncul ketika gameUiState.isGameOver = true

3. GameLayout Composable (Core Game UI)

  • Parameter: 7 parameter termasuk currentScrambledWord, wordCount, isGuessWrong, dan callback functions
  • Card Container: Card dengan elevation = 5.dp dan CardDefaults.cardElevation
  • Word Display: Scrambled word ditampilkan dengan typography.displayMedium
  • Input Field: OutlinedTextField dengan error state, keyboard actions, dan IME handling
  • Word Counter: Badge dengan background colorScheme.surfaceTint menampilkan progress "X/10"

4. GameViewModel (Business Logic)

  • State Variables:
    • _uiState sebagai MutableStateFlow<GameUiState>
    • userGuess sebagai mutableStateOf("")
    • usedWords sebagai MutableSet<String>
  • Core Functions:
    • checkUserGuess() - validasi jawaban user
    • skipWord() - skip ke kata berikutnya
    • updateGameState() - update state berdasarkan kondisi game
  • Word Management: pickRandomWordAndShuffle() dengan rekursi untuk avoid duplikasi
  • Scoring Logic: Score bertambah SCORE_INCREASE = 20 per jawaban benar

5. Game State Management

  • GameUiState Data Class: 5 properties - currentScrambledWord, currentWordCount, score, isGuessedWordWrong, isGameOver
  • StateFlow Pattern: Unidirectional data flow dengan immutable state updates
  • State Updates: Menggunakan _uiState.update { } untuk atomic state changes
  • Error Handling: isGuessedWordWrong flag untuk menampilkan error UI feedback

6. Word Shuffling Algorithm

  • Shuffle Logic: shuffleCurrentWord() menggunakan toCharArray() dan shuffle()
  • Anti-Duplicate: Loop while memastikan scrambled word berbeda dari original
  • Word Selection: allWords.random() dari Set berisi 185+ kata dalam WordsData.kt
  • Used Words Tracking: usedWords.contains() mencegah kata berulang dalam satu game

7. UI Components & Styling

  • FinalScoreDialog: AlertDialog dengan congratulations message dan 2 action buttons
  • GameStatus: Card container menampilkan score dengan typography.headlineMedium
  • Button Actions: Primary Button untuk submit, OutlinedButton untuk skip
  • Input Validation: TextField dengan isError state dan dynamic label text
  • Activity Control: Dialog exit button memanggil activity.finish()

8. Theme & Design System

  • Material Design 3: Complete color scheme dengan light/dark mode support
  • Color Palette: 24 warna light theme + 24 warna dark theme dalam Color.kt
  • Typography: Custom Typography dengan headlineMedium style (28sp, Bold)
  • Shapes: RoundedCornerShape dengan small(4dp), medium(10dp), large(16dp)
  • Dynamic Colors: Conditional support untuk Android 12+ dynamic theming

9. Resource Management

  • String Resources: 11 string resources dalam strings.xml dengan placeholder support (%d)
  • Dimension Resources: padding_medium = 16dp untuk consistent spacing
  • Constants: MAX_NO_OF_WORDS = 10, SCORE_INCREASE = 20 dalam WordsData.kt
  • Word Database: Set dengan 185+ kata dari berbagai kategori (animals, objects, actions)

10. Game Flow & Logic

  • Game Lifecycle: Start → Pick word → Shuffle → User input → Validate → Update state → Repeat
  • Win Condition: User guess equals currentWord (case insensitive)
  • Game End: Setelah 10 kata atau user mencapai MAX_NO_OF_WORDS
  • Reset Functionality: resetGame() clear used words dan restart dengan score 0
  • Skip Mechanism: User dapat skip kata tanpa penalty, hanya melanjutkan ke kata berikutnya

11. Keyboard & Interaction

  • IME Integration: ImeAction.Done dengan KeyboardActions untuk submit on Enter
  • Input Management: Single line text field dengan real-time onValueChange
  • Error Feedback: Dynamic label berubah "Wrong Guess!" ketika salah
  • Button States: Submit dan Skip button selalu aktif untuk user flexibility

Aplikasi ini menggunakan modern Android development practices dengan Jetpack Compose, MVVM architecture, StateFlow untuk reactive programming, dan Material Design 3 untuk consistent user experience. Struktur modular memungkinkan easy testing dan future enhancements.

https://github.com/thossb/unscramble.git

Comments

Popular posts from this blog

Pemrograman Perangkat Beregerak - ETS - - Timothy Hosia

ETS PPL A

EAS PPL A - Perancangan Perangkat Lunak Hotel