summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorA404M <ahmadmahmoudiprogrammer@gmail.com>2025-06-26 14:11:59 +0330
committerA404M <ahmadmahmoudiprogrammer@gmail.com>2025-06-26 14:11:59 +0330
commitb6ecd5026e5e64b5e8fb67f84d6c89ec6a24db31 (patch)
tree0c5cf7ac65ed1fcc4bd02ade34cc4607e88c85ed /app/src/main/java
inital commitv0.1.0
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/com/a404m/mine_game/ContextHelper.kt9
-rw-r--r--app/src/main/java/com/a404m/mine_game/MainActivity.kt38
-rw-r--r--app/src/main/java/com/a404m/mine_game/core/Constants.kt4
-rw-r--r--app/src/main/java/com/a404m/mine_game/model/Action.kt22
-rw-r--r--app/src/main/java/com/a404m/mine_game/model/GameSettings.kt49
-rw-r--r--app/src/main/java/com/a404m/mine_game/model/GameState.kt236
-rw-r--r--app/src/main/java/com/a404m/mine_game/model/JsonSerializable.kt7
-rw-r--r--app/src/main/java/com/a404m/mine_game/storage/StorageBase.kt127
-rw-r--r--app/src/main/java/com/a404m/mine_game/storage/StorageGame.kt50
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Controls.kt78
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Game.kt442
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Home.kt286
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Route.kt166
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Settings.kt259
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/page/Splash.kt38
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/theme/Color.kt11
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/theme/Font.kt27
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/theme/Theme.kt82
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/theme/Type.kt34
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/Action.kt62
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/AppBar.kt54
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/Container.kt160
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/Dialog.kt47
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/Dropdown.kt86
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/Toast.kt15
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/modifier/Extension.kt323
-rw-r--r--app/src/main/java/com/a404m/mine_game/ui/utils/modifier/model/FreeScrollState.kt141
-rw-r--r--app/src/main/java/com/a404m/mine_game/utils/Application.kt14
-rw-r--r--app/src/main/java/com/a404m/mine_game/utils/Extensions.kt85
-rw-r--r--app/src/main/java/com/a404m/mine_game/utils/PersianDate.kt539
30 files changed, 3491 insertions, 0 deletions
diff --git a/app/src/main/java/com/a404m/mine_game/ContextHelper.kt b/app/src/main/java/com/a404m/mine_game/ContextHelper.kt
new file mode 100644
index 0000000..4203878
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ContextHelper.kt
@@ -0,0 +1,9 @@
+package com.a404m.mine_game
+
+import android.annotation.SuppressLint
+import androidx.activity.ComponentActivity
+
+@SuppressLint("StaticFieldLeak")
+object ContextHelper {
+ lateinit var context: ComponentActivity
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/MainActivity.kt b/app/src/main/java/com/a404m/mine_game/MainActivity.kt
new file mode 100644
index 0000000..7a7a275
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/MainActivity.kt
@@ -0,0 +1,38 @@
+package com.a404m.mine_game
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.a404m.mine_game.core.TAG
+import com.a404m.mine_game.model.GameState
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.ui.page.Route
+import com.a404m.mine_game.ui.theme.MineGameTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ ContextHelper.context = this
+
+ setContent {
+ MineGameTheme {
+ Route()
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ Log.d(
+ TAG,
+ "onPause: called"
+ )
+ val gameState = GameState.current
+ if(gameState != null) {
+ StorageGame.save(gameState)
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/core/Constants.kt b/app/src/main/java/com/a404m/mine_game/core/Constants.kt
new file mode 100644
index 0000000..91859a6
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/core/Constants.kt
@@ -0,0 +1,4 @@
+package com.a404m.mine_game.core
+
+
+const val TAG = "A404M"
diff --git a/app/src/main/java/com/a404m/mine_game/model/Action.kt b/app/src/main/java/com/a404m/mine_game/model/Action.kt
new file mode 100644
index 0000000..08d7ecf
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/model/Action.kt
@@ -0,0 +1,22 @@
+package com.a404m.mine_game.model
+
+import androidx.annotation.DrawableRes
+import com.a404m.mine_game.R
+
+enum class Action(
+ @DrawableRes val icon: Int,
+) {
+ FLAG(
+ icon = R.drawable.flag,
+ ),
+ OPEN(
+ icon = R.drawable.open,
+ ),
+ ;
+
+ companion object{
+ var current = OPEN
+
+ fun from(value:Int) = entries.find { it.ordinal == value }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/model/GameSettings.kt b/app/src/main/java/com/a404m/mine_game/model/GameSettings.kt
new file mode 100644
index 0000000..2889ce8
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/model/GameSettings.kt
@@ -0,0 +1,49 @@
+package com.a404m.mine_game.model
+
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.utils.getIntOrNull
+import org.json.JSONObject
+
+data class GameSettings(
+ val width: Int,
+ val height: Int,
+ val mines: Int,
+ val seed: Int?,
+) : JsonSerializable {
+
+ constructor(json: JSONObject) : this(
+ width = json.getInt("width"),
+ height = json.getInt("height"),
+ mines = json.getInt("mines"),
+ seed = json.getIntOrNull("seed"),
+ )
+
+ override fun toJson(): JSONObject = JSONObject()
+ .put(
+ "width",
+ width,
+ )
+ .put(
+ "height",
+ height,
+ )
+ .put(
+ "mines",
+ mines,
+ )
+ .put(
+ "seed",
+ seed,
+ )
+
+ companion object {
+ val default = GameSettings(
+ width = 9,
+ height = 9,
+ mines = 10,
+ seed = null,
+ )
+
+ var current = StorageGame.getGameSettings() ?: default
+ }
+}
diff --git a/app/src/main/java/com/a404m/mine_game/model/GameState.kt b/app/src/main/java/com/a404m/mine_game/model/GameState.kt
new file mode 100644
index 0000000..69d022a
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/model/GameState.kt
@@ -0,0 +1,236 @@
+package com.a404m.mine_game.model
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.a404m.mine_game.utils.PersianDate
+import com.a404m.mine_game.utils.map
+import com.a404m.mine_game.utils.toJson
+import org.json.JSONArray
+import org.json.JSONObject
+import kotlin.random.Random
+
+class GameState(
+ val startDate: PersianDate,
+ val matrix: ArrayList<ArrayList<Cell>>,
+ millis: Long = 0,
+) : JsonSerializable {
+ var millis by mutableLongStateOf(millis)
+
+ val seconds:Int
+ get() = ((millis/1000)%60).toInt()
+
+ val minutes:Int
+ get() = ((millis/1000)/60).toInt()
+
+ val remainingBombs: Int
+ get() = matrix.sumOf { it.count { cell -> cell.isBomb && !cell.isOpened } }
+
+ val flagCounts: Int
+ get() = matrix.sumOf { it.count { cell -> cell.isFlag } }
+
+ val bombCount: Int
+ get() = matrix.sumOf { it.count { cell -> cell.isBomb } }
+
+ constructor(
+ width: Int,
+ height: Int,
+ mines: Int,
+ seed: Int?,
+ ) : this(
+ startDate = PersianDate(),
+ matrix = arrayListOf<ArrayList<Cell>>().apply {
+ ensureCapacity(width)
+ var cellRemaining = width * height
+ if (cellRemaining < mines) {
+ throw IllegalStateException("Mines are more than cells $mines > $cellRemaining")
+ }
+ var bombsRemaining = mines
+
+ val random = if (seed == null) Random else Random(seed)
+
+ for (i in 0 until width) {
+ add(
+ arrayListOf<Cell>().apply {
+ ensureCapacity(height)
+ for (j in 0 until height) {
+ val isBomb = random.nextInt(cellRemaining) < bombsRemaining
+ add(
+ Cell(
+ isBomb = isBomb,
+ )
+ )
+ cellRemaining -= 1
+ if (isBomb) {
+ bombsRemaining -= 1
+ }
+ }
+ }
+ )
+ }
+
+ for (i in this.indices) {
+ val items = this[i]
+ for (j in items.indices) {
+ val cell = items[j]
+
+ var count = 0
+
+ for (i0 in -1..1) {
+ val index0 = i + i0
+ if (index0 !in this.indices) {
+ continue
+ }
+ val row = this[index0]
+ for (j0 in -1..1) {
+ val index1 = j + j0
+ if (index1 !in row.indices) {
+ continue
+ }
+ if (row[index1].isBomb) {
+ count++
+ }
+ }
+ }
+
+ cell.setCountAround(count)
+ }
+ }
+ }
+ )
+
+ constructor(json: JSONObject) : this(
+ startDate = PersianDate(json.getLong("start_date")),
+ matrix = ArrayList(json.getJSONArray("matrix").map {
+ ArrayList((it as JSONArray).map { cell ->
+ Cell(cell as JSONObject)
+ })
+ }),
+ millis = json.getLong("millis"),
+ )
+
+ override fun toJson(): JSONObject = JSONObject()
+ .put(
+ "start_date",
+ startDate.getTime(),
+ )
+ .put(
+ "matrix",
+ matrix.map {
+ it.map {
+ it.toJson()
+ }.toJson()
+ }.toJson()
+ )
+ .put(
+ "millis",
+ millis,
+ )
+
+ fun openNearbyCells(
+ i: Int,
+ j: Int,
+ ) {
+ if (i !in matrix.indices || j !in matrix[i].indices) {
+ return
+ }
+ val cell = matrix[i][j]
+
+ if (cell.isOpened) {
+ return
+ }
+ cell.isOpened = true
+
+ if (cell.countAround != 0) {
+ return
+ }
+
+ for (di in -1..1) {
+ for (dj in -1..1) {
+ if (di == 0 && dj == 0)
+ continue
+
+ openNearbyCells(
+ i + di,
+ j + dj
+ )
+ }
+ }
+ }
+
+ fun isLost(): Boolean {
+ return matrix.any { it.any { cell -> cell.isBomb && cell.isOpened } }
+ }
+
+ fun isWon(): Boolean {
+ return matrix.all { it.all { cell -> (cell.isBomb && cell.isFlag) || (!cell.isBomb && cell.isOpened) } }
+ }
+
+ class Cell(
+ flag: Int,
+ ) {
+ private var flagValue by mutableIntStateOf(flag)
+
+ constructor(
+ isBomb: Boolean,
+ ) : this(
+ flag = if (isBomb) BOMB_BIT else 0,
+ )
+
+ constructor(json: JSONObject) : this(
+ flag = json.getInt("flag"),
+ )
+
+ fun toJson(): JSONObject = JSONObject()
+ .put(
+ "flag",
+ flagValue,
+ )
+
+ val countAround: Int
+ get() = (flagValue and COUNT_BITS)
+
+ var isOpened: Boolean
+ get() = (flagValue and OPENED_BIT) != 0
+ set(value) {
+ flagValue = if (value) {
+ flagValue or OPENED_BIT
+ } else {
+ flagValue and OPENED_BIT.inv()
+ }
+ }
+
+ val isBomb: Boolean
+ get() = (flagValue and BOMB_BIT) != 0
+
+ var isFlag: Boolean
+ get() = (flagValue and FLAG_BIT) != 0
+ set(value) {
+ flagValue = if (value) {
+ flagValue or FLAG_BIT
+ } else {
+ flagValue and FLAG_BIT.inv()
+ }
+ }
+
+ fun setCountAround(count: Int) {
+ if (count !in 0..9) {
+ throw IllegalStateException("Bad count $count")
+ }
+ flagValue = (flagValue and COUNT_BITS.inv()) or count
+ }
+
+ companion object {
+ private const val COUNT_BITS = 0b1111
+ private const val OPENED_BIT = 0b10000
+ private const val BOMB_BIT = 0b100000
+ private const val FLAG_BIT = 0b1000000
+ }
+ }
+
+ companion object {
+ var current by mutableStateOf<GameState?>(null)
+ }
+}
diff --git a/app/src/main/java/com/a404m/mine_game/model/JsonSerializable.kt b/app/src/main/java/com/a404m/mine_game/model/JsonSerializable.kt
new file mode 100644
index 0000000..532f130
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/model/JsonSerializable.kt
@@ -0,0 +1,7 @@
+package com.a404m.mine_game.model
+
+import org.json.JSONObject
+
+interface JsonSerializable {
+ fun toJson():JSONObject
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/storage/StorageBase.kt b/app/src/main/java/com/a404m/mine_game/storage/StorageBase.kt
new file mode 100644
index 0000000..d3e0269
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/storage/StorageBase.kt
@@ -0,0 +1,127 @@
+package com.a404m.mine_game.storage
+
+import androidx.core.content.edit
+import com.a404m.mine_game.ContextHelper
+import com.a404m.mine_game.model.JsonSerializable
+import org.json.JSONObject
+
+open class StorageBase(
+ private val storageKey: String,
+) {
+ private fun getPrefs() = ContextHelper.context.getSharedPreferences(
+ storageKey,
+ 0,
+ )
+
+ protected fun save(
+ key: String,
+ value: String,
+ ) = getPrefs().edit {
+ putString(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: Int,
+ ) = getPrefs().edit {
+ putInt(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: Long,
+ ) = getPrefs().edit {
+ putLong(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: Float,
+ ) = getPrefs().edit {
+ putFloat(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: Boolean,
+ ) = getPrefs().edit {
+ putBoolean(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: Set<String>,
+ ) = getPrefs().edit {
+ putStringSet(
+ key,
+ value,
+ )
+ }
+
+ protected fun save(
+ key: String,
+ value: JsonSerializable,
+ ) = getPrefs().edit {
+ putString(
+ key,
+ value.toJson().toString(),
+ )
+ }
+
+ protected fun getString(key: String) = getPrefs().getString(
+ key,
+ null,
+ )
+
+ protected fun getInt(key: String) = getPrefs().getInt(
+ key,
+ -1,
+ )
+
+ protected fun getLong(key: String) = getPrefs().getLong(
+ key,
+ -1,
+ )
+
+ protected fun getFloat(key: String) = getPrefs().getFloat(
+ key,
+ -1f,
+ )
+
+ protected fun getBoolean(key: String) = getPrefs().getBoolean(
+ key,
+ false,
+ )
+
+ protected fun getStringSet(key: String) = getPrefs().getStringSet(
+ key,
+ null,
+ )
+
+ protected fun getJson(key: String): JSONObject? {
+ val value = getPrefs().getString(
+ key,
+ null,
+ ) ?: return null
+ return JSONObject(value)
+ }
+
+ protected fun contains(key: String) = getPrefs().contains(key)
+
+ protected fun delete(key: String) = getPrefs().edit { remove(key) }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/storage/StorageGame.kt b/app/src/main/java/com/a404m/mine_game/storage/StorageGame.kt
new file mode 100644
index 0000000..402fe20
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/storage/StorageGame.kt
@@ -0,0 +1,50 @@
+package com.a404m.mine_game.storage
+
+import com.a404m.mine_game.model.Action
+import com.a404m.mine_game.model.GameSettings
+import com.a404m.mine_game.model.GameState
+
+object StorageGame : StorageBase("StorageSettings") {
+ private const val GAME_SETTINGS_KEY = "game_settings"
+ private const val LAST_GAME_STATE_KEY = "last_game"
+ private const val PRIMARY_ACTION_KEY = "primary_action"
+
+ fun save(value: GameSettings) = save(
+ GAME_SETTINGS_KEY,
+ value,
+ )
+
+ fun getGameSettings(): GameSettings? {
+ val json = getJson(GAME_SETTINGS_KEY) ?: return null
+
+ return GameSettings(json)
+ }
+
+ fun deleteGameSettings() = delete(GAME_SETTINGS_KEY)
+
+ fun save(value: GameState) = save(
+ LAST_GAME_STATE_KEY,
+ value,
+ )
+
+ fun getLastGame(): GameState? {
+ val json = getJson(LAST_GAME_STATE_KEY) ?: return null
+
+ return GameState(json)
+ }
+
+ fun deleteLastGame() = delete(LAST_GAME_STATE_KEY)
+
+ fun savePrimaryAction(value: Action) = save(
+ PRIMARY_ACTION_KEY,
+ value.ordinal,
+ )
+
+ fun getPrimaryAction(): Action? {
+ val value = getInt(PRIMARY_ACTION_KEY)
+
+ return Action.from(value)
+ }
+
+ fun deletePrimaryAction() = delete(PRIMARY_ACTION_KEY)
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Controls.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Controls.kt
new file mode 100644
index 0000000..02ea22a
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Controls.kt
@@ -0,0 +1,78 @@
+package com.a404m.mine_game.ui.page
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.a404m.mine_game.model.Action
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.ui.utils.ActionChooser
+import com.a404m.mine_game.ui.utils.CustomScaffold
+import com.a404m.mine_game.ui.utils.TitledColumn
+import com.a404m.mine_game.ui.utils.TopBar
+
+
+@Composable
+fun ControlsPage(
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit,
+) {
+ var selectedAction by rememberSaveable { mutableStateOf(Action.current) }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ Action.current = selectedAction
+ StorageGame.savePrimaryAction(selectedAction)
+ }
+ }
+
+ CustomScaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = { innerPadding ->
+ TopBar(
+ modifier = Modifier.padding(innerPadding),
+ title = "Controls",
+ onBack = onBack,
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(10.dp),
+ ) {
+ TitledColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.medium,
+ ),
+ title = "Default Button",
+ ) {
+ ActionChooser(
+ modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 10.dp, bottom = 15.dp),
+ selected = selectedAction,
+ onSelect = {
+ selectedAction = it
+ },
+ )
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Game.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Game.kt
new file mode 100644
index 0000000..cf3b991
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Game.kt
@@ -0,0 +1,442 @@
+package com.a404m.mine_game.ui.page
+
+import android.util.Log
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ElevatedButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.a404m.mine_game.R
+import com.a404m.mine_game.core.TAG
+import com.a404m.mine_game.model.Action
+import com.a404m.mine_game.model.GameState
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.ui.utils.ActionChooser
+import com.a404m.mine_game.ui.utils.CustomScaffold
+import com.a404m.mine_game.ui.utils.modifier.freeScrollWithTransformGesture
+import com.a404m.mine_game.ui.utils.modifier.model.rememberFreeScrollState
+import com.a404m.mine_game.utils.PersianDate
+import kotlinx.coroutines.delay
+
+
+@Composable
+fun GamePage(
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit,
+ onNewGame: () -> Unit,
+ gameState: GameState,
+) {
+ val time by remember {
+ derivedStateOf {
+ "${gameState.minutes}:${
+ gameState.seconds.toString().padStart(
+ 2,
+ '0'
+ )
+ }"
+ }
+ }
+ val minesRemaining by remember { derivedStateOf { gameState.remainingBombs - gameState.flagCounts } }
+ val isLost by remember {
+ derivedStateOf {
+ gameState.isLost()
+ }
+ }
+ val isWon by remember {
+ derivedStateOf {
+ gameState.isWon()
+ }
+ }
+
+ var action by rememberSaveable { mutableStateOf(Action.current) }
+
+ var zoomOverall by rememberSaveable { mutableFloatStateOf(1f) }
+
+ LaunchedEffect(Unit) {
+ val startTime = PersianDate()
+ val startingMillis = gameState.millis
+ while (!isWon && !isLost) {
+ delay(50)
+ gameState.millis = startingMillis + PersianDate().getTime() - startTime.getTime()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ StorageGame.save(gameState)
+ }
+ }
+
+ CustomScaffold(
+ topBar = { innerPadding ->
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.background.copy(alpha = 0.9f),
+ )
+ .padding(innerPadding)
+ .padding(5.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ modifier = Modifier.padding(end = 10.dp),
+ onClick = onBack,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_back),
+ contentDescription = "Back",
+ )
+ }
+ Row(
+ modifier = Modifier.weight(1F),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LabeledIcon(
+ modifier = Modifier.padding(end = 12.dp),
+ icon = R.drawable.time,
+ contentDescription = "Time",
+ text = time,
+ )
+ LabeledIcon(
+ icon = R.drawable.mine,
+ contentDescription = "Mines remaining",
+ text = minesRemaining.toString(),
+ )
+ }
+ IconButton(
+ modifier = Modifier.padding(end = 10.dp),
+ onClick = onBack,
+ ) {
+ Row(
+ modifier = Modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier,
+ text = "6",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Icon(
+ painter = painterResource(R.drawable.hint),
+ contentDescription = "Back",
+ )
+ }
+ }
+ }
+ }
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .freeScrollWithTransformGesture(
+ rememberFreeScrollState(),
+ onGesture = { centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->
+ zoomOverall *= zoom
+ if (zoomOverall >= 1f) {
+ zoomOverall = 1f
+ } else if (zoomOverall <= 0.1f) {
+ zoomOverall = 0.1f
+ }
+ Log.d(
+ TAG,
+ "GamePage: zoom = $zoomOverall"
+ )
+ },
+ )
+ .padding(innerPadding)
+ .padding(100.dp),
+ ) {
+ for (i in gameState.matrix.indices) {
+ val items = gameState.matrix[i]
+ Row {
+ for (j in items.indices) {
+ val cell = items[j]
+ Cell(
+ cell = cell,
+ zoom = zoomOverall,
+ onSelect = {
+ if(isLost || isWon){
+ return@Cell
+ }
+ when (action) {
+ Action.OPEN -> {
+ if (cell.isFlag) {
+ return@Cell
+ }
+ if (cell.isBomb) {
+ cell.isOpened = true
+ } else if (cell.countAround == 0) {
+ gameState.openNearbyCells(
+ i,
+ j,
+ )
+ } else {
+ cell.isOpened = true
+ }
+ }
+
+ Action.FLAG -> {
+ if (cell.isOpened) {
+ return@Cell
+ }
+ cell.isFlag = !cell.isFlag
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ ActionChooser(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(innerPadding)
+ .padding(bottom = 20.dp),
+ selected = action,
+ onSelect = {
+ action = it
+ }
+ )
+ }
+ }
+
+ var dialogIsDismissed by remember { mutableStateOf(false) }
+
+ if(!dialogIsDismissed) {
+ if (isLost) {
+ Dialog(
+ onDismissRequest = {
+ dialogIsDismissed = true
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().background(
+ color = MaterialTheme.colorScheme.background,
+ shape = MaterialTheme.shapes.large,
+ ).padding(10.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ text = "YOU LOST!",
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ text = "Good luck on your next game.",
+ textAlign = TextAlign.Center,
+ )
+ ElevatedButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ onClick = onNewGame,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "New Game",
+ textAlign = TextAlign.Center,
+ )
+ }
+ ElevatedButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ onClick = onBack,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Back",
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ } else if (isWon) {
+ Dialog(
+ onDismissRequest = {
+ dialogIsDismissed = true
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().background(
+ color = MaterialTheme.colorScheme.background,
+ shape = MaterialTheme.shapes.large,
+ ).padding(10.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ text = "YOU WON!",
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ text = "You found ${gameState.remainingBombs} mines in ${gameState.minutes}:${
+ gameState.seconds.toString().padStart(
+ 2,
+ '0'
+ )
+ }",
+ textAlign = TextAlign.Center,
+ )
+ ElevatedButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ onClick = onNewGame,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "New Game",
+ textAlign = TextAlign.Center,
+ )
+ }
+ ElevatedButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp),
+ onClick = onBack,
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Back",
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun LabeledIcon(
+ modifier: Modifier = Modifier,
+ @DrawableRes icon: Int,
+ contentDescription: String?,
+ text: String,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier
+ .padding(end = 8.dp)
+ .size(24.dp),
+ painter = painterResource(icon),
+ contentDescription = contentDescription,
+ )
+ Text(
+ modifier = Modifier,
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+}
+
+@Composable
+fun Cell(
+ modifier: Modifier = Modifier,
+ cell: GameState.Cell,
+ zoom: Float,
+ onSelect: () -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .size(50.dp * zoom)
+ .background(
+ color =
+ if (cell.isOpened) Color.Transparent
+ else MaterialTheme.colorScheme.primary,
+ shape = RoundedCornerShape(0),
+ )
+ .let {
+ if (!cell.isOpened) {
+ it.clickable {
+ onSelect()
+ }
+ } else {
+ it
+ }
+ }
+ .padding(8.dp * zoom),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (cell.isOpened) {
+ if (cell.isBomb) {
+ Icon(
+ modifier = Modifier.fillMaxSize(),
+ painter = painterResource(R.drawable.mine),
+ contentDescription = "Mine",
+ )
+ } else if (cell.countAround != 0) {
+ Text(
+ text = cell.countAround.toString(),
+ style = MaterialTheme.typography.titleSmall.copy(
+ fontSize = MaterialTheme.typography.titleSmall.fontSize * zoom,
+ ),
+ lineHeight = 0.0001.sp,
+ )
+ }
+ } else {
+ if (cell.isFlag) {
+ Icon(
+ modifier = Modifier.fillMaxSize(),
+ painter = painterResource(R.drawable.flag),
+ contentDescription = "Flag",
+ )
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Home.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Home.kt
new file mode 100644
index 0000000..2a97c01
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Home.kt
@@ -0,0 +1,286 @@
+package com.a404m.mine_game.ui.page
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.a404m.mine_game.R
+import com.a404m.mine_game.model.GameState
+import com.a404m.mine_game.ui.utils.CustomScaffold
+import com.a404m.mine_game.utils.getApplicationName
+
+@Composable
+fun HomePage(
+ modifier: Modifier = Modifier,
+ onGoToContinue: () -> Unit,
+ onGoToNewGame: () -> Unit,
+ onGoToThemes: () -> Unit,
+ onGoToControls: () -> Unit,
+ onGoToSettings: () -> Unit,
+ onGoToStatistics: () -> Unit,
+ onGoToPreviousGames: () -> Unit,
+ onGoToDonation: () -> Unit,
+ onGoToTutorial: () -> Unit,
+ onGoToLanguage: () -> Unit,
+ onGoToAbout: () -> Unit,
+) {
+ val hasLastGame by remember { derivedStateOf { GameState.current != null } }
+ CustomScaffold(
+ modifier = modifier.fillMaxSize(),
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .align(Alignment.Center)
+ .padding(innerPadding)
+ .padding(horizontal = 30.dp)
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.Start,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ LogoAndName(
+ modifier = Modifier
+ .padding(bottom = 10.dp)
+ .fillMaxWidth(),
+ color = Color(0xff303030),
+ )
+ MenuGroup(
+ modifier = Modifier
+ .padding(bottom = 10.dp)
+ .fillMaxWidth(),
+ items = if (hasLastGame) listOf(
+ MenuItem(
+ icon = R.drawable.play,
+ text = "Continue",
+ onClick = onGoToContinue,
+ isPrimary = true,
+ ),
+ MenuItem(
+ icon = R.drawable.add,
+ text = "New Game",
+ onClick = onGoToNewGame,
+ ),
+ ) else listOf(
+ MenuItem(
+ icon = R.drawable.add,
+ text = "New Game",
+ onClick = onGoToNewGame,
+ ),
+ ),
+ )
+ MenuGroup(
+ modifier = Modifier
+ .padding(bottom = 10.dp)
+ .fillMaxWidth(),
+ items = listOf(
+ MenuItem(
+ icon = R.drawable.theme,
+ text = "Themes",
+ onClick = onGoToThemes,
+ ),
+ MenuItem(
+ icon = R.drawable.controls,
+ text = "Controls",
+ onClick = onGoToControls,
+ ),
+ MenuItem(
+ icon = R.drawable.settings,
+ text = "Settings",
+ onClick = onGoToSettings,
+ ),
+ ),
+ )
+ MenuGroup(
+ modifier = Modifier
+ .padding(bottom = 10.dp)
+ .fillMaxWidth(),
+ items = listOf(
+ MenuItem(
+ icon = R.drawable.statistics,
+ text = "Statistics",
+ onClick = onGoToStatistics,
+ ),
+ MenuItem(
+ icon = R.drawable.history,
+ text = "Previous Games",
+ onClick = onGoToPreviousGames,
+ ),
+ ),
+ )
+ MenuGroup(
+ modifier = Modifier
+ .padding(bottom = 10.dp)
+ .fillMaxWidth(),
+ items = listOf(
+ MenuItem(
+ icon = R.drawable.donate,
+ text = "Donation",
+ onClick = onGoToDonation,
+ ),
+ MenuItem(
+ icon = R.drawable.tutorial,
+ text = "Tutorial",
+ onClick = onGoToTutorial,
+ ),
+ MenuItem(
+ icon = R.drawable.language,
+ text = "Language",
+ onClick = onGoToLanguage,
+ ),
+ MenuItem(
+ icon = R.drawable.about_us,
+ text = "About",
+ onClick = onGoToAbout,
+ ),
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+fun LogoAndName(
+ modifier: Modifier = Modifier,
+ color: Color,
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+
+ var height by remember { mutableStateOf(0.dp) }
+
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier
+ .padding(end = 2.dp)
+ .size(height)
+ .padding(bottom = 2.dp),
+ painter = painterResource(R.drawable.mine),
+ contentDescription = "Logo",
+ tint = color,
+ )
+ Text(
+ modifier = Modifier.onGloballyPositioned {
+ with(density) {
+ height = it.size.height.toDp()
+ }
+ },
+ text = getApplicationName(context),
+ style = MaterialTheme.typography.titleLarge,
+ color = color,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 0.01.sp,
+ maxLines = 1,
+ minLines = 1,
+ )
+ }
+}
+
+data class MenuItem(
+ @DrawableRes val icon: Int,
+ val text: String,
+ val onClick: () -> Unit,
+ val isPrimary: Boolean = false,
+)
+
+@Composable
+fun MenuGroup(
+ modifier: Modifier = Modifier,
+ items: List<MenuItem>,
+) {
+ val density = LocalDensity.current
+
+ Column(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.medium,
+ )
+ .padding(4.dp),
+ ) {
+ for (i in items.indices) {
+ val item = items[i]
+ val backgroundColor =
+ if (item.isPrimary) MaterialTheme.colorScheme.primary
+ else Color.Transparent
+ val contentColor =
+ if (item.isPrimary) MaterialTheme.colorScheme.contentColorFor(backgroundColor)
+ else MaterialTheme.colorScheme.primary
+
+ if (i != 0) {
+ Spacer(
+ modifier = Modifier.height(4.dp),
+ )
+ }
+ Row(
+ modifier = Modifier
+ .background(
+ color = backgroundColor,
+ shape = MaterialTheme.shapes.small,
+ )
+ .clip(
+ shape = MaterialTheme.shapes.small,
+ )
+ .fillMaxWidth()
+ .clickable {
+ item.onClick()
+ }
+ .padding(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier
+ .padding(end = 5.dp)
+ .size(25.dp),
+ painter = painterResource(item.icon),
+ contentDescription = "Icon",
+ tint = contentColor,
+ )
+ Text(
+ modifier = Modifier,
+ text = item.text.uppercase(),
+ style = MaterialTheme.typography.titleMedium,
+ color = contentColor,
+ lineHeight = 0.01.sp,
+ maxLines = 1,
+ minLines = 1,
+ )
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Route.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Route.kt
new file mode 100644
index 0000000..f622952
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Route.kt
@@ -0,0 +1,166 @@
+package com.a404m.mine_game.ui.page
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.a404m.mine_game.core.TAG
+import com.a404m.mine_game.model.GameSettings
+import com.a404m.mine_game.model.GameState
+import com.a404m.mine_game.storage.StorageGame
+
+@Composable
+fun Route(
+ modifier: Modifier = Modifier,
+ navController: NavHostController = rememberNavController(),
+) {
+ NavHost(
+ modifier = modifier,
+ navController = navController,
+ startDestination = AppRoute.getSplashRoute(),
+ ) {
+ composable(
+ route = AppRoute.getSplashStaticRoute(),
+ ) {
+ Log.d(
+ TAG,
+ "Route: Hey"
+ )
+ SplashPage(
+ onGoToHome = {
+ navController.navigate(
+ AppRoute.getHomeRoute(),
+ ) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ }
+ },
+ )
+ }
+ composable(
+ route = AppRoute.getHomeStaticRoute(),
+ ) {
+ HomePage(
+ onGoToContinue = {
+ navController.navigate(AppRoute.getGameRoute(newGame = false))
+ },
+ onGoToNewGame = {
+ navController.navigate(AppRoute.getGameRoute(newGame = true))
+ },
+ onGoToThemes = {},
+ onGoToControls = {
+ navController.navigate(AppRoute.getControlsRoute())
+ },
+ onGoToSettings = {
+ navController.navigate(AppRoute.getSettingsRoute())
+ },
+ onGoToStatistics = {},
+ onGoToPreviousGames = {},
+ onGoToDonation = {},
+ onGoToTutorial = {},
+ onGoToLanguage = {},
+ onGoToAbout = {}
+ )
+ }
+ composable(
+ route = AppRoute.getGameStaticRoute(),
+ arguments = AppRoute.getGameArguments(),
+ ) {
+ val newGame = it.arguments!!.getBoolean("new_game")
+
+ val gameState = remember {
+ if (newGame) {
+ val gameSettings = GameSettings.current
+ GameState(
+ width = gameSettings.width,
+ height = gameSettings.height,
+ mines = gameSettings.mines,
+ seed = gameSettings.seed,
+ )
+ } else {
+ StorageGame.getLastGame()!!
+ }
+ }
+
+ GameState.current = gameState
+
+ GamePage(
+ onBack = {
+ navController.popBackStack()
+ },
+ onNewGame = {
+ navController.navigate(AppRoute.getGameRoute(true)){
+ popUpTo(navController.graph.findStartDestination().id){
+ inclusive = true
+ }
+ }
+ },
+ gameState = gameState,
+ )
+ }
+ composable(
+ route = AppRoute.getControlsStaticRoute(),
+ ) {
+ ControlsPage(
+ onBack = {
+ navController.popBackStack()
+ },
+ )
+ }
+ composable(
+ route = AppRoute.getSettingsStaticRoute(),
+ ) {
+ SettingsPage(
+ onBack = {
+ navController.popBackStack()
+ },
+ )
+ }
+ }
+}
+
+object AppRoute {
+ private const val SPLASH = "splash"
+ private const val HOME = "home"
+ private const val GAME = "game"
+ private const val CONTROLS = "controls"
+ private const val SETTINGS = "settings"
+
+ fun getSplashStaticRoute() = "/$SPLASH"
+ fun getSplashRoute() = "/$SPLASH"
+
+ fun getHomeStaticRoute() = "/$HOME"
+ fun getHomeRoute() = "/$HOME"
+
+ fun getGameStaticRoute() = "/$GAME/{new_game}"
+ fun getGameRoute(newGame: Boolean) = "/$GAME/$newGame"
+ fun getGameArguments() = listOf(
+ navArgument("new_game") {
+ type = NavType.BoolType
+ },
+ )
+
+ fun getControlsStaticRoute() = "/$CONTROLS"
+ fun getControlsRoute() = "/$CONTROLS"
+
+ fun getSettingsStaticRoute() = "/$SETTINGS"
+ fun getSettingsRoute() = "/$SETTINGS"
+
+ /*
+ fun getChatStaticRoute() = "/$CHAT/{chat_id}"
+ fun getChatRoute(chat: Chat) = "/$CHAT/${chat.id}"
+ fun getChatArguments() = listOf(
+ navArgument("chat_id") {
+ type = NavType.IntType
+ },
+ )
+ */
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Settings.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Settings.kt
new file mode 100644
index 0000000..09c07f1
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Settings.kt
@@ -0,0 +1,259 @@
+package com.a404m.mine_game.ui.page
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+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.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
+import androidx.compose.ui.unit.sp
+import com.a404m.mine_game.R
+import com.a404m.mine_game.model.GameSettings
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.ui.utils.CustomScaffold
+import com.a404m.mine_game.ui.utils.TitledColumn
+import com.a404m.mine_game.ui.utils.TopBar
+import com.a404m.mine_game.ui.utils.showToast
+
+@Composable
+fun SettingsPage(
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit,
+) {
+ var width by rememberSaveable { mutableStateOf(GameSettings.current.width.toString()) }
+ var height by rememberSaveable { mutableStateOf(GameSettings.current.height.toString()) }
+ var mines by rememberSaveable { mutableStateOf(GameSettings.current.mines.toString()) }
+ var seed by rememberSaveable { mutableStateOf(GameSettings.current.seed?.toString() ?: "") }
+
+ val isWidthValid by remember {
+ derivedStateOf {
+ val width = width.toIntOrNull()
+ (width?:0) >= 1
+ }
+ }
+ val isHeightValid by remember {
+ derivedStateOf {
+ val height = height.toIntOrNull()
+ (height?:0) >= 1
+ }
+ }
+ val isMinesValid by remember {
+ derivedStateOf {
+ val mines = mines.toIntOrNull() ?: return@derivedStateOf false
+
+ val width = width.toIntOrNull() ?: return@derivedStateOf true
+ val height = height.toIntOrNull() ?: return@derivedStateOf true
+
+ mines in 1 until (width * height)
+ }
+ }
+ val isSeedValid by remember { derivedStateOf { seed.isEmpty() || seed.toIntOrNull() != null } }
+
+ CustomScaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = { innerPadding ->
+ TopBar(
+ modifier = Modifier.padding(innerPadding),
+ title = "Settings",
+ onBack = onBack,
+ actions = {
+ IconButton(
+ onClick = {
+ GameSettings.current = GameSettings.default
+ width = GameSettings.current.width.toString()
+ height = GameSettings.current.height.toString()
+ mines = GameSettings.current.mines.toString()
+ seed = GameSettings.current.seed?.toString() ?: ""
+ StorageGame.deleteGameSettings()
+ },
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.delete),
+ contentDescription = "Delete to default settings",
+ )
+ }
+ IconButton(
+ onClick = {
+ if (!isWidthValid ||
+ !isHeightValid ||
+ !isMinesValid ||
+ !isSeedValid
+ ) {
+ showToast("Fix the errors")
+ return@IconButton
+ }
+ val settings = GameSettings(
+ width = width.toInt(),
+ height = height.toInt(),
+ mines = mines.toInt(),
+ seed = seed.toIntOrNull(),
+ )
+ GameSettings.current = settings
+ StorageGame.save(settings)
+ },
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.save),
+ contentDescription = "Save settings",
+ )
+ }
+ },
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(10.dp),
+ ) {
+ TitledColumn(
+ modifier = Modifier
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.medium,
+ ),
+ title = "New Game",
+ ) {
+ val density = LocalDensity.current
+ var spaceBetween by remember { mutableStateOf(0.dp) }
+ Row(
+ modifier = Modifier.padding(
+ start = 20.dp,
+ end = 20.dp,
+ bottom = max(
+ 0.dp,
+ spaceBetween - 8.dp
+ ),
+ ),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ modifier = Modifier
+ .weight(1F),
+ value = width,
+ onValueChange = {
+ width = it
+ },
+ isError = !isWidthValid,
+ label = {
+ Text(
+ text = "Width",
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ ),
+ )
+ Text(
+ modifier = Modifier
+ .onGloballyPositioned {
+ with(density) {
+ spaceBetween = it.size.width.toDp()
+ }
+ }
+ .padding(top = 6.dp)
+ .padding(horizontal = 2.dp),
+ text = "X",
+ style = MaterialTheme.typography.labelSmall,
+ lineHeight = 0.01.sp,
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .weight(1F),
+ value = height,
+ onValueChange = {
+ height = it
+ },
+ isError = !isHeightValid,
+ label = {
+ Text(
+ text = "Height",
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ ),
+ )
+ }
+ Row(
+ modifier = Modifier.padding(
+ start = 20.dp,
+ end = 20.dp,
+ bottom = 20.dp
+ ),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ modifier = Modifier
+ .weight(1F),
+ value = mines,
+ onValueChange = {
+ mines = it
+ },
+ isError = !isMinesValid,
+ label = {
+ Text(
+ text = "Mines",
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ ),
+ )
+ Spacer(
+ modifier = Modifier.width(spaceBetween),
+ )
+ OutlinedTextField(
+ modifier = Modifier
+ .weight(1F),
+ value = seed,
+ onValueChange = {
+ seed = it
+ },
+ isError = !isSeedValid,
+ label = {
+ Text(
+ text = "Seed",
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ ),
+ )
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/page/Splash.kt b/app/src/main/java/com/a404m/mine_game/ui/page/Splash.kt
new file mode 100644
index 0000000..7db68f6
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/page/Splash.kt
@@ -0,0 +1,38 @@
+package com.a404m.mine_game.ui.page
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.a404m.mine_game.model.Action
+import com.a404m.mine_game.model.GameSettings
+import com.a404m.mine_game.model.GameState
+import com.a404m.mine_game.storage.StorageGame
+import com.a404m.mine_game.ui.utils.CustomScaffold
+
+@Composable
+fun SplashPage(
+ modifier: Modifier = Modifier,
+ onGoToHome: () -> Unit,
+) {
+ LaunchedEffect(Unit) {
+ GameSettings.current = StorageGame.getGameSettings() ?: GameSettings.default
+ GameState.current = StorageGame.getLastGame()
+ Action.current = StorageGame.getPrimaryAction() ?: Action.OPEN
+ onGoToHome()
+ }
+
+ CustomScaffold(
+ modifier = modifier.fillMaxSize(),
+ ) { innerPadding ->
+ Text(
+ modifier = Modifier
+ .padding(innerPadding)
+ .align(Alignment.Center),
+ text = "Loading..."
+ )
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/theme/Color.kt b/app/src/main/java/com/a404m/mine_game/ui/theme/Color.kt
new file mode 100644
index 0000000..c414f46
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.a404m.mine_game.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
diff --git a/app/src/main/java/com/a404m/mine_game/ui/theme/Font.kt b/app/src/main/java/com/a404m/mine_game/ui/theme/Font.kt
new file mode 100644
index 0000000..d35fab3
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/theme/Font.kt
@@ -0,0 +1,27 @@
+package com.a404m.mine_game.ui.theme
+
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import com.a404m.mine_game.R
+
+object AppFont {
+ val IranYekan = FontFamily(
+ Font(R.font.iranyekan_bold, weight = FontWeight.Bold),
+ Font(R.font.iranyekan_medium, weight = FontWeight.Medium),
+ Font(R.font.iranyekan_regular, weight = FontWeight.Normal),
+ Font(R.font.iranyekan_light, weight = FontWeight.Light),
+ )
+ val VazirMatn = FontFamily(
+ Font(R.font.vazirmatn),
+ Font(R.font.vazirmatn_black, weight = FontWeight.Black),
+ Font(R.font.vazirmatn_extrabold, weight = FontWeight.ExtraBold),
+ Font(R.font.vazirmatn_bold, weight = FontWeight.Bold),
+ Font(R.font.vazirmatn_semibold, weight = FontWeight.SemiBold),
+ Font(R.font.vazirmatn_medium, weight = FontWeight.Medium),
+ Font(R.font.vazirmatn_regular, weight = FontWeight.Normal),
+ Font(R.font.vazirmatn_light, weight = FontWeight.Light),
+ Font(R.font.vazirmatn_extralight, weight = FontWeight.ExtraLight),
+ Font(R.font.vazirmatn_thin, weight = FontWeight.Thin),
+ )
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/theme/Theme.kt b/app/src/main/java/com/a404m/mine_game/ui/theme/Theme.kt
new file mode 100644
index 0000000..20aa623
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/theme/Theme.kt
@@ -0,0 +1,82 @@
+package com.a404m.mine_game.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val defaultTypography = Typography()
+private val typography = defaultTypography
+ /* Typography(
+ displayLarge = defaultTypography.displayLarge.copy(fontFamily = AppFont.VazirMatn),
+ displayMedium = defaultTypography.displayMedium.copy(fontFamily = AppFont.VazirMatn),
+ displaySmall = defaultTypography.displaySmall.copy(fontFamily = AppFont.VazirMatn),
+
+ headlineLarge = defaultTypography.headlineLarge.copy(fontFamily = AppFont.VazirMatn),
+ headlineMedium = defaultTypography.headlineMedium.copy(fontFamily = AppFont.VazirMatn),
+ headlineSmall = defaultTypography.headlineSmall.copy(fontFamily = AppFont.VazirMatn),
+
+ titleLarge = defaultTypography.titleLarge.copy(fontFamily = AppFont.VazirMatn),
+ titleMedium = defaultTypography.titleMedium.copy(fontFamily = AppFont.VazirMatn),
+ titleSmall = defaultTypography.titleSmall.copy(fontFamily = AppFont.VazirMatn),
+
+ bodyLarge = defaultTypography.bodyLarge.copy(fontFamily = AppFont.VazirMatn),
+ bodyMedium = defaultTypography.bodyMedium.copy(fontFamily = AppFont.VazirMatn),
+ bodySmall = defaultTypography.bodySmall.copy(fontFamily = AppFont.VazirMatn),
+
+ labelLarge = defaultTypography.labelLarge.copy(fontFamily = AppFont.VazirMatn),
+ labelMedium = defaultTypography.labelMedium.copy(fontFamily = AppFont.VazirMatn),
+ labelSmall = defaultTypography.labelSmall.copy(fontFamily = AppFont.VazirMatn),
+)
+ */
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ surface = Color(0xFFFFFBFE),
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun MineGameTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = typography,
+ content = {
+ content()
+ },
+ )
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/theme/Type.kt b/app/src/main/java/com/a404m/mine_game/ui/theme/Type.kt
new file mode 100644
index 0000000..900ff40
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.a404m.mine_game.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+) \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/Action.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/Action.kt
new file mode 100644
index 0000000..3e06a0a
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/Action.kt
@@ -0,0 +1,62 @@
+package com.a404m.mine_game.ui.utils
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.a404m.mine_game.model.Action
+
+@Composable
+fun ActionChooser(
+ modifier: Modifier = Modifier,
+ selected: Action,
+ onSelect: (Action) -> Unit,
+) {
+ val contentColor = MaterialTheme.colorScheme.primary
+ val backgroundColor = MaterialTheme.colorScheme.onPrimary
+ Row(
+ modifier = modifier
+ .background(
+ color = backgroundColor,
+ shape = MaterialTheme.shapes.small,
+ )
+ .border(
+ width = 2.dp,
+ color = contentColor,
+ shape = MaterialTheme.shapes.small,
+ ),
+ ) {
+ for (action in Action.entries) {
+ val isSelected = action == selected
+ Icon(
+ modifier = Modifier
+ .background(
+ color = if (isSelected) contentColor else backgroundColor,
+ shape = MaterialTheme.shapes.small,
+ )
+ .clip(
+ shape = MaterialTheme.shapes.small,
+ )
+ .clickable {
+ onSelect(action)
+ }
+ .padding(10.dp)
+ .size(25.dp),
+ painter = painterResource(action.icon),
+ contentDescription = action.name,
+ tint =
+ if (isSelected) backgroundColor
+ else contentColor,
+ )
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/AppBar.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/AppBar.kt
new file mode 100644
index 0000000..bf15d24
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/AppBar.kt
@@ -0,0 +1,54 @@
+package com.a404m.mine_game.ui.utils
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.a404m.mine_game.R
+
+@Composable
+fun TopBar(
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit,
+ title: String,
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ modifier = Modifier.padding(end = 10.dp),
+ onClick = onBack,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.arrow_back),
+ contentDescription = "Back",
+ )
+ }
+ Text(
+ modifier = Modifier
+ .padding(end = 10.dp),
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Row(
+ modifier = Modifier
+ .weight(1F),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ actions()
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/Container.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/Container.kt
new file mode 100644
index 0000000..e40c42f
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/Container.kt
@@ -0,0 +1,160 @@
+package com.a404m.mine_game.ui.utils
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.add
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CustomScaffold(
+ modifier: Modifier = Modifier,
+ topBar: (@Composable (PaddingValues) -> Unit)? = null,
+ bottomBar: (@Composable (PaddingValues) -> Unit)? = null,
+ content: @Composable BoxScope.(PaddingValues) -> Unit,
+) {
+ val density = LocalDensity.current
+ var topBarHeight by remember { mutableStateOf(0.dp) }
+
+ Box(
+ modifier = modifier
+ .background(
+ color = MaterialTheme.colorScheme.background,
+ )
+ .imePadding(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ ) {
+ content(
+ calcContentWindowInsets(
+ isTopBarVisible = topBar != null,
+ isBottomBarVisible = bottomBar != null,
+ topBarInsets = WindowInsets(
+ top = topBarHeight,
+ ),
+ ).asPaddingValues()
+ )
+ }
+ if (topBar != null) {
+ Box(
+ modifier = Modifier.onGloballyPositioned {
+ with(density) {
+ topBarHeight = it.size.height.toDp()
+ }
+ },
+ ) {
+ topBar(
+ WindowInsets.statusBars.asPaddingValues()
+ )
+ }
+ }
+ if (bottomBar != null)
+ bottomBar(
+ calcBottomBarPadding().asPaddingValues()
+ )
+ }
+}
+
+@Composable
+private fun calcContentWindowInsets(
+ isTopBarVisible: Boolean,
+ isBottomBarVisible: Boolean,
+ topBarInsets: WindowInsets,
+): WindowInsets {
+ return if (isTopBarVisible) {
+ if (isBottomBarVisible) {
+ topBarInsets
+ } else {
+ calcBottomBarPadding().add(topBarInsets)
+ }
+ } else if (isBottomBarVisible) {
+ WindowInsets.statusBars
+ } else {
+ WindowInsets.statusBars.add(calcBottomBarPadding())
+ }
+}
+
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun calcBottomBarPadding(): WindowInsets {
+ val bottomBar = WindowInsets.navigationBars
+ return if (WindowInsets.isImeVisible) {
+ val density = LocalDensity.current
+ val direction = LocalLayoutDirection.current
+
+ val imePadding = WindowInsets.ime
+ if (bottomBar.getBottom(density) >= imePadding.getBottom(density)) {
+ WindowInsets(
+ left = bottomBar.getLeft(
+ density,
+ direction,
+ ),
+ right = bottomBar.getRight(
+ density,
+ direction,
+ ),
+ top = bottomBar.getTop(density),
+ bottom = bottomBar.getBottom(density) - imePadding.getBottom(density),
+ )
+ } else {
+ WindowInsets(
+ left = 0.dp,
+ right = 0.dp,
+ top = 0.dp,
+ bottom = 0.dp,
+ )
+ }
+ } else {
+ bottomBar
+ }
+}
+
+@Composable
+fun TitledColumn(
+ modifier: Modifier = Modifier,
+ title: String,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(
+ modifier = modifier.padding(5.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(
+ top = 10.dp,
+ start = 15.dp,
+ end = 15.dp,
+ bottom = 5.dp,
+ ),
+ text = title.uppercase(),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ content()
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/Dialog.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/Dialog.kt
new file mode 100644
index 0000000..6b1a552
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/Dialog.kt
@@ -0,0 +1,47 @@
+package com.a404m.mine_game.ui.utils
+
+import android.view.ViewGroup
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.window.Dialog
+import com.a404m.mine_game.ContextHelper
+import com.a404m.mine_game.ui.theme.MineGameTheme
+
+fun showDialog(
+ canDismiss: Boolean = true,
+ onDismiss: (byUser: Boolean) -> Unit = {},
+ content: @Composable (dismiss: () -> Unit) -> Unit,
+) {
+ ContextHelper.context.addContentView(
+ ComposeView(ContextHelper.context).apply {
+ setContent {
+ var showDialog by remember { mutableStateOf(true) }
+ if (showDialog) {
+ MineGameTheme {
+ Dialog(
+ onDismissRequest = {
+ if (canDismiss) {
+ onDismiss(true)
+ showDialog = false
+ }
+ }
+ ) {
+ content {
+ onDismiss(false)
+ showDialog = false
+ }
+ }
+ }
+ }
+ }
+ },
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ )
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/Dropdown.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/Dropdown.kt
new file mode 100644
index 0000000..fb8887f
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/Dropdown.kt
@@ -0,0 +1,86 @@
+package com.a404m.mine_game.ui.utils
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.a404m.mine_game.R
+import com.a404m.mine_game.ui.theme.MineGameTheme
+
+@Composable
+fun <T> Dropdown(
+ modifier: Modifier = Modifier,
+ items: List<T>,
+ selectedItem: T?,
+ onItemChange: (T) -> Unit,
+ hint: String = "",
+ toString: (T) -> String = { it.toString() },
+) {
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ Row(
+ modifier = modifier
+ .clickable { expanded = true }
+ .padding(5.dp),
+ ) {
+ Text(
+ modifier = Modifier
+ .weight(1F)
+ .padding(end = 5.dp),
+ text = if (selectedItem != null) toString(selectedItem) else hint
+ )
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter =
+ if (expanded) painterResource(R.drawable.arrow_drop_up)
+ else painterResource(R.drawable.arrow_drop_down),
+ contentDescription = "Drop down",
+ )
+ }
+ DropdownMenu(
+ modifier = Modifier.fillMaxWidth(),
+ expanded = expanded,
+ onDismissRequest = {
+ expanded = false
+ },
+ ) {
+ for (item in items) {
+ MineGameTheme {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .apply {
+ if (item == selectedItem) {
+ background(
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ .clickable {
+ onItemChange(item)
+ expanded = false
+ }
+ .padding(5.dp)
+ ,
+ text = toString(item),
+ )
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/Toast.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/Toast.kt
new file mode 100644
index 0000000..6ac84f0
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/Toast.kt
@@ -0,0 +1,15 @@
+package com.a404m.mine_game.ui.utils
+
+import android.widget.Toast
+import com.a404m.mine_game.ContextHelper
+
+
+fun showToast(text: String) {
+ ContextHelper.context.runOnUiThread{
+ Toast.makeText(
+ ContextHelper.context,
+ text,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/Extension.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/Extension.kt
new file mode 100644
index 0000000..9401a62
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/Extension.kt
@@ -0,0 +1,323 @@
+package com.a404m.mine_game.ui.utils.modifier
+
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.calculateCentroid
+import androidx.compose.foundation.gestures.calculateCentroidSize
+import androidx.compose.foundation.gestures.calculatePan
+import androidx.compose.foundation.gestures.calculateRotation
+import androidx.compose.foundation.gestures.calculateZoom
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.remember
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.positionChanged
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
+import com.a404m.mine_game.ui.utils.modifier.model.FreeScrollState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlin.math.PI
+import kotlin.math.abs
+
+
+/**
+ * Modify element to allow to scroll in both directions.
+ *
+ * Note that this modifier uses [pointerInput] as the underlying implementation, so some
+ * pointer events will be consumed. If you want to use
+ * [androidx.compose.foundation.gestures.detectTransformGestures] simultaneously,
+ * use [freeScrollWithTransformGesture] instead.
+ *
+ * @param state state of the scroll
+ * @param enabled whether the scroll is enabled
+ * @param horizontalReverseScrolling reverse the horizontal scrolling direction,
+ * when true, 0 [FreeScrollState.xValue] will mean right, otherwise left.
+ * @param verticalReverseScrolling reverse the vertical scrolling direction,
+ * when true, 0 [FreeScrollState.yValue] will mean bottom, otherwise top.
+ * @param flingBehavior logic describing fling behavior when drag has finished with velocity.
+ * If null, default from [ScrollableDefaults.flingBehavior] will be used.
+ */
+fun Modifier.freeScroll(
+ state: FreeScrollState,
+ enabled: Boolean = true,
+ horizontalReverseScrolling: Boolean = false,
+ verticalReverseScrolling: Boolean = false,
+ flingBehavior: FlingBehavior? = null,
+): Modifier = composed {
+
+ val velocityTracker = remember { VelocityTracker() }
+ val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()
+
+ this.horizontalScroll(
+ state = state.horizontalScrollState,
+ enabled = false,
+ reverseScrolling = horizontalReverseScrolling
+ ).verticalScroll(
+ state = state.verticalScrollState,
+ enabled = false,
+ reverseScrolling = verticalReverseScrolling
+ )
+ .pointerInput(enabled, horizontalReverseScrolling, verticalReverseScrolling) {
+ if (!enabled) return@pointerInput
+
+ coroutineScope {
+ detectDragGestures(
+ onDragStart = { },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ onDrag(
+ change = change,
+ dragAmount = dragAmount,
+ state = state,
+ horizontalReverseScrolling = horizontalReverseScrolling,
+ verticalReverseScrolling = verticalReverseScrolling,
+ velocityTracker = velocityTracker,
+ coroutineScope = this
+ )
+ },
+ onDragEnd = {
+ onEnd(
+ velocityTracker = velocityTracker,
+ state = state,
+ horizontalReverseScrolling = horizontalReverseScrolling,
+ verticalReverseScrolling = verticalReverseScrolling,
+ flingBehavior = fling,
+ coroutineScope = this
+ )
+ }
+ )
+ }
+ }
+}
+
+/**
+ * Modify element to allow to scroll in both directions, and detect transform gestures.
+ * If you don't need to detect transform gestures, use [freeScroll] instead.
+ *
+ * See [androidx.compose.foundation.gestures.detectTransformGestures] for more details.
+ */
+fun Modifier.freeScrollWithTransformGesture(
+ state: FreeScrollState,
+ enabled: Boolean = true,
+ panZoomLock: Boolean = false,
+ horizontalReverseScrolling: Boolean = false,
+ verticalReverseScrolling: Boolean = false,
+ flingBehavior: FlingBehavior? = null,
+ onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,
+): Modifier = composed {
+
+ val velocityTracker = remember { VelocityTracker() }
+ val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()
+
+ this.horizontalScroll(
+ state = state.horizontalScrollState,
+ enabled = false,
+ reverseScrolling = horizontalReverseScrolling
+ ).verticalScroll(
+ state = state.verticalScrollState,
+ enabled = false,
+ reverseScrolling = verticalReverseScrolling
+ )
+ .pointerInput(enabled, horizontalReverseScrolling, verticalReverseScrolling) {
+ if (!enabled) return@pointerInput
+
+ coroutineScope {
+ detectFreeScrollGestures(
+ panZoomLock = panZoomLock,
+ onGesture = { centroid, pan, zoom, rotation, change ->
+ onDrag(
+ change = change,
+ dragAmount = pan,
+ state = state,
+ horizontalReverseScrolling = horizontalReverseScrolling,
+ verticalReverseScrolling = verticalReverseScrolling,
+ velocityTracker = velocityTracker,
+ coroutineScope = this
+ )
+ onGesture(centroid, pan, zoom, rotation)
+ },
+ onEnd = {
+ onEnd(
+ velocityTracker = velocityTracker,
+ state = state,
+ horizontalReverseScrolling = horizontalReverseScrolling,
+ verticalReverseScrolling = verticalReverseScrolling,
+ flingBehavior = fling,
+ coroutineScope = this
+ )
+ }
+ )
+ }
+ }
+}
+
+
+/**
+ * If [change] is null, it means that the id of the pointer is changed. This happens when
+ * freeScrollWithTransformGesture is used. In this case, we need to reset the velocity tracker to
+ * avoid incorrect velocity calculation.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+private fun onDrag(
+ change: PointerInputChange?,
+ dragAmount: Offset,
+ state: FreeScrollState,
+ horizontalReverseScrolling: Boolean,
+ verticalReverseScrolling: Boolean,
+ velocityTracker: VelocityTracker,
+ coroutineScope: CoroutineScope,
+) {
+
+ coroutineScope.launch {
+ state.horizontalScrollState.scrollBy(
+ if (horizontalReverseScrolling) dragAmount.x else -dragAmount.x
+ )
+ state.verticalScrollState.scrollBy(
+ if (verticalReverseScrolling) dragAmount.y else -dragAmount.y
+ )
+ }
+
+ if (change == null) {
+ velocityTracker.resetTracking()
+ return
+ }
+
+ // Add historical position to velocity tracker to increase accuracy
+ val changeList = change.historical.map {
+ it.uptimeMillis to it.position
+ } + (change.uptimeMillis to change.position)
+
+ changeList.forEach { (time, pos) ->
+ val position = Offset(
+ x = pos.x - if (horizontalReverseScrolling)
+ -state.horizontalScrollState.value
+ else
+ state.horizontalScrollState.value,
+ y = pos.y - if (verticalReverseScrolling)
+ -state.verticalScrollState.value
+ else
+ state.verticalScrollState.value,
+ )
+ velocityTracker.addPosition(time, position)
+ }
+}
+
+
+private fun onEnd(
+ velocityTracker: VelocityTracker,
+ state: FreeScrollState,
+ horizontalReverseScrolling: Boolean,
+ verticalReverseScrolling: Boolean,
+ flingBehavior: FlingBehavior,
+ coroutineScope: CoroutineScope
+) {
+ val velocity = velocityTracker.calculateVelocity()
+ velocityTracker.resetTracking()
+
+ // Launch two animation separately to make sure they work simultaneously.
+ coroutineScope.launch {
+ state.horizontalScrollState.scroll {
+ with(flingBehavior) {
+ performFling(if (horizontalReverseScrolling) velocity.x else -velocity.x)
+ }
+ }
+ }
+ coroutineScope.launch {
+ state.verticalScrollState.scroll {
+ with(flingBehavior) {
+ performFling(if (verticalReverseScrolling) velocity.y else -velocity.y)
+ }
+ }
+ }
+}
+
+internal suspend fun PointerInputScope.detectFreeScrollGestures(
+ panZoomLock: Boolean = false,
+ onGesture: (
+ centroid: Offset,
+ pan: Offset,
+ zoom: Float,
+ rotation: Float,
+ change: PointerInputChange?
+ ) -> Unit,
+ onEnd: () -> Unit = {}
+) {
+ awaitEachGesture {
+ var rotation = 0f
+ var zoom = 1f
+ var pan = Offset.Zero
+ var pastTouchSlop = false
+ val touchSlop = viewConfiguration.touchSlop
+ var lockedToPanZoom = false
+
+ val down: PointerInputChange = awaitFirstDown(requireUnconsumed = false)
+
+ // Drag event
+ val pointer: PointerId = down.id
+
+ do {
+ val event = awaitPointerEvent()
+ val canceled = event.changes.fastAny { it.isConsumed }
+
+ if (!canceled) {
+ val dragEvent = event.changes.fastFirstOrNull { it.id == pointer }
+
+ val zoomChange = event.calculateZoom()
+ val rotationChange = event.calculateRotation()
+ val panChange = event.calculatePan()
+
+ if (!pastTouchSlop) {
+ zoom *= zoomChange
+ rotation += rotationChange
+ pan += panChange
+
+ val centroidSize = event.calculateCentroidSize(useCurrent = false)
+ val zoomMotion = abs(1 - zoom) * centroidSize
+ val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
+ val panMotion = pan.getDistance()
+
+ if (zoomMotion > touchSlop ||
+ rotationMotion > touchSlop ||
+ panMotion > touchSlop
+ ) {
+ pastTouchSlop = true
+ lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
+ }
+ }
+
+ if (pastTouchSlop) {
+ val centroid = event.calculateCentroid(useCurrent = false)
+ val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
+ if (effectiveRotation != 0f ||
+ zoomChange != 1f ||
+ panChange != Offset.Zero
+ ) {
+ onGesture(centroid, panChange, zoomChange, effectiveRotation, dragEvent)
+ }
+ event.changes.fastForEach {
+ if (it.positionChanged()) {
+ it.consume()
+ }
+ }
+ }
+ }
+ } while (!canceled && event.changes.fastAny { it.pressed })
+
+ onEnd()
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/model/FreeScrollState.kt b/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/model/FreeScrollState.kt
new file mode 100644
index 0000000..2e55dc0
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/ui/utils/modifier/model/FreeScrollState.kt
@@ -0,0 +1,141 @@
+package com.a404m.mine_game.ui.utils.modifier.model
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+
+class FreeScrollState(
+ val horizontalScrollState: ScrollState,
+ val verticalScrollState: ScrollState,
+) {
+
+ /**
+ * current horizontal scroll position value in pixels
+ */
+ val xValue: Int get() = horizontalScrollState.value
+
+ /**
+ * current vertical scroll position value in pixels
+ */
+ val yValue: Int get() = verticalScrollState.value
+
+
+ /**
+ * maximum bound for [xValue], or [Int.MAX_VALUE] if still unknown
+ */
+ val xMaxValue: Int get() = horizontalScrollState.maxValue
+
+ /**
+ * maximum bound for [yValue], or [Int.MAX_VALUE] if still unknown
+ */
+ val yMaxValue: Int get() = verticalScrollState.maxValue
+
+
+ /**
+ * Jump instantly by [offset] pixels.
+ *
+ * @see animateScrollBy for an animated version
+ *
+ * @param offset number of pixels to scroll by
+ * @return the amount of scroll consumed
+ */
+ suspend fun scrollBy(
+ offset: Offset
+ ): Offset = coroutineScope {
+ val xOffset = async {
+ horizontalScrollState.scrollBy(offset.x)
+ }
+ val yOffset = async {
+ verticalScrollState.scrollBy(offset.y)
+ }
+ Offset(xOffset.await(), yOffset.await())
+ }
+
+ /**
+ * Instantly jump to the given position in pixels.
+ *
+ * @see animateScrollTo for an animated version
+ *
+ * @param x the horizontal position to scroll to
+ * @param y the vertical position to scroll to
+ * @return the amount of scroll consumed
+ */
+ suspend fun scrollTo(
+ x: Int,
+ y: Int,
+ ): Offset = coroutineScope {
+ val xOffset = async {
+ horizontalScrollState.scrollTo(x)
+ }
+ val yOffset = async {
+ verticalScrollState.scrollTo(y)
+ }
+ Offset(xOffset.await(), yOffset.await())
+ }
+
+ /**
+ * Scroll by [offset] pixels with animation.
+ *
+ * @param offset number of pixels to scroll by
+ * @param animationSpec [AnimationSpec] to be used for this scrolling
+ *
+ * @return the amount of scroll consumed
+ */
+ suspend fun animateScrollBy(
+ offset: Offset,
+ animationSpec: AnimationSpec<Float> = spring(),
+ ): Offset = coroutineScope {
+ val xOffset = async {
+ horizontalScrollState.animateScrollBy(offset.x, animationSpec)
+ }
+ val yOffset = async {
+ verticalScrollState.animateScrollBy(offset.y, animationSpec)
+ }
+ Offset(xOffset.await(), yOffset.await())
+ }
+
+ /**
+ * Scroll to the given position in pixels with animation.
+ *
+ * @param x the horizontal position to scroll to
+ * @param y the vertical position to scroll to
+ * @param animationSpec [AnimationSpec] to be used for this scrolling
+ */
+ suspend fun animateScrollTo(
+ x: Int,
+ y: Int,
+ animationSpec: AnimationSpec<Float> = spring(),
+ ) = coroutineScope {
+ val xOffset = async {
+ horizontalScrollState.animateScrollTo(x, animationSpec)
+ }
+ val yOffset = async {
+ verticalScrollState.animateScrollTo(y, animationSpec)
+ }
+ awaitAll(xOffset, yOffset)
+ }
+}
+
+/**
+ * Create and [remember] [FreeScrollState] that is used to control and observe scrolling
+ *
+ * @param initialX initial horizontal scroller position
+ * @param initialY initial vertical scroller position
+ */
+@Composable
+fun rememberFreeScrollState(initialX: Int = 0, initialY: Int = 0): FreeScrollState {
+ val horizontalScrollState = rememberScrollState(initialX)
+ val verticalScrollState = rememberScrollState(initialY)
+ return remember {
+ FreeScrollState(horizontalScrollState, verticalScrollState)
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/utils/Application.kt b/app/src/main/java/com/a404m/mine_game/utils/Application.kt
new file mode 100644
index 0000000..72dd242
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/utils/Application.kt
@@ -0,0 +1,14 @@
+package com.a404m.mine_game.utils
+
+import android.content.Context
+
+fun getApplicationName(context: Context): String {
+ val applicationInfo = context.applicationInfo
+ val stringId = applicationInfo.labelRes
+
+ return if (stringId == 0) {
+ applicationInfo.nonLocalizedLabel.toString()
+ } else {
+ context.getString(stringId)
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/utils/Extensions.kt b/app/src/main/java/com/a404m/mine_game/utils/Extensions.kt
new file mode 100644
index 0000000..92ef621
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/utils/Extensions.kt
@@ -0,0 +1,85 @@
+package com.a404m.mine_game.utils
+
+import androidx.compose.ui.graphics.Color
+import org.json.JSONArray
+import org.json.JSONObject
+
+fun String.toPersianNumbers(): String {
+ val result = StringBuilder()
+
+ for (c in this) {
+ result.append(
+ when (c) {
+ '0' -> '۰'
+ '1' -> '۱'
+ '2' -> '۲'
+ '3' -> '۳'
+ '4' -> '۴'
+ '5' -> '۵'
+ '6' -> '۶'
+ '7' -> '۷'
+ '8' -> '۸'
+ '9' -> '۹'
+ else -> c
+ }
+ )
+ }
+
+ return result.toString()
+}
+
+fun Color.lerp(
+ color: Color,
+ t: Float,
+): Color {
+ return this.copy(
+ alpha = this.alpha * (1 - t) + color.alpha * t,
+ red = this.red * (1 - t) + color.red * t,
+ blue = this.blue * (1 - t) + color.blue * t,
+ green = this.green * (1 - t) + color.green * t,
+ )
+}
+
+fun <T> JSONArray.map(transform: (Any) -> T): ArrayList<T> {
+ val result = arrayListOf<T>()
+
+ for (i in 0 until this.length()) {
+ result.add(transform(this.get(i)))
+ }
+
+ return result
+}
+
+fun JSONObject.getDoubleOrNull(key: String): Double? {
+ return if (this.isNull(key)) {
+ null
+ } else {
+ this.getDouble(key)
+ }
+}
+
+fun JSONObject.getIntOrNull(key: String): Int? {
+ return if (this.isNull(key)) {
+ null
+ } else {
+ this.getInt(key)
+ }
+}
+
+fun JSONObject.getStringOrNull(key: String): String? {
+ return if (this.isNull(key)) {
+ null
+ } else {
+ this.getString(key)
+ }
+}
+
+fun <T> List<T>.toJson(): JSONArray {
+ val result = JSONArray()
+
+ for (item in this) {
+ result.put(item)
+ }
+
+ return result
+} \ No newline at end of file
diff --git a/app/src/main/java/com/a404m/mine_game/utils/PersianDate.kt b/app/src/main/java/com/a404m/mine_game/utils/PersianDate.kt
new file mode 100644
index 0000000..9352348
--- /dev/null
+++ b/app/src/main/java/com/a404m/mine_game/utils/PersianDate.kt
@@ -0,0 +1,539 @@
+package com.a404m.mine_game.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import java.util.Date
+import kotlin.math.min
+import kotlin.reflect.KProperty
+
+
+class PersianDate(private var millisecondsSinceEpoch: Long) : Comparable<PersianDate> {
+ companion object {
+ const val MILLISECONDS: Long = 1000
+ const val SECONDS = 60 * MILLISECONDS
+ const val MINUTES = 60 * SECONDS
+ const val HOURS = 24 * MINUTES
+ private const val FOUR_YEARS_DAYS: Long = 4 * 365 + 1
+ private const val FOUR_YEARS_MILLISECONDS = FOUR_YEARS_DAYS * HOURS
+
+ const val FIRST_YEAR: Int = 1348 // 1348/10/11
+ private const val FIRST_DATE_DAYS = 286
+ private const val TO_YEAR_OFFSET = 365 - FIRST_DATE_DAYS
+
+ private const val FIRST_WEEK_DAY_OF_TIME = 5
+
+ private const val LEAP_YEAR = 3
+
+ val MONTH_NAMES = arrayOf(
+ "فروردین",
+ "اردیبهشت",
+ "خرداد",
+ "تیر",
+ "مرداد",
+ "شهریور",
+ "مهر",
+ "آبان",
+ "آذر",
+ "دی",
+ "بهمن",
+ "اسفند",
+ )
+
+ val WEEK_DAY_NAMES = arrayOf(
+ "شنبه",
+ "یک شنبه",
+ "دو شنبه",
+ "سه شنبه",
+ "چهار شنبه",
+ "پنج شنبه",
+ "جمعه",
+ )
+
+ @JvmStatic
+ fun today(): PersianDate {
+ val persianDate = PersianDate()
+ persianDate.startOfDay()
+ return persianDate
+ }
+
+ @JvmStatic
+ fun tomorrow(): PersianDate {
+ val persianDate = PersianDate()
+ persianDate.startOfDay()
+ return persianDate
+ }
+
+ @JvmStatic
+ fun init(
+ year: Int,
+ month: Int,
+ day: Int,
+ hour: Int = 0,
+ minute: Int = 0,
+ second: Int = 0,
+ millisecond: Int = 0
+ ): PersianDate {
+ if (year < FIRST_YEAR + 1)
+ throw RuntimeException("bad year")
+ if (month !in 0 until 12)
+ throw RuntimeException("bad month")
+ else if (day !in 0 until getMonthDays(
+ year,
+ month
+ )
+ )
+ throw RuntimeException("bad day")
+ else if (hour !in 0 until 24)
+ throw RuntimeException("bad hour")
+ else if (minute !in 0 until 60)
+ throw RuntimeException("bad minute")
+ else if (second !in 0 until 60)
+ throw RuntimeException("bad second")
+ else if (millisecond !in 0 until 1000)
+ throw RuntimeException("bad millisecond")
+
+
+ var millisecondsSinceEpoch: Long = TO_YEAR_OFFSET * HOURS +
+ millisecond +
+ second * MILLISECONDS +
+ minute * SECONDS +
+ hour * MINUTES +
+ day * HOURS +
+ getYearDaysUntilMonth(
+ year,
+ month
+ ) * HOURS
+
+ val calculatingYear = year - FIRST_YEAR - 1
+
+ millisecondsSinceEpoch += (calculatingYear / 4) * FOUR_YEARS_MILLISECONDS
+
+ var yearToCalc = year - (calculatingYear % 4)
+
+ while (yearToCalc < year) {
+ millisecondsSinceEpoch += (if (isLeap(yearToCalc)) 366 else 365) * HOURS
+ ++yearToCalc
+ }
+
+ return PersianDate(millisecondsSinceEpoch)
+ }
+
+ private fun isLeap(year: Int): Boolean {
+ return year % 4 == LEAP_YEAR
+ }
+
+ private fun getMonthDays(
+ year: Int,
+ month: Int
+ ): Int {
+ return if (month < 6) {
+ 31
+ } else if (month != 11 || isLeap(year)) {
+ 30
+ } else {
+ 29
+ }
+ }
+
+ private fun getYearDaysUntilMonth(
+ year: Int,
+ month: Int
+ ): Int {
+ return if (month <= 6)
+ month * 31
+ else if (month == 12)
+ 365 + (if (isLeap(year)) 1 else 0)
+ else
+ 6 * 31 + (month - 6) * 30
+ }
+
+ fun parse(str: String): PersianDate? {
+ val sep = str.split('/')
+ if (sep.size != 3 || sep.any { it.toIntOrNull() == null }) {
+ return null
+ }
+
+ val date = PersianDate()
+ date.setYear(sep[0].toInt())
+ date.setMonth(sep[1].toInt() - 1)
+ date.setDay(sep[2].toInt() - 1)
+
+ return date
+ }
+ }
+
+ // todo: make a better way
+ constructor() : this(Date().time + (3 * MINUTES + 30 * SECONDS))
+
+ constructor(date: Date) : this(date.time)
+
+ constructor(persianDate: PersianDate) : this(persianDate.millisecondsSinceEpoch)
+
+ fun getMillisecondsSinceEpoch() = millisecondsSinceEpoch
+
+ fun getMillisecond(): Int {
+ return (millisecondsSinceEpoch % 1000).toInt()
+ }
+
+ fun getSecond(): Int {
+ return ((millisecondsSinceEpoch / MILLISECONDS) % 60).toInt()
+ }
+
+ fun getMinute(): Int {
+ return ((millisecondsSinceEpoch / SECONDS) % 60).toInt()
+ }
+
+ fun getHour(): Int {
+ return ((millisecondsSinceEpoch / MINUTES) % 24).toInt()
+ }
+
+ fun getDays(): Long {
+ return millisecondsSinceEpoch / HOURS
+ }
+
+ fun getYearDay(): Int {
+ var days = getDays().toInt()
+ if (days < TO_YEAR_OFFSET)
+ return FIRST_DATE_DAYS + days
+ days -= TO_YEAR_OFFSET
+
+ days %= FOUR_YEARS_DAYS.toInt()
+
+ return if (days < 366)
+ days
+ else if (days < 365 * 3)
+ days % 365
+ else if (days == 365 * 3)
+ 365
+ else
+ (days - 366) % 365
+ }
+
+ fun getDay(): Int {
+ val days = getYearDay()
+ return if (days < 31 * 6)
+ days % 31
+ else
+ (days - 31 * 6) % 30
+ }
+
+ fun getHumanDay(): Int {
+ return getDay() + 1
+ }
+
+ fun getMonth(): Int {
+ val days = getYearDay()
+ return if (days < 31 * 6) {
+ days / 31
+ } else {
+ (days - 31 * 6) / 30 + 6
+ }
+ }
+
+ fun getHumanMonth(): Int {
+ return getMonth() + 1
+ }
+
+ fun getYear(): Int {
+ var days = getDays()
+ var year = FIRST_YEAR + (days / FOUR_YEARS_DAYS) * 4
+ days %= FOUR_YEARS_DAYS
+ days -= TO_YEAR_OFFSET
+ if (days < 0) {
+ return year.toInt()
+ }
+ ++year
+ days -= 366
+ if (days < 0) {
+ return year.toInt()
+ }
+ year += 1 + days / 365
+ return year.toInt()
+ }
+
+ fun getWeekDay(): Int {
+ return ((getDays() + FIRST_WEEK_DAY_OF_TIME) % 7).toInt()
+ }
+
+ fun getWeekDayString(): String {
+ return WEEK_DAY_NAMES[getWeekDay()]
+ }
+
+ fun getMonthDays(): Int {
+ return getMonthDays(
+ getYear(),
+ getMonth()
+ )
+ }
+
+ fun getTime(): Long {
+ return millisecondsSinceEpoch
+ }
+
+ fun setMillisecond(milliseconds: Int) {
+ if (milliseconds !in 0 until 1000)
+ throw RuntimeException("bad milliseconds $milliseconds")
+
+ this.millisecondsSinceEpoch -= millisecondsSinceEpoch % MILLISECONDS
+ this.millisecondsSinceEpoch += milliseconds
+ }
+
+ fun setSecond(second: Int) {
+ if (second !in 0 until 60)
+ throw RuntimeException("bad second $second")
+
+ val diff = second - getSecond()
+ this.millisecondsSinceEpoch += diff * MILLISECONDS
+ }
+
+ fun setMinute(minute: Int) {
+ if (minute !in 0 until 60)
+ throw RuntimeException("bad minute $minute")
+
+ val diff = minute - getMinute()
+ this.millisecondsSinceEpoch += diff * SECONDS
+ }
+
+ fun setHour(hour: Int) {
+ if (hour !in 0 until 60)
+ throw RuntimeException("bad hour $hour")
+
+ val diff = hour - getHour()
+ this.millisecondsSinceEpoch += diff * MINUTES
+ }
+
+ fun setDay(day: Int) {
+ if (day !in 0 until getMonthDays())
+ throw RuntimeException("bad day $day")
+
+ val diff = day - getDay()
+ this.millisecondsSinceEpoch += diff * HOURS
+ }
+
+ fun setMonth(month: Int) {
+ if (month !in 0 until 12)
+ throw RuntimeException("bad month $month")
+
+ val year = getYear()
+ val newMonthDays = getMonthDays(
+ year,
+ month
+ )
+
+ val day = min(
+ getDay(),
+ newMonthDays - 1
+ )
+
+ val dayDiff = day + getYearDaysUntilMonth(
+ year,
+ month
+ ) - getYearDay()
+
+ this.millisecondsSinceEpoch += dayDiff * HOURS
+ }
+
+ fun setYear(year: Int) {
+ if (year < FIRST_YEAR + 1)
+ throw RuntimeException("bad year $year")
+
+ val currentYear = getYear()
+ addYears(year - currentYear)
+ }
+
+ fun isLeap(): Boolean {
+ return isLeap(getYear())
+ }
+
+ fun addMilliseconds(milliseconds: Long) {
+ millisecondsSinceEpoch += milliseconds
+ }
+
+ fun addSeconds(seconds: Long) {
+ millisecondsSinceEpoch += seconds * MILLISECONDS
+ }
+
+ fun addMinutes(minutes: Long) {
+ millisecondsSinceEpoch += minutes * SECONDS
+ }
+
+ fun addHours(hours: Long) {
+ millisecondsSinceEpoch += hours * MINUTES
+ }
+
+ fun addDays(days: Long) {
+ millisecondsSinceEpoch += days * HOURS
+ }
+
+ fun addMonths(months: Int) {
+ addYears(months / 12)
+ var monthsLeft = months % 12
+
+ var year = getYear()
+ var month = getMonth()
+ while (monthsLeft > 0) {
+ addDays(
+ getMonthDays(
+ year,
+ month
+ ).toLong()
+ )
+
+ --monthsLeft
+ month = (month + 1) % 12
+ if (month == 0) {
+ ++year
+ }
+ }
+ }
+
+ fun addYears(years: Int) {
+ var currentYear = getYear()
+ val year = years + currentYear
+
+ val isLeapDay = getMonth() == 11 && getDay() == 29
+
+ var yearDiff = year - currentYear
+ val dayDiff = if (isLeapDay && !isLeap(year)) 1 else 0
+
+ var milliseconds = (yearDiff / 4) * FOUR_YEARS_MILLISECONDS - dayDiff * HOURS
+ currentYear += yearDiff
+ yearDiff %= 4
+
+ while (yearDiff > 0) {
+ milliseconds += (if (isLeap(currentYear - 1)) 366 else 365) * HOURS
+ ++currentYear
+ --yearDiff
+ }
+
+ while (yearDiff < 0) {
+ milliseconds -= (if (isLeap(currentYear)) 366 else 365) * HOURS
+ --currentYear
+ ++yearDiff
+ }
+
+ this.millisecondsSinceEpoch += milliseconds
+ }
+
+ fun addMillisecond() {
+ this.addMilliseconds(1)
+ }
+
+ fun addSecond() {
+ this.addSeconds(1)
+ }
+
+ fun addMinute() {
+ this.addMinutes(1)
+ }
+
+ fun addHour() {
+ this.addHours(1)
+ }
+
+ fun addDay() {
+ this.addDays(1)
+ }
+
+ fun addMonth() {
+ this.addMonths(1)
+ }
+
+ fun addYear() {
+ this.addYears(1)
+ }
+
+ fun startOfDay() {
+ millisecondsSinceEpoch -= millisecondsSinceEpoch % HOURS
+ }
+
+ fun endOfDay() {
+ this.startOfDay()
+ millisecondsSinceEpoch += 23 * MINUTES + 59 * SECONDS + 59 * MILLISECONDS + 999
+ }
+
+ override fun compareTo(other: PersianDate): Int {
+ return this.millisecondsSinceEpoch.compareTo(other.millisecondsSinceEpoch)
+ }
+
+ fun isInPastDays(): Boolean {
+ return this < today()
+ }
+
+ fun isInFutureDays(): Boolean {
+ return this >= tomorrow()
+ }
+
+ fun getMonthName(): String {
+ return MONTH_NAMES[getMonth()]
+ }
+
+ override fun toString(): String {
+ return "${this.getYear()}/${this.getHumanMonth()}/${this.getHumanDay()} ${this.getHour()}:${this.getMinute()}:${this.getSecond()}.${this.getMillisecond()}"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return when (other) {
+ is PersianDate -> other.millisecondsSinceEpoch == this.millisecondsSinceEpoch
+ else -> false
+ }
+ }
+
+ override fun hashCode(): Int {
+ return millisecondsSinceEpoch.hashCode()
+ }
+
+ operator fun getValue(
+ nothing: Nothing?,
+ property: KProperty<*>
+ ): PersianDate {
+ return this
+ }
+
+ operator fun setValue(
+ nothing: Nothing?,
+ property: KProperty<*>,
+ date: PersianDate
+ ) {
+ this.millisecondsSinceEpoch = date.millisecondsSinceEpoch
+ }
+
+ fun toRelativeString(): String {
+ val now = PersianDate()
+ val diff = now.millisecondsSinceEpoch - this.millisecondsSinceEpoch
+ return if (diff < MILLISECONDS) {
+ "الان"
+ } else if (diff < SECONDS) {
+ "${diff / MILLISECONDS} ثانیه قبل"
+ } else if (diff < MINUTES) {
+ "${diff / SECONDS} دقیقه قبل"
+ } else if (diff < HOURS) {
+ "${diff / MINUTES} ساعت قبل"
+ } else {
+ "${diff / HOURS} روز قبل"
+ }
+ }
+
+ fun toYMD(): String = "${getYear()}/${getMonth()}/${getDay()}"
+
+ fun toHM(): String = "${
+ getHour()
+ }:${
+ getMinute().toString().padStart(
+ 2,
+ '0'
+ )
+ }"
+}
+
+val PersianDateSaver = Saver<PersianDate, Long>(
+ save = { it.getTime() },
+ restore = { PersianDate(it) },
+)
+
+@Composable
+fun rememberSavablePersianDate(init: () -> PersianDate) = rememberSaveable(
+ saver = PersianDateSaver,
+ init = init
+) \ No newline at end of file