From b6ecd5026e5e64b5e8fb67f84d6c89ec6a24db31 Mon Sep 17 00:00:00 2001 From: A404M Date: Thu, 26 Jun 2025 14:11:59 +0330 Subject: inital commit --- .gitignore | 15 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 24 + .idea/gradle.xml | 19 + .idea/inspectionProfiles/Project_Default.xml | 61 +++ .idea/kotlinc.xml | 6 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + .idea/statistic.xml | 6 + .idea/vcs.xml | 6 + app/.gitignore | 1 + app/build.gradle.kts | 74 +++ app/proguard-rules.pro | 21 + app/release/app-release.apk | Bin 0 -> 1214911 bytes app/release/baselineProfiles/0/app-release.dm | Bin 0 -> 4727 bytes app/release/baselineProfiles/1/app-release.dm | Bin 0 -> 4684 bytes app/release/output-metadata.json | 37 ++ .../com/a404m/mine_game/ExampleInstrumentedTest.kt | 27 ++ app/src/main/AndroidManifest.xml | 29 ++ app/src/main/ic_launcher-playstore.png | Bin 0 -> 13958 bytes .../main/java/com/a404m/mine_game/ContextHelper.kt | 9 + .../main/java/com/a404m/mine_game/MainActivity.kt | 38 ++ .../java/com/a404m/mine_game/core/Constants.kt | 4 + .../main/java/com/a404m/mine_game/model/Action.kt | 22 + .../java/com/a404m/mine_game/model/GameSettings.kt | 49 ++ .../java/com/a404m/mine_game/model/GameState.kt | 236 +++++++++ .../com/a404m/mine_game/model/JsonSerializable.kt | 7 + .../com/a404m/mine_game/storage/StorageBase.kt | 127 +++++ .../com/a404m/mine_game/storage/StorageGame.kt | 50 ++ .../java/com/a404m/mine_game/ui/page/Controls.kt | 78 +++ .../main/java/com/a404m/mine_game/ui/page/Game.kt | 442 +++++++++++++++++ .../main/java/com/a404m/mine_game/ui/page/Home.kt | 286 +++++++++++ .../main/java/com/a404m/mine_game/ui/page/Route.kt | 166 +++++++ .../java/com/a404m/mine_game/ui/page/Settings.kt | 259 ++++++++++ .../java/com/a404m/mine_game/ui/page/Splash.kt | 38 ++ .../java/com/a404m/mine_game/ui/theme/Color.kt | 11 + .../main/java/com/a404m/mine_game/ui/theme/Font.kt | 27 ++ .../java/com/a404m/mine_game/ui/theme/Theme.kt | 82 ++++ .../main/java/com/a404m/mine_game/ui/theme/Type.kt | 34 ++ .../java/com/a404m/mine_game/ui/utils/Action.kt | 62 +++ .../java/com/a404m/mine_game/ui/utils/AppBar.kt | 54 +++ .../java/com/a404m/mine_game/ui/utils/Container.kt | 160 ++++++ .../java/com/a404m/mine_game/ui/utils/Dialog.kt | 47 ++ .../java/com/a404m/mine_game/ui/utils/Dropdown.kt | 86 ++++ .../java/com/a404m/mine_game/ui/utils/Toast.kt | 15 + .../a404m/mine_game/ui/utils/modifier/Extension.kt | 323 ++++++++++++ .../ui/utils/modifier/model/FreeScrollState.kt | 141 ++++++ .../java/com/a404m/mine_game/utils/Application.kt | 14 + .../java/com/a404m/mine_game/utils/Extensions.kt | 85 ++++ .../java/com/a404m/mine_game/utils/PersianDate.kt | 539 +++++++++++++++++++++ app/src/main/res/drawable/about_us.xml | 5 + app/src/main/res/drawable/add.xml | 5 + app/src/main/res/drawable/arrow_back.xml | 5 + app/src/main/res/drawable/arrow_drop_down.xml | 5 + app/src/main/res/drawable/arrow_drop_up.xml | 5 + app/src/main/res/drawable/controls.xml | 5 + app/src/main/res/drawable/delete.xml | 5 + app/src/main/res/drawable/donate.xml | 5 + app/src/main/res/drawable/flag.xml | 5 + app/src/main/res/drawable/hint.xml | 5 + app/src/main/res/drawable/history.xml | 5 + .../main/res/drawable/ic_launcher_foreground.xml | 21 + app/src/main/res/drawable/language.xml | 5 + app/src/main/res/drawable/mine.xml | 16 + app/src/main/res/drawable/open.xml | 5 + app/src/main/res/drawable/play.xml | 5 + app/src/main/res/drawable/save.xml | 5 + app/src/main/res/drawable/settings.xml | 5 + app/src/main/res/drawable/statistics.xml | 9 + app/src/main/res/drawable/theme.xml | 5 + app/src/main/res/drawable/time.xml | 5 + app/src/main/res/drawable/tutorial.xml | 5 + app/src/main/res/font/iranyekan_bold.ttf | Bin 0 -> 59528 bytes app/src/main/res/font/iranyekan_light.ttf | Bin 0 -> 61488 bytes app/src/main/res/font/iranyekan_medium.ttf | Bin 0 -> 60404 bytes app/src/main/res/font/iranyekan_regular.ttf | Bin 0 -> 60268 bytes app/src/main/res/font/vazirmatn.ttf | Bin 0 -> 241328 bytes app/src/main/res/font/vazirmatn_black.ttf | Bin 0 -> 123376 bytes app/src/main/res/font/vazirmatn_bold.ttf | Bin 0 -> 123036 bytes app/src/main/res/font/vazirmatn_extrabold.ttf | Bin 0 -> 123324 bytes app/src/main/res/font/vazirmatn_extralight.ttf | Bin 0 -> 123292 bytes app/src/main/res/font/vazirmatn_light.ttf | Bin 0 -> 122920 bytes app/src/main/res/font/vazirmatn_medium.ttf | Bin 0 -> 122656 bytes app/src/main/res/font/vazirmatn_regular.ttf | Bin 0 -> 122752 bytes app/src/main/res/font/vazirmatn_semibold.ttf | Bin 0 -> 122920 bytes app/src/main/res/font/vazirmatn_thin.ttf | Bin 0 -> 124232 bytes app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 864 bytes .../main/res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2058 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 590 bytes .../main/res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1290 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1148 bytes .../main/res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2890 bytes app/src/main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1614 bytes .../main/res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4464 bytes app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 2226 bytes .../main/res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6498 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + app/src/main/res/xml/data_extraction_rules.xml | 19 + .../java/com/a404m/mine_game/ExampleUnitTest.kt | 20 + build.gradle.kts | 6 + gradle.properties | 23 + gradle/libs.versions.toml | 40 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 +++++++ gradlew.bat | 89 ++++ settings.gradle.kts | 24 + 116 files changed, 4466 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/kotlinc.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/statistic.xml create mode 100644 .idea/vcs.xml create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/release/app-release.apk create mode 100644 app/release/baselineProfiles/0/app-release.dm create mode 100644 app/release/baselineProfiles/1/app-release.dm create mode 100644 app/release/output-metadata.json create mode 100644 app/src/androidTest/java/com/a404m/mine_game/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/com/a404m/mine_game/ContextHelper.kt create mode 100644 app/src/main/java/com/a404m/mine_game/MainActivity.kt create mode 100644 app/src/main/java/com/a404m/mine_game/core/Constants.kt create mode 100644 app/src/main/java/com/a404m/mine_game/model/Action.kt create mode 100644 app/src/main/java/com/a404m/mine_game/model/GameSettings.kt create mode 100644 app/src/main/java/com/a404m/mine_game/model/GameState.kt create mode 100644 app/src/main/java/com/a404m/mine_game/model/JsonSerializable.kt create mode 100644 app/src/main/java/com/a404m/mine_game/storage/StorageBase.kt create mode 100644 app/src/main/java/com/a404m/mine_game/storage/StorageGame.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Controls.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Game.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Home.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Route.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Settings.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/page/Splash.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/theme/Color.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/theme/Font.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/theme/Type.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/Action.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/AppBar.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/Container.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/Dialog.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/Dropdown.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/Toast.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/modifier/Extension.kt create mode 100644 app/src/main/java/com/a404m/mine_game/ui/utils/modifier/model/FreeScrollState.kt create mode 100644 app/src/main/java/com/a404m/mine_game/utils/Application.kt create mode 100644 app/src/main/java/com/a404m/mine_game/utils/Extensions.kt create mode 100644 app/src/main/java/com/a404m/mine_game/utils/PersianDate.kt create mode 100644 app/src/main/res/drawable/about_us.xml create mode 100644 app/src/main/res/drawable/add.xml create mode 100644 app/src/main/res/drawable/arrow_back.xml create mode 100644 app/src/main/res/drawable/arrow_drop_down.xml create mode 100644 app/src/main/res/drawable/arrow_drop_up.xml create mode 100644 app/src/main/res/drawable/controls.xml create mode 100644 app/src/main/res/drawable/delete.xml create mode 100644 app/src/main/res/drawable/donate.xml create mode 100644 app/src/main/res/drawable/flag.xml create mode 100644 app/src/main/res/drawable/hint.xml create mode 100644 app/src/main/res/drawable/history.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/language.xml create mode 100644 app/src/main/res/drawable/mine.xml create mode 100644 app/src/main/res/drawable/open.xml create mode 100644 app/src/main/res/drawable/play.xml create mode 100644 app/src/main/res/drawable/save.xml create mode 100644 app/src/main/res/drawable/settings.xml create mode 100644 app/src/main/res/drawable/statistics.xml create mode 100644 app/src/main/res/drawable/theme.xml create mode 100644 app/src/main/res/drawable/time.xml create mode 100644 app/src/main/res/drawable/tutorial.xml create mode 100644 app/src/main/res/font/iranyekan_bold.ttf create mode 100644 app/src/main/res/font/iranyekan_light.ttf create mode 100644 app/src/main/res/font/iranyekan_medium.ttf create mode 100644 app/src/main/res/font/iranyekan_regular.ttf create mode 100644 app/src/main/res/font/vazirmatn.ttf create mode 100644 app/src/main/res/font/vazirmatn_black.ttf create mode 100644 app/src/main/res/font/vazirmatn_bold.ttf create mode 100644 app/src/main/res/font/vazirmatn_extrabold.ttf create mode 100644 app/src/main/res/font/vazirmatn_extralight.ttf create mode 100644 app/src/main/res/font/vazirmatn_light.ttf create mode 100644 app/src/main/res/font/vazirmatn_medium.ttf create mode 100644 app/src/main/res/font/vazirmatn_regular.ttf create mode 100644 app/src/main/res/font/vazirmatn_semibold.ttf create mode 100644 app/src/main/res/font/vazirmatn_thin.ttf create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/a404m/mine_game/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..04cb880 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..6d0ee1c --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/statistic.xml b/.idea/statistic.xml new file mode 100644 index 0000000..5b8a46d --- /dev/null +++ b/.idea/statistic.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7511bf2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.a404m.mine_game" + compileSdk = 36 + + defaultConfig { + applicationId = "com.a404m.mine_game" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + isProfileable = false + isDebuggable = false + isJniDebuggable = false + isPseudoLocalesEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // navigation + implementation(libs.androidx.navigation.compose) + + // AsyncImage + // implementation(libs.coil.compose) + + // http + // implementation(libs.okhttp) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/app-release.apk b/app/release/app-release.apk new file mode 100644 index 0000000..ab02a01 Binary files /dev/null and b/app/release/app-release.apk differ diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..c96ca9d Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..38b3a4e Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..b693519 --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.a404m.mine_game", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "0.1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 24 +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/a404m/mine_game/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/a404m/mine_game/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b72d64b --- /dev/null +++ b/app/src/androidTest/java/com/a404m/mine_game/ExampleInstrumentedTest.kt @@ -0,0 +1,27 @@ +package com.a404m.mine_game + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + "com.a404m.mine_game", + appContext.packageName + ) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7a0aa4c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..ed8c430 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ 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>, + 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>().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().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(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, + ) = 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, +) { + 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 Dropdown( + modifier: Modifier = Modifier, + items: List, + 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 = 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 = 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 JSONArray.map(transform: (Any) -> T): ArrayList { + val result = arrayListOf() + + 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 List.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 { + 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( + save = { it.getTime() }, + restore = { PersianDate(it) }, +) + +@Composable +fun rememberSavablePersianDate(init: () -> PersianDate) = rememberSaveable( + saver = PersianDateSaver, + init = init +) \ No newline at end of file diff --git a/app/src/main/res/drawable/about_us.xml b/app/src/main/res/drawable/about_us.xml new file mode 100644 index 0000000..3218106 --- /dev/null +++ b/app/src/main/res/drawable/about_us.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000..0c707a7 --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_back.xml b/app/src/main/res/drawable/arrow_back.xml new file mode 100644 index 0000000..85258df --- /dev/null +++ b/app/src/main/res/drawable/arrow_back.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_drop_down.xml b/app/src/main/res/drawable/arrow_drop_down.xml new file mode 100644 index 0000000..d1e8d53 --- /dev/null +++ b/app/src/main/res/drawable/arrow_drop_down.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_drop_up.xml b/app/src/main/res/drawable/arrow_drop_up.xml new file mode 100644 index 0000000..119a094 --- /dev/null +++ b/app/src/main/res/drawable/arrow_drop_up.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/controls.xml b/app/src/main/res/drawable/controls.xml new file mode 100644 index 0000000..2d249fc --- /dev/null +++ b/app/src/main/res/drawable/controls.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..2a144e2 --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/donate.xml b/app/src/main/res/drawable/donate.xml new file mode 100644 index 0000000..6a0360c --- /dev/null +++ b/app/src/main/res/drawable/donate.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/flag.xml b/app/src/main/res/drawable/flag.xml new file mode 100644 index 0000000..5e0ddfb --- /dev/null +++ b/app/src/main/res/drawable/flag.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/hint.xml b/app/src/main/res/drawable/hint.xml new file mode 100644 index 0000000..d49a6ff --- /dev/null +++ b/app/src/main/res/drawable/hint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 0000000..f7879b4 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..edecdbf --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/drawable/language.xml b/app/src/main/res/drawable/language.xml new file mode 100644 index 0000000..23fbbb0 --- /dev/null +++ b/app/src/main/res/drawable/language.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/mine.xml b/app/src/main/res/drawable/mine.xml new file mode 100644 index 0000000..9c6a985 --- /dev/null +++ b/app/src/main/res/drawable/mine.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/open.xml b/app/src/main/res/drawable/open.xml new file mode 100644 index 0000000..8d43bad --- /dev/null +++ b/app/src/main/res/drawable/open.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml new file mode 100644 index 0000000..42c14a7 --- /dev/null +++ b/app/src/main/res/drawable/play.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/save.xml b/app/src/main/res/drawable/save.xml new file mode 100644 index 0000000..125b090 --- /dev/null +++ b/app/src/main/res/drawable/save.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..bc83407 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/statistics.xml b/app/src/main/res/drawable/statistics.xml new file mode 100644 index 0000000..2d2b4f0 --- /dev/null +++ b/app/src/main/res/drawable/statistics.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/theme.xml b/app/src/main/res/drawable/theme.xml new file mode 100644 index 0000000..830a7f1 --- /dev/null +++ b/app/src/main/res/drawable/theme.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/time.xml b/app/src/main/res/drawable/time.xml new file mode 100644 index 0000000..c8b32e4 --- /dev/null +++ b/app/src/main/res/drawable/time.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/tutorial.xml b/app/src/main/res/drawable/tutorial.xml new file mode 100644 index 0000000..2863c6d --- /dev/null +++ b/app/src/main/res/drawable/tutorial.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/font/iranyekan_bold.ttf b/app/src/main/res/font/iranyekan_bold.ttf new file mode 100644 index 0000000..a68e403 Binary files /dev/null and b/app/src/main/res/font/iranyekan_bold.ttf differ diff --git a/app/src/main/res/font/iranyekan_light.ttf b/app/src/main/res/font/iranyekan_light.ttf new file mode 100644 index 0000000..7a89cfa Binary files /dev/null and b/app/src/main/res/font/iranyekan_light.ttf differ diff --git a/app/src/main/res/font/iranyekan_medium.ttf b/app/src/main/res/font/iranyekan_medium.ttf new file mode 100644 index 0000000..551a67f Binary files /dev/null and b/app/src/main/res/font/iranyekan_medium.ttf differ diff --git a/app/src/main/res/font/iranyekan_regular.ttf b/app/src/main/res/font/iranyekan_regular.ttf new file mode 100644 index 0000000..72d5808 Binary files /dev/null and b/app/src/main/res/font/iranyekan_regular.ttf differ diff --git a/app/src/main/res/font/vazirmatn.ttf b/app/src/main/res/font/vazirmatn.ttf new file mode 100644 index 0000000..b02ceb0 Binary files /dev/null and b/app/src/main/res/font/vazirmatn.ttf differ diff --git a/app/src/main/res/font/vazirmatn_black.ttf b/app/src/main/res/font/vazirmatn_black.ttf new file mode 100644 index 0000000..4b9bd66 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_black.ttf differ diff --git a/app/src/main/res/font/vazirmatn_bold.ttf b/app/src/main/res/font/vazirmatn_bold.ttf new file mode 100644 index 0000000..efa9b09 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_bold.ttf differ diff --git a/app/src/main/res/font/vazirmatn_extrabold.ttf b/app/src/main/res/font/vazirmatn_extrabold.ttf new file mode 100644 index 0000000..380bd15 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_extrabold.ttf differ diff --git a/app/src/main/res/font/vazirmatn_extralight.ttf b/app/src/main/res/font/vazirmatn_extralight.ttf new file mode 100644 index 0000000..b7b947e Binary files /dev/null and b/app/src/main/res/font/vazirmatn_extralight.ttf differ diff --git a/app/src/main/res/font/vazirmatn_light.ttf b/app/src/main/res/font/vazirmatn_light.ttf new file mode 100644 index 0000000..2dfd5c3 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_light.ttf differ diff --git a/app/src/main/res/font/vazirmatn_medium.ttf b/app/src/main/res/font/vazirmatn_medium.ttf new file mode 100644 index 0000000..1e08dd5 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_medium.ttf differ diff --git a/app/src/main/res/font/vazirmatn_regular.ttf b/app/src/main/res/font/vazirmatn_regular.ttf new file mode 100644 index 0000000..64e4a81 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_regular.ttf differ diff --git a/app/src/main/res/font/vazirmatn_semibold.ttf b/app/src/main/res/font/vazirmatn_semibold.ttf new file mode 100644 index 0000000..6b3842a Binary files /dev/null and b/app/src/main/res/font/vazirmatn_semibold.ttf differ diff --git a/app/src/main/res/font/vazirmatn_thin.ttf b/app/src/main/res/font/vazirmatn_thin.ttf new file mode 100644 index 0000000..b7a7d23 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_thin.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..60cf3df Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d6e8483 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..5ab41d1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2b30cdc Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..0d22a7f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..aac6620 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..96b86e8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c7a35ba Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..956ac3c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a3f14e8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..d94d5d7 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #CFCFCF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3bb55dc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + MineGame + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d16bacc --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +