diff options
| author | A404M <ahmadmahmoudiprogrammer@gmail.com> | 2025-06-26 14:11:59 +0330 | 
|---|---|---|
| committer | A404M <ahmadmahmoudiprogrammer@gmail.com> | 2025-06-26 14:11:59 +0330 | 
| commit | b6ecd5026e5e64b5e8fb67f84d6c89ec6a24db31 (patch) | |
| tree | 0c5cf7ac65ed1fcc4bd02ade34cc4607e88c85ed /app/src/main/java/com | |
inital commitv0.1.0
Diffstat (limited to 'app/src/main/java/com')
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  |