├── .gitignore ├── COPYING ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── net │ │ └── multun │ │ └── gamecounter │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── net │ │ │ └── multun │ │ │ └── gamecounter │ │ │ ├── GameCounterApplication.kt │ │ │ ├── MainActivity.kt │ │ │ ├── Palette.kt │ │ │ ├── di │ │ │ └── Coroutines.kt │ │ │ ├── store │ │ │ ├── GameRepository.kt │ │ │ ├── GameStore.kt │ │ │ ├── NewGameRepository.kt │ │ │ └── NewGameStore.kt │ │ │ └── ui │ │ │ ├── AboutScreen.kt │ │ │ ├── GameCounterTopBar.kt │ │ │ ├── board │ │ │ ├── BoardBottomBar.kt │ │ │ ├── BoardLayout.kt │ │ │ ├── BoardLayoutPlan.kt │ │ │ ├── BoardScreen.kt │ │ │ ├── BoardViewModel.kt │ │ │ ├── ConfirmDialog.kt │ │ │ ├── CounterUpdateButton.kt │ │ │ ├── FontScale.kt │ │ │ ├── NumberFormat.kt │ │ │ ├── PlayerCard.kt │ │ │ ├── PlayerCardPalette.kt │ │ │ ├── PlayerCounter.kt │ │ │ ├── PlayerCounterUpdateMenu.kt │ │ │ ├── RotateLayout.kt │ │ │ └── UniqueJobPool.kt │ │ │ ├── counter_settings │ │ │ ├── CounterSettingsScreen.kt │ │ │ └── GameCounterSettingsViewModel.kt │ │ │ ├── main_menu │ │ │ ├── MainMenu.kt │ │ │ └── MainMenuViewModel.kt │ │ │ ├── new_game_menu │ │ │ ├── NewGameMenu.kt │ │ │ └── NewGameViewModel.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ ├── proto │ │ ├── game.proto │ │ └── new_game.proto │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── icon-foreground.svg │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── net │ └── multun │ └── gamecounter │ └── TestBoardLayout.kt ├── build.gradle.kts ├── fastlane ├── Appfile ├── Fastfile ├── README.md └── metadata │ ├── en-US │ ├── changelogs │ │ ├── 1.txt │ │ ├── 2.txt │ │ ├── 3.txt │ │ ├── 4.txt │ │ ├── 7.txt │ │ └── 8.txt │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── board_dark.png │ │ │ ├── board_light.png │ │ │ ├── counter_settings.png │ │ │ └── random.png │ ├── short_description.txt │ └── title.txt │ └── fr-FR │ ├── changelogs │ ├── 2.txt │ ├── 3.txt │ ├── 4.txt │ ├── 7.txt │ └── 8.txt │ ├── full_description.txt │ └── short_description.txt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | /keystore.properties 12 | /gamecounter-fastlane.json 13 | report.xml 14 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The intellectual property of the logo and all other images in the repository is retained by Victor Collod. 2 | A license to display and redistribute these images is granted to F-Droid. 3 | 4 | All source code in this repository is released under the GPLv3 license, unless otherwise specified. 5 | A copy of the GPLv3 license can be found in the file named LICENSE. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | 5 | # workaround for missing 3.4 support 6 | gem "nkf" 7 | gem "bigdecimal" 8 | gem "abbrev" 9 | gem "mutex_m" 10 | gem "ostruct" 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | abbrev (0.1.2) 9 | addressable (2.8.7) 10 | public_suffix (>= 2.0.2, < 7.0) 11 | artifactory (3.0.17) 12 | atomos (0.1.3) 13 | aws-eventstream (1.3.2) 14 | aws-partitions (1.1109.0) 15 | aws-sdk-core (3.224.1) 16 | aws-eventstream (~> 1, >= 1.3.0) 17 | aws-partitions (~> 1, >= 1.992.0) 18 | aws-sigv4 (~> 1.9) 19 | base64 20 | jmespath (~> 1, >= 1.6.1) 21 | logger 22 | aws-sdk-kms (1.101.0) 23 | aws-sdk-core (~> 3, >= 3.216.0) 24 | aws-sigv4 (~> 1.5) 25 | aws-sdk-s3 (1.188.0) 26 | aws-sdk-core (~> 3, >= 3.224.1) 27 | aws-sdk-kms (~> 1) 28 | aws-sigv4 (~> 1.5) 29 | aws-sigv4 (1.11.0) 30 | aws-eventstream (~> 1, >= 1.0.2) 31 | babosa (1.0.4) 32 | base64 (0.3.0) 33 | bigdecimal (3.2.1) 34 | claide (1.1.0) 35 | colored (1.2) 36 | colored2 (3.1.2) 37 | commander (4.6.0) 38 | highline (~> 2.0.0) 39 | declarative (0.0.20) 40 | digest-crc (0.7.0) 41 | rake (>= 12.0.0, < 14.0.0) 42 | domain_name (0.6.20240107) 43 | dotenv (2.8.1) 44 | emoji_regex (3.2.3) 45 | excon (0.112.0) 46 | faraday (1.10.4) 47 | faraday-em_http (~> 1.0) 48 | faraday-em_synchrony (~> 1.0) 49 | faraday-excon (~> 1.1) 50 | faraday-httpclient (~> 1.0) 51 | faraday-multipart (~> 1.0) 52 | faraday-net_http (~> 1.0) 53 | faraday-net_http_persistent (~> 1.0) 54 | faraday-patron (~> 1.0) 55 | faraday-rack (~> 1.0) 56 | faraday-retry (~> 1.0) 57 | ruby2_keywords (>= 0.0.4) 58 | faraday-cookie_jar (0.0.7) 59 | faraday (>= 0.8.0) 60 | http-cookie (~> 1.0.0) 61 | faraday-em_http (1.0.0) 62 | faraday-em_synchrony (1.0.0) 63 | faraday-excon (1.1.0) 64 | faraday-httpclient (1.0.1) 65 | faraday-multipart (1.1.0) 66 | multipart-post (~> 2.0) 67 | faraday-net_http (1.0.2) 68 | faraday-net_http_persistent (1.2.0) 69 | faraday-patron (1.0.0) 70 | faraday-rack (1.0.0) 71 | faraday-retry (1.0.3) 72 | faraday_middleware (1.2.1) 73 | faraday (~> 1.0) 74 | fastimage (2.4.0) 75 | fastlane (2.227.2) 76 | CFPropertyList (>= 2.3, < 4.0.0) 77 | addressable (>= 2.8, < 3.0.0) 78 | artifactory (~> 3.0) 79 | aws-sdk-s3 (~> 1.0) 80 | babosa (>= 1.0.3, < 2.0.0) 81 | bundler (>= 1.12.0, < 3.0.0) 82 | colored (~> 1.2) 83 | commander (~> 4.6) 84 | dotenv (>= 2.1.1, < 3.0.0) 85 | emoji_regex (>= 0.1, < 4.0) 86 | excon (>= 0.71.0, < 1.0.0) 87 | faraday (~> 1.0) 88 | faraday-cookie_jar (~> 0.0.6) 89 | faraday_middleware (~> 1.0) 90 | fastimage (>= 2.1.0, < 3.0.0) 91 | fastlane-sirp (>= 1.0.0) 92 | gh_inspector (>= 1.1.2, < 2.0.0) 93 | google-apis-androidpublisher_v3 (~> 0.3) 94 | google-apis-playcustomapp_v1 (~> 0.1) 95 | google-cloud-env (>= 1.6.0, < 2.0.0) 96 | google-cloud-storage (~> 1.31) 97 | highline (~> 2.0) 98 | http-cookie (~> 1.0.5) 99 | json (< 3.0.0) 100 | jwt (>= 2.1.0, < 3) 101 | mini_magick (>= 4.9.4, < 5.0.0) 102 | multipart-post (>= 2.0.0, < 3.0.0) 103 | naturally (~> 2.2) 104 | optparse (>= 0.1.1, < 1.0.0) 105 | plist (>= 3.1.0, < 4.0.0) 106 | rubyzip (>= 2.0.0, < 3.0.0) 107 | security (= 0.1.5) 108 | simctl (~> 1.6.3) 109 | terminal-notifier (>= 2.0.0, < 3.0.0) 110 | terminal-table (~> 3) 111 | tty-screen (>= 0.6.3, < 1.0.0) 112 | tty-spinner (>= 0.8.0, < 1.0.0) 113 | word_wrap (~> 1.0.0) 114 | xcodeproj (>= 1.13.0, < 2.0.0) 115 | xcpretty (~> 0.4.1) 116 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 117 | fastlane-sirp (1.0.0) 118 | sysrandom (~> 1.0) 119 | gh_inspector (1.1.3) 120 | google-apis-androidpublisher_v3 (0.54.0) 121 | google-apis-core (>= 0.11.0, < 2.a) 122 | google-apis-core (0.11.3) 123 | addressable (~> 2.5, >= 2.5.1) 124 | googleauth (>= 0.16.2, < 2.a) 125 | httpclient (>= 2.8.1, < 3.a) 126 | mini_mime (~> 1.0) 127 | representable (~> 3.0) 128 | retriable (>= 2.0, < 4.a) 129 | rexml 130 | google-apis-iamcredentials_v1 (0.17.0) 131 | google-apis-core (>= 0.11.0, < 2.a) 132 | google-apis-playcustomapp_v1 (0.13.0) 133 | google-apis-core (>= 0.11.0, < 2.a) 134 | google-apis-storage_v1 (0.31.0) 135 | google-apis-core (>= 0.11.0, < 2.a) 136 | google-cloud-core (1.8.0) 137 | google-cloud-env (>= 1.0, < 3.a) 138 | google-cloud-errors (~> 1.0) 139 | google-cloud-env (1.6.0) 140 | faraday (>= 0.17.3, < 3.0) 141 | google-cloud-errors (1.5.0) 142 | google-cloud-storage (1.47.0) 143 | addressable (~> 2.8) 144 | digest-crc (~> 0.4) 145 | google-apis-iamcredentials_v1 (~> 0.1) 146 | google-apis-storage_v1 (~> 0.31.0) 147 | google-cloud-core (~> 1.6) 148 | googleauth (>= 0.16.2, < 2.a) 149 | mini_mime (~> 1.0) 150 | googleauth (1.8.1) 151 | faraday (>= 0.17.3, < 3.a) 152 | jwt (>= 1.4, < 3.0) 153 | multi_json (~> 1.11) 154 | os (>= 0.9, < 2.0) 155 | signet (>= 0.16, < 2.a) 156 | highline (2.0.3) 157 | http-cookie (1.0.8) 158 | domain_name (~> 0.5) 159 | httpclient (2.9.0) 160 | mutex_m 161 | jmespath (1.6.2) 162 | json (2.12.2) 163 | jwt (2.10.1) 164 | base64 165 | logger (1.7.0) 166 | mini_magick (4.13.2) 167 | mini_mime (1.1.5) 168 | multi_json (1.15.0) 169 | multipart-post (2.4.1) 170 | mutex_m (0.3.0) 171 | nanaimo (0.4.0) 172 | naturally (2.2.1) 173 | nkf (0.2.0) 174 | optparse (0.6.0) 175 | os (1.1.4) 176 | ostruct (0.6.1) 177 | plist (3.7.2) 178 | public_suffix (6.0.2) 179 | rake (13.3.0) 180 | representable (3.2.0) 181 | declarative (< 0.1.0) 182 | trailblazer-option (>= 0.1.1, < 0.2.0) 183 | uber (< 0.2.0) 184 | retriable (3.1.2) 185 | rexml (3.4.1) 186 | rouge (3.28.0) 187 | ruby2_keywords (0.0.5) 188 | rubyzip (2.4.1) 189 | security (0.1.5) 190 | signet (0.20.0) 191 | addressable (~> 2.8) 192 | faraday (>= 0.17.5, < 3.a) 193 | jwt (>= 1.5, < 3.0) 194 | multi_json (~> 1.10) 195 | simctl (1.6.10) 196 | CFPropertyList 197 | naturally 198 | sysrandom (1.0.5) 199 | terminal-notifier (2.0.0) 200 | terminal-table (3.0.2) 201 | unicode-display_width (>= 1.1.1, < 3) 202 | trailblazer-option (0.1.2) 203 | tty-cursor (0.7.1) 204 | tty-screen (0.8.2) 205 | tty-spinner (0.9.3) 206 | tty-cursor (~> 0.7) 207 | uber (0.1.0) 208 | unicode-display_width (2.6.0) 209 | word_wrap (1.0.0) 210 | xcodeproj (1.27.0) 211 | CFPropertyList (>= 2.3.3, < 4.0) 212 | atomos (~> 0.1.3) 213 | claide (>= 1.0.2, < 2.0) 214 | colored2 (~> 3.1) 215 | nanaimo (~> 0.4.0) 216 | rexml (>= 3.3.6, < 4.0) 217 | xcpretty (0.4.1) 218 | rouge (~> 3.28.0) 219 | xcpretty-travis-formatter (1.0.1) 220 | xcpretty (~> 0.2, >= 0.0.7) 221 | 222 | PLATFORMS 223 | ruby 224 | x86_64-linux 225 | 226 | DEPENDENCIES 227 | abbrev 228 | bigdecimal 229 | fastlane 230 | mutex_m 231 | nkf 232 | ostruct 233 | 234 | BUNDLED WITH 235 | 2.6.9 236 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | gamecounter logo 3 |

4 | 5 | An app for counting points at board, card, or role playing games: 6 | - keep track of multiple counters per player 7 | - customizable counter name and initial value 8 | - long press plus or minus for quick updates 9 | - players can change card colors 10 | - roll dices of any size, or pick player order 11 | 12 | [Get it on F-Droid](https://f-droid.org/packages/net.multun.gamecounter.fdroid/) 15 | 16 | # Preview 17 | 18 |

19 | board screenshot 20 |

21 | 22 | # Privacy policy 23 | 24 | - No personal or device information is collected 25 | - No permissions are required. Most notably, the application does not require access to the internet. 26 | 27 | # Build 28 | 29 | ```sh 30 | # build a signed debug package 31 | ./gradlew assembleDevRelease # or assembleDevDebug 32 | ``` 33 | 34 | # Install 35 | 36 | ```sh 37 | adb install ./app/build/outputs/apk/dev/release/app-dev-release.apk 38 | ``` 39 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.google.protobuf.gradle.* 2 | import org.gradle.internal.extensions.stdlib.capitalized 3 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 4 | import java.util.Properties 5 | import java.io.FileInputStream 6 | 7 | 8 | plugins { 9 | alias(libs.plugins.ksp) 10 | alias(libs.plugins.android.application) 11 | alias(libs.plugins.jetbrains.kotlin.android) 12 | alias(libs.plugins.compose.compiler) 13 | alias(libs.plugins.hilt) 14 | alias(libs.plugins.protobuf) 15 | alias(libs.plugins.aboutlibraries) 16 | } 17 | 18 | 19 | // load the keystore if present 20 | val keystoreProperties = Properties() 21 | val keystorePropertiesFile = rootProject.file("keystore.properties") 22 | val hasKeystore = keystorePropertiesFile.exists() 23 | if (hasKeystore) { 24 | keystoreProperties.load(FileInputStream(keystorePropertiesFile)) 25 | } 26 | 27 | android { 28 | signingConfigs { 29 | if (hasKeystore) { 30 | create("playstore") { 31 | keyAlias = keystoreProperties["keyAlias"] as String 32 | keyPassword = keystoreProperties["keyPassword"] as String 33 | storeFile = file(keystoreProperties["storeFile"] as String) 34 | storePassword = keystoreProperties["storePassword"] as String 35 | } 36 | } 37 | } 38 | 39 | namespace = "net.multun.gamecounter" 40 | compileSdk = 35 41 | 42 | defaultConfig { 43 | applicationId = "net.multun.gamecounter" 44 | minSdk = 24 45 | targetSdk = 35 46 | versionCode = 8 47 | versionName = "2.4.1" 48 | 49 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 50 | vectorDrawables { 51 | useSupportLibrary = true 52 | } 53 | } 54 | 55 | flavorDimensions += "target" 56 | productFlavors { 57 | create("fdroid") { 58 | dimension = "target" 59 | applicationIdSuffix = ".fdroid" 60 | } 61 | 62 | create("playstore") { 63 | dimension = "target" 64 | applicationIdSuffix = ".playstore" 65 | if (hasKeystore) { 66 | signingConfig = signingConfigs.getByName("playstore") 67 | } 68 | } 69 | 70 | create("dev") { 71 | isDefault = true 72 | dimension = "target" 73 | applicationIdSuffix = ".dev" 74 | signingConfig = signingConfigs.getByName("debug") 75 | } 76 | } 77 | 78 | buildFeatures { 79 | buildConfig = true 80 | } 81 | 82 | buildTypes { 83 | release { 84 | isMinifyEnabled = true 85 | proguardFiles( 86 | getDefaultProguardFile("proguard-android-optimize.txt"), 87 | "proguard-rules.pro" 88 | ) 89 | } 90 | } 91 | 92 | compileOptions { 93 | sourceCompatibility = JavaVersion.VERSION_1_8 94 | targetCompatibility = JavaVersion.VERSION_1_8 95 | } 96 | 97 | kotlinOptions { 98 | jvmTarget = "1.8" 99 | } 100 | 101 | packaging { 102 | resources { 103 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 104 | } 105 | } 106 | } 107 | 108 | composeCompiler { 109 | reportsDestination = layout.buildDirectory.dir("compose_compiler") 110 | metricsDestination = layout.buildDirectory.dir("compose_compiler") 111 | } 112 | 113 | dependencies { 114 | implementation(libs.compose.wheel.picker) 115 | implementation(libs.kotlinx.collections.immutable) 116 | implementation(libs.androidx.core.ktx) 117 | implementation(libs.androidx.core.splashscreen) 118 | implementation(libs.androidx.lifecycle.runtime.ktx) 119 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 120 | implementation(libs.androidx.constraintlayout) 121 | implementation(libs.androidx.datastore) 122 | implementation(libs.protobuf.kotlin) 123 | implementation(libs.protobuf.java) 124 | implementation(libs.androidx.navigation.compose) 125 | compileOnly(libs.protobuf.protoc) 126 | implementation(libs.androidx.activity.compose) 127 | implementation(libs.hilt.android) 128 | implementation(platform(libs.androidx.compose.bom)) 129 | implementation(libs.androidx.ui) 130 | implementation(libs.androidx.ui.graphics) 131 | implementation(libs.androidx.ui.tooling.preview) 132 | implementation(libs.androidx.material3) 133 | implementation(libs.androidx.material3.icons.extended) 134 | implementation(libs.androidx.lifecycle.runtime.compose.android) 135 | implementation(libs.aboutlibraries.core) 136 | implementation(libs.aboutlibraries.compose.m3) 137 | testImplementation(libs.junit) 138 | androidTestImplementation(libs.androidx.junit) 139 | androidTestImplementation(libs.androidx.espresso.core) 140 | androidTestImplementation(platform(libs.androidx.compose.bom)) 141 | androidTestImplementation(libs.androidx.ui.test.junit4) 142 | debugImplementation(libs.androidx.ui.tooling) 143 | debugImplementation(libs.androidx.ui.test.manifest) 144 | ksp(libs.hilt.compiler) 145 | } 146 | 147 | project.tasks.withType(JavaCompile::class.java).configureEach { 148 | // JDK 21 considers Java 8 an obsolete source and target value. Disable this warning. 149 | options.compilerArgs.add("-Xlint:-options") 150 | options.compilerArgs.add("-Xlint:deprecation") 151 | } 152 | 153 | protobuf { 154 | protoc { 155 | artifact = libs.protobuf.protoc.get().toString() 156 | } 157 | 158 | // Generates the java Protobuf-lite code for the Protobufs in this project. See 159 | // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation 160 | // for more information. 161 | generateProtoTasks { 162 | all().forEach { task -> 163 | task.builtins { 164 | id("java") { 165 | option("lite") 166 | } 167 | id("kotlin") { 168 | option("lite") 169 | } 170 | } 171 | } 172 | } 173 | } 174 | 175 | // workaround for https://github.com/google/ksp/issues/1590 176 | androidComponents { 177 | onVariants(selector().all()) { variant -> 178 | afterEvaluate { 179 | val capName = variant.name.capitalized() 180 | tasks.getByName("ksp${capName}Kotlin") { 181 | setSource(tasks.getByName("generate${capName}Proto").outputs) 182 | } 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Skip runtime check for isOnAndroidDevice(). 24 | # One line to make it easy to remove with sed. 25 | -assumevalues class com.google.protobuf.Android { static boolean ASSUME_ANDROID return true; } 26 | 27 | -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { 28 | ; 29 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/net/multun/gamecounter/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("net.multun.gamecounter", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/GameCounterApplication.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.hilt.android.HiltAndroidApp 6 | import dagger.hilt.android.qualifiers.ApplicationContext 7 | import javax.inject.Inject 8 | 9 | @HiltAndroidApp 10 | class GameCounterApplication : Application() { 11 | // workaround for a deprecation warning: https://github.com/google/dagger/issues/3601 12 | @Inject 13 | @ApplicationContext 14 | lateinit var context: Context 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.activity.viewModels 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.material3.Surface 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.ui.Modifier 12 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 13 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 14 | import androidx.navigation.compose.NavHost 15 | import androidx.navigation.compose.composable 16 | import androidx.navigation.compose.rememberNavController 17 | import dagger.hilt.android.AndroidEntryPoint 18 | import net.multun.gamecounter.ui.AboutScreen 19 | import net.multun.gamecounter.ui.board.BoardScreen 20 | import net.multun.gamecounter.ui.board.BoardViewModel 21 | import net.multun.gamecounter.ui.counter_settings.CounterSettingsScreen 22 | import net.multun.gamecounter.ui.counter_settings.GameCounterSettingsViewModel 23 | import net.multun.gamecounter.ui.main_menu.MainMenu 24 | import net.multun.gamecounter.ui.main_menu.MainMenuViewModel 25 | import net.multun.gamecounter.ui.new_game_menu.NewGameMenu 26 | import net.multun.gamecounter.ui.new_game_menu.NewGameViewModel 27 | import net.multun.gamecounter.ui.theme.GamecounterTheme 28 | 29 | sealed class Screens(val route: String) { 30 | data object MainMenu: Screens("main_menu") 31 | data object NewGameMenu: Screens("new_game_menu") 32 | data object Board: Screens("board") 33 | // current game settings 34 | data object CounterSettings: Screens("counter_settings") 35 | data object About: Screens("about") 36 | } 37 | 38 | @AndroidEntryPoint 39 | class MainActivity : ComponentActivity() { 40 | private val boardViewModel: BoardViewModel by viewModels() 41 | private val gameCounterSettingsViewModel: GameCounterSettingsViewModel by viewModels() 42 | private val mainMenuViewModel: MainMenuViewModel by viewModels() 43 | private val newGameViewModel: NewGameViewModel by viewModels() 44 | 45 | override fun onCreate(savedInstanceState: Bundle?) { 46 | // the default splash screen does a weird flashing animation in dark mode 47 | installSplashScreen().setOnExitAnimationListener { splashScreenViewProvider -> 48 | val height = splashScreenViewProvider.view.width.toFloat() 49 | splashScreenViewProvider.view 50 | .animate() 51 | .translationY(-height) 52 | .alpha(0f) 53 | .setDuration(400) 54 | .withEndAction { splashScreenViewProvider.remove() } 55 | .start() 56 | } 57 | 58 | super.onCreate(savedInstanceState) 59 | enableEdgeToEdge() 60 | setContent { 61 | GamecounterTheme { 62 | // the surface is used to provide a sane background during navigation transitions 63 | // without it, navigation in dark mode will flash a light background 64 | Surface { 65 | val controller = rememberNavController() 66 | NavHost(navController = controller, startDestination = Screens.MainMenu.route) { 67 | composable(route = Screens.MainMenu.route) { 68 | MainMenu(mainMenuViewModel, controller) 69 | } 70 | composable(route = Screens.NewGameMenu.route) { 71 | NewGameMenu(newGameViewModel, controller) 72 | } 73 | composable(route = Screens.Board.route) { 74 | BoardScreen(boardViewModel, controller, modifier = Modifier.fillMaxSize()) 75 | } 76 | composable(route = Screens.CounterSettings.route) { 77 | val counters by gameCounterSettingsViewModel.settingsUIState.collectAsStateWithLifecycle() 78 | CounterSettingsScreen(counters, gameCounterSettingsViewModel, controller) 79 | } 80 | composable(route = Screens.About.route) { 81 | AboutScreen(controller) 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/Palette.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 8 | import androidx.compose.foundation.layout.FlowRow 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.material3.darkColorScheme 14 | import androidx.compose.material3.lightColorScheme 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.graphics.colorspace.ColorSpaces 20 | import androidx.compose.ui.graphics.colorspace.connect 21 | import androidx.compose.ui.tooling.preview.Preview 22 | import androidx.compose.ui.unit.dp 23 | import kotlin.enums.enumEntries 24 | import kotlin.math.sqrt 25 | 26 | // tweaked from 2014 Material Design color palettes 27 | // https://m2.material.io/design/color/the-color-system.html#tools-for-picking-colors 28 | enum class PaletteColor(val color: Color) { 29 | Red(Color(0xFFFFCDD2)), // red 100 30 | Blue(Color(0xFFBBDEFB)), // blue 100 31 | Purple(Color(0xFFD1C4E9)), // deep purple 100 32 | Green(Color(0xFFC8E6C9)), // green 100 33 | Indigo(Color(0xFFC5CAE9)), // indigo 100 34 | Gold(Color(0xFFF1E4AB)), 35 | Teal(Color(0xFFB2DFDB)), // teal 100 36 | Pink(Color(0xFFF8BBD0)), // pink 100 37 | Cyan(Color(0xFFB2EBF2)), // cyan 100 38 | Orange(Color(0xFFF1CCB4)), // custom orange 39 | Gray(Color(0xFFE0E0E0)), // custom gray 40 | ; 41 | 42 | companion object { 43 | @JvmStatic 44 | fun allocate(usedColors: List): PaletteColor { 45 | val paletteColors = enumEntries() 46 | if (usedColors.isEmpty()) 47 | return paletteColors[0] 48 | 49 | // compute how often palette colors are currently used 50 | val colorUsage = mutableMapOf() 51 | for (paletteColor in paletteColors) 52 | colorUsage[paletteColor.color] = 0 53 | 54 | // only count palette colors 55 | for (usedColor in usedColors) { 56 | colorUsage.compute(usedColor) { 57 | _, oldCount -> 58 | if (oldCount == null) 59 | return@compute null 60 | oldCount + 1 61 | } 62 | } 63 | 64 | // the number of time the least used color occurred 65 | val leastUsedCount = colorUsage.values.minOrNull() ?: 0 66 | 67 | // iterate over the palette, pick the first color that hasn't been used too many times 68 | val availableColors = enumEntries().filter { (colorUsage[it.color] ?: 0) <= leastUsedCount } 69 | return availableColors[0] 70 | } 71 | } 72 | } 73 | 74 | val PALETTE = enumEntries().map { it.color } 75 | 76 | 77 | private val transformSpace = ColorSpaces.Oklab 78 | private val srgbToLab = ColorSpaces.Srgb.connect(transformSpace) 79 | private val labToSrgb = transformSpace.connect(ColorSpaces.Srgb) 80 | 81 | const val DARK_LUMA = 0.65f 82 | const val DARK_CHROMA = 1.45f 83 | 84 | private fun Color.toOklab(): FloatArray { 85 | return srgbToLab.transform(this.red, this.green, this.blue) 86 | } 87 | 88 | @Composable 89 | fun Color.toDisplayColor(isDark: Boolean = isSystemInDarkTheme()): Color { 90 | val labColor = this.toOklab() 91 | 92 | if (isDark) { 93 | labColor[0] *= DARK_LUMA 94 | labColor[1] *= DARK_CHROMA 95 | labColor[2] *= DARK_CHROMA 96 | } 97 | 98 | val res = labToSrgb.transform(labColor) 99 | return Color(res[0], res[1], res[2]) 100 | } 101 | 102 | @ExperimentalLayoutApi 103 | @Preview(widthDp = 600) 104 | @Composable 105 | fun PalettePreview() { 106 | val lightColors = PALETTE.map { it.toDisplayColor(false) } 107 | val darkColors = PALETTE.map { it.toDisplayColor(true) } 108 | 109 | Column { 110 | Colors(darkColors, modifier = Modifier.background(darkColorScheme().background)) 111 | Colors(lightColors, modifier = Modifier.background(lightColorScheme().background)) 112 | } 113 | } 114 | 115 | @ExperimentalLayoutApi 116 | @Composable 117 | fun Colors(colors: List, modifier: Modifier = Modifier) { 118 | FlowRow( 119 | modifier = modifier 120 | .fillMaxWidth() 121 | .padding(20.dp), 122 | horizontalArrangement = Arrangement.spacedBy( 123 | space = 16.dp, 124 | alignment = Alignment.CenterHorizontally 125 | ), 126 | verticalArrangement = Arrangement.spacedBy( 127 | space = 16.dp, 128 | alignment = Alignment.CenterVertically 129 | ), 130 | ) { 131 | for (color in colors) { 132 | Spacer( 133 | modifier = Modifier 134 | .size(100.dp) 135 | .background(color) 136 | ) 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/di/Coroutines.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.SupervisorJob 11 | import javax.inject.Qualifier 12 | import javax.inject.Singleton 13 | 14 | 15 | @Qualifier 16 | @Retention(AnnotationRetention.RUNTIME) 17 | annotation class Dispatcher(val appDispatcher: AppDispatchers) 18 | 19 | @Retention(AnnotationRetention.RUNTIME) 20 | @Qualifier 21 | annotation class ApplicationScope 22 | 23 | enum class AppDispatchers { 24 | Default, 25 | IO, 26 | } 27 | 28 | @Module 29 | @InstallIn(SingletonComponent::class) 30 | object DispatchersModule { 31 | @Provides 32 | @Dispatcher(AppDispatchers.IO) 33 | fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO 34 | 35 | @Provides 36 | @Dispatcher(AppDispatchers.Default) 37 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 38 | } 39 | 40 | @Module 41 | @InstallIn(SingletonComponent::class) 42 | internal object CoroutineScopesModule { 43 | @Provides 44 | @Singleton 45 | @ApplicationScope 46 | fun providesCoroutineScope( 47 | @Dispatcher(AppDispatchers.Default) dispatcher: CoroutineDispatcher, 48 | ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/store/GameRepository.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.store 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.colorspace.ColorSpaces 6 | import kotlinx.collections.immutable.ImmutableList 7 | import kotlinx.collections.immutable.PersistentMap 8 | import kotlinx.collections.immutable.toPersistentList 9 | import kotlinx.collections.immutable.toPersistentMap 10 | import kotlinx.coroutines.flow.map 11 | import net.multun.gamecounter.PaletteColor 12 | import net.multun.gamecounter.proto.ProtoGame 13 | import net.multun.gamecounter.proto.copy 14 | import net.multun.gamecounter.proto.counter 15 | import net.multun.gamecounter.proto.player 16 | import javax.inject.Inject 17 | 18 | @JvmInline 19 | @Immutable 20 | value class CounterId(val value: Int) 21 | 22 | @JvmInline 23 | @Immutable 24 | value class PlayerId(val value: Int) 25 | 26 | data class GameState( 27 | val isPlayable: Boolean, 28 | val selectedDice: Int, // either -1 for player order, or dice size 29 | val alwaysUprightMode: Boolean, 30 | val players: ImmutableList, 31 | val counters: ImmutableList, 32 | ) 33 | 34 | data class Counter( 35 | val id: CounterId, 36 | val defaultValue: Int, 37 | val name: String, 38 | ) 39 | 40 | data class Player( 41 | val id: PlayerId, 42 | val selectedCounter: CounterId?, 43 | val counters: PersistentMap, 44 | val color: Color, 45 | val name: String, 46 | ) 47 | 48 | class GameRepository @Inject constructor(private val appStateStore: GameStore) { 49 | val appState = appStateStore.data.map { protoAppState -> 50 | GameState( 51 | isPlayable = protoAppState.counterCount != 0, 52 | selectedDice = protoAppState.selectedDice, 53 | alwaysUprightMode = protoAppState.alwaysUprightMode, 54 | players = protoAppState.playerList.map { protoPlayer -> 55 | val selectedCounter = if (protoPlayer.selectedCounter == -1) 56 | null 57 | else 58 | CounterId(protoPlayer.selectedCounter) 59 | 60 | Player( 61 | id = PlayerId(protoPlayer.id), 62 | selectedCounter = selectedCounter, 63 | name = protoPlayer.name, 64 | color = Color(protoPlayer.color), 65 | counters = protoPlayer.countersMap.entries.associate { 66 | Pair(CounterId(it.key), it.value) 67 | }.toPersistentMap(), 68 | ) 69 | }.toPersistentList(), 70 | counters = protoAppState.counterList.map { protoCounter -> 71 | Counter( 72 | id = CounterId(protoCounter.id), 73 | defaultValue = protoCounter.defaultValue, 74 | name = protoCounter.name, 75 | ) 76 | }.toPersistentList(), 77 | ) 78 | } 79 | 80 | suspend fun resetPlayerCounters() { 81 | appStateStore.updateData { oldState -> 82 | val defaultCounters = oldState.getDefaultCounters() 83 | val builder = oldState.toBuilder() 84 | builder.clearPlayer() 85 | for (oldPlayer in oldState.playerList) { 86 | val playerBuilder = oldPlayer.toBuilder() 87 | playerBuilder.clearCounters() 88 | playerBuilder.putAllCounters(defaultCounters) 89 | builder.addPlayer(playerBuilder) 90 | } 91 | builder.build() 92 | } 93 | } 94 | 95 | suspend fun addCounter(defaultValue: Int, name: String): CounterId { 96 | var counterId = 0 97 | appStateStore.updateData { oldState -> 98 | // allocate an ID 99 | counterId = (oldState.counterList.maxOfOrNull { it.id } ?: -1) + 1 100 | 101 | // create a new state 102 | oldState.copy { 103 | // add a counter 104 | this.counter.add(counter { 105 | this.id = counterId 106 | this.defaultValue = defaultValue 107 | this.name = name 108 | }) 109 | 110 | // update all players to add the counter 111 | this.player.clear() 112 | for (oldPlayer in oldState.playerList) { 113 | val newPlayer = oldPlayer.toBuilder() 114 | newPlayer.putCounters(counterId, defaultValue) 115 | this.player.add(newPlayer.build()) 116 | } 117 | } 118 | } 119 | 120 | return CounterId(counterId) 121 | } 122 | 123 | suspend fun removeCounter(counterId: CounterId) { 124 | appStateStore.updateData { oldState -> 125 | // remove the counter itself 126 | val counterIndex = oldState.counterList.indexOfFirst { it.id == counterId.value } 127 | if (counterIndex == -1) 128 | return@updateData oldState 129 | val builder = oldState.toBuilder().removeCounter(counterIndex) 130 | 131 | // find the new default counter, if any 132 | var newDefaultCounter = -1 133 | if (builder.counterList.size > 0) 134 | newDefaultCounter = builder.counterList[0].id 135 | 136 | // remove references to the counter from players 137 | builder.clearPlayer() 138 | for (oldPlayer in oldState.playerList) { 139 | val newPlayer = oldPlayer.toBuilder() 140 | newPlayer.removeCounters(counterId.value) 141 | if (newPlayer.selectedCounter == counterId.value) 142 | newPlayer.setSelectedCounter(newDefaultCounter) 143 | builder.addPlayer(newPlayer) 144 | } 145 | builder.build() 146 | } 147 | } 148 | 149 | suspend fun addPlayers(count: Int) { 150 | appStateStore.updateData { oldState -> 151 | val builder = oldState.toBuilder() 152 | builder.addPlayers(count) 153 | builder.build() 154 | } 155 | } 156 | 157 | suspend fun startGame(playerCount: Int, counters: List) { 158 | appStateStore.updateData { 159 | val builder = ProtoGame.Game.newBuilder() 160 | builder.addAllCounter(counters) 161 | builder.addPlayers(playerCount) 162 | builder.build() 163 | } 164 | } 165 | 166 | suspend fun removePlayer(playerId: PlayerId) { 167 | appStateStore.updateData { oldState -> 168 | val playerIndex = oldState.getPlayerIndex(playerId) 169 | if (playerIndex == -1) 170 | return@updateData oldState 171 | oldState.toBuilder().removePlayer(playerIndex).build() 172 | } 173 | } 174 | 175 | private suspend fun updatePlayer(playerId: PlayerId, updater: (ProtoGame.Game, ProtoGame.Player) -> ProtoGame.Player) { 176 | appStateStore.updateData { oldState -> 177 | val playerIndex = oldState.getPlayerIndex(playerId) 178 | if (playerIndex == -1) 179 | return@updateData oldState 180 | 181 | val newPlayer = updater(oldState, oldState.getPlayer(playerIndex)) 182 | val newState = oldState.toBuilder() 183 | newState.setPlayer(playerIndex, newPlayer) 184 | newState.build() 185 | } 186 | } 187 | 188 | suspend fun updatePlayerCounter(playerId: PlayerId, counterId: CounterId, difference: Int) { 189 | updatePlayer(playerId) { 190 | _, oldPlayer -> 191 | oldPlayer.copy { 192 | val oldCounter = counters[counterId.value]!! 193 | this.counters.put(counterId.value, oldCounter + difference) 194 | } 195 | } 196 | } 197 | 198 | suspend fun setPlayerColor(playerId: PlayerId, color: Color) { 199 | updatePlayer(playerId) { 200 | _, oldPlayer -> 201 | oldPlayer.copy { 202 | this.color = color.encode() 203 | } 204 | } 205 | } 206 | 207 | suspend fun setPlayerName(playerId: PlayerId, name: String) { 208 | updatePlayer(playerId) { 209 | _, oldPlayer -> 210 | oldPlayer.copy { 211 | this.name = name 212 | } 213 | } 214 | } 215 | 216 | suspend fun selectCounter(playerId: PlayerId, counterId: CounterId) { 217 | updatePlayer(playerId) { 218 | _, oldPlayer -> 219 | oldPlayer.copy { 220 | this.selectedCounter = counterId.value 221 | } 222 | } 223 | } 224 | 225 | suspend fun updateCounter(counterId: CounterId, name: String, defaultValue: Int) { 226 | appStateStore.updateData { oldState -> 227 | val counterIndex = oldState.getCounterIndex(counterId) 228 | if (counterIndex == -1) 229 | return@updateData oldState 230 | 231 | val newCounter = oldState.getCounter(counterIndex).copy { 232 | this.name = name 233 | this.defaultValue = defaultValue 234 | } 235 | val newState = oldState.toBuilder() 236 | newState.setCounter(counterIndex, newCounter) 237 | newState.build() 238 | } 239 | } 240 | 241 | suspend fun moveCounter(counterId: CounterId, direction: Int) { 242 | appStateStore.updateData { oldState -> 243 | // find the current index of the counter 244 | val counterIndex = oldState.counterList.indexOfFirst { it.id == counterId.value } 245 | if (counterIndex == -1) 246 | return@updateData oldState 247 | val counter = oldState.getCounter(counterIndex) 248 | 249 | var newCounterIndex = counterIndex + direction 250 | if (newCounterIndex < 0) 251 | newCounterIndex = 0 252 | if (newCounterIndex > (oldState.counterCount - 1)) 253 | newCounterIndex = oldState.counterCount - 1 254 | 255 | oldState.toBuilder() 256 | .removeCounter(counterIndex) 257 | .addCounter(newCounterIndex, counter) 258 | .build() 259 | } 260 | } 261 | 262 | suspend fun movePlayer(playerId: PlayerId, direction: Int) { 263 | appStateStore.updateData { oldState -> 264 | // find the current index of the counter 265 | val playerIndex = oldState.playerList.indexOfFirst { it.id == playerId.value } 266 | if (playerIndex == -1) 267 | return@updateData oldState 268 | val player = oldState.getPlayer(playerIndex) 269 | 270 | var newPlayerIndex = playerIndex + direction 271 | if (newPlayerIndex < 0) 272 | newPlayerIndex = oldState.playerCount - 1 273 | if (newPlayerIndex > (oldState.playerCount - 1)) 274 | newPlayerIndex = 0 275 | 276 | oldState.toBuilder() 277 | .removePlayer(playerIndex) 278 | .addPlayer(newPlayerIndex, player) 279 | .build() 280 | } 281 | } 282 | 283 | suspend fun selectDice(diceSize: Int) { 284 | appStateStore.updateData { oldState -> 285 | oldState.copy { 286 | selectedDice = diceSize 287 | } 288 | } 289 | } 290 | 291 | suspend fun setAlwaysUprightMode(alwaysUprightMode: Boolean) { 292 | appStateStore.updateData { oldState -> 293 | oldState.copy { 294 | this.alwaysUprightMode = alwaysUprightMode 295 | } 296 | } 297 | } 298 | } 299 | 300 | fun ProtoGame.GameOrBuilder.getDefaultCounters(): Map { 301 | return counterList.associate { 302 | Pair(it.id, it.defaultValue) 303 | } 304 | } 305 | 306 | fun ProtoGame.GameOrBuilder.getPlayerIndex(playerId: PlayerId): Int { 307 | return playerList.indexOfFirst { it.id == playerId.value } 308 | } 309 | 310 | fun ProtoGame.GameOrBuilder.getCounterIndex(counterId: CounterId): Int { 311 | return counterList.indexOfFirst { it.id == counterId.value } 312 | } 313 | 314 | fun Color.encode(): Long { 315 | assert(colorSpace == ColorSpaces.Srgb) 316 | return (value shr 32).toLong() 317 | } 318 | 319 | fun ProtoGame.Game.Builder.addPlayers(playerCount: Int) { 320 | // color allocation 321 | val oldCounters = this.getDefaultCounters() 322 | val usedColors = this.playerList.map { Color(it.color) }.toMutableList() 323 | fun allocateColor(): Color { 324 | val newColor = PaletteColor.allocate(usedColors).color 325 | usedColors.add(newColor) 326 | return newColor 327 | } 328 | 329 | // id allocation 330 | val newPlayerIdStart = (this.playerList.maxOfOrNull { it.id } ?: -1) + 1 331 | 332 | for (newPlayerIndex in 0 until playerCount) { 333 | val playerId = newPlayerIdStart + newPlayerIndex 334 | 335 | this.addPlayer(player { 336 | this.id = playerId 337 | this.color = allocateColor().encode() 338 | this.counters.putAll(oldCounters) 339 | this.selectedCounter = if (oldCounters.isEmpty()) 340 | -1 341 | else 342 | this@addPlayers.counterList[0].id 343 | }) 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/store/GameStore.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.store 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.CorruptionException 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.core.DataStoreFactory 7 | import androidx.datastore.core.Serializer 8 | import androidx.datastore.dataStoreFile 9 | import com.google.protobuf.InvalidProtocolBufferException 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import kotlinx.coroutines.CoroutineDispatcher 16 | import kotlinx.coroutines.CoroutineScope 17 | import net.multun.gamecounter.di.AppDispatchers 18 | import net.multun.gamecounter.di.ApplicationScope 19 | import net.multun.gamecounter.di.Dispatcher 20 | import net.multun.gamecounter.proto.ProtoGame 21 | import java.io.InputStream 22 | import java.io.OutputStream 23 | import javax.inject.Singleton 24 | 25 | typealias GameStore = DataStore 26 | 27 | @Module 28 | @InstallIn(SingletonComponent::class) 29 | object GameStoreProvider { 30 | @Provides 31 | @Singleton 32 | internal fun providesGameStateStore( 33 | @ApplicationContext context: Context, 34 | @Dispatcher(AppDispatchers.IO) ioDispatcher: CoroutineDispatcher, 35 | @ApplicationScope scope: CoroutineScope, 36 | ): GameStore = 37 | DataStoreFactory.create( 38 | serializer = GameSerializer, 39 | scope = CoroutineScope(scope.coroutineContext + ioDispatcher), 40 | migrations = listOf(), 41 | ) { 42 | context.dataStoreFile("game.pb") 43 | } 44 | } 45 | 46 | object GameSerializer : Serializer { 47 | override val defaultValue: ProtoGame.Game = ProtoGame.Game.newBuilder().build() 48 | 49 | override suspend fun readFrom(input: InputStream): ProtoGame.Game { 50 | try { 51 | return ProtoGame.Game.parseFrom(input) 52 | } catch (exception: InvalidProtocolBufferException) { 53 | throw CorruptionException("Cannot read proto.", exception) 54 | } 55 | } 56 | 57 | override suspend fun writeTo(t: ProtoGame.Game, output: OutputStream) = t.writeTo(output) 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/store/NewGameRepository.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.store 2 | 3 | import kotlinx.collections.immutable.ImmutableList 4 | import kotlinx.collections.immutable.toPersistentList 5 | import kotlinx.coroutines.flow.map 6 | import net.multun.gamecounter.proto.ProtoNewGame 7 | import net.multun.gamecounter.proto.copy 8 | import net.multun.gamecounter.proto.counter 9 | import javax.inject.Inject 10 | 11 | 12 | data class NewGameState( 13 | val playerCount: Int, 14 | val counters: ImmutableList, 15 | ) 16 | 17 | class NewGameRepository @Inject constructor(private val appStateStore: NewGameStore) { 18 | val appState = appStateStore.data.map { protoAppState -> 19 | NewGameState( 20 | playerCount = protoAppState.playerCount, 21 | counters = protoAppState.counterList.map { protoCounter -> 22 | Counter( 23 | id = CounterId(protoCounter.id), 24 | defaultValue = protoCounter.defaultValue, 25 | name = protoCounter.name, 26 | ) 27 | }.toPersistentList(), 28 | ) 29 | } 30 | 31 | suspend fun setPlayerCount(playerCount: Int) { 32 | appStateStore.updateData { oldState -> 33 | oldState.copy { 34 | this.playerCount = playerCount 35 | } 36 | } 37 | } 38 | 39 | suspend fun addCounter(defaultValue: Int, name: String): CounterId { 40 | var counterId = 0 41 | appStateStore.updateData { oldState -> 42 | // allocate an ID 43 | counterId = (oldState.counterList.maxOfOrNull { it.id } ?: -1) + 1 44 | 45 | // create a new state 46 | oldState.copy { 47 | // add a counter 48 | this.counter.add(counter { 49 | this.id = counterId 50 | this.defaultValue = defaultValue 51 | this.name = name 52 | }) 53 | } 54 | } 55 | 56 | return CounterId(counterId) 57 | } 58 | 59 | suspend fun removeCounter(counterId: CounterId) { 60 | appStateStore.updateData { oldState -> 61 | // remove the counter itself 62 | val counterIndex = oldState.counterList.indexOfFirst { it.id == counterId.value } 63 | if (counterIndex == -1) 64 | return@updateData oldState 65 | oldState.toBuilder().removeCounter(counterIndex).build() 66 | } 67 | } 68 | 69 | suspend fun updateCounter(counterId: CounterId, name: String, defaultValue: Int) { 70 | appStateStore.updateData { oldState -> 71 | val counterIndex = oldState.getCounterIndex(counterId) 72 | if (counterIndex == -1) 73 | return@updateData oldState 74 | 75 | val newCounter = oldState.getCounter(counterIndex).copy { 76 | this.name = name 77 | this.defaultValue = defaultValue 78 | } 79 | val newState = oldState.toBuilder() 80 | newState.setCounter(counterIndex, newCounter) 81 | newState.build() 82 | } 83 | } 84 | 85 | suspend fun moveCounter(counterId: CounterId, direction: Int) { 86 | appStateStore.updateData { oldState -> 87 | // find the current index of the counter 88 | val counterIndex = oldState.counterList.indexOfFirst { it.id == counterId.value } 89 | if (counterIndex == -1) 90 | return@updateData oldState 91 | val counter = oldState.getCounter(counterIndex) 92 | 93 | var newCounterIndex = counterIndex + direction 94 | if (newCounterIndex < 0) 95 | newCounterIndex = 0 96 | if (newCounterIndex > (oldState.counterCount - 1)) 97 | newCounterIndex = oldState.counterCount - 1 98 | 99 | oldState.toBuilder() 100 | .removeCounter(counterIndex) 101 | .addCounter(newCounterIndex, counter) 102 | .build() 103 | } 104 | } 105 | } 106 | 107 | fun ProtoNewGame.NewGame.getCounterIndex(counterId: CounterId): Int { 108 | return counterList.indexOfFirst { it.id == counterId.value } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/store/NewGameStore.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.store 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.CorruptionException 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.core.DataStoreFactory 7 | import androidx.datastore.core.Serializer 8 | import androidx.datastore.dataStoreFile 9 | import com.google.protobuf.InvalidProtocolBufferException 10 | import dagger.Module 11 | import dagger.Provides 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.qualifiers.ApplicationContext 14 | import dagger.hilt.components.SingletonComponent 15 | import kotlinx.coroutines.CoroutineDispatcher 16 | import kotlinx.coroutines.CoroutineScope 17 | import net.multun.gamecounter.di.AppDispatchers 18 | import net.multun.gamecounter.di.ApplicationScope 19 | import net.multun.gamecounter.di.Dispatcher 20 | import net.multun.gamecounter.proto.ProtoGame 21 | import net.multun.gamecounter.proto.ProtoNewGame 22 | import net.multun.gamecounter.proto.counter 23 | import java.io.InputStream 24 | import java.io.OutputStream 25 | import javax.inject.Singleton 26 | 27 | 28 | typealias NewGameStore = DataStore 29 | 30 | @Module 31 | @InstallIn(SingletonComponent::class) 32 | object NewGameStoreProvider { 33 | @Provides 34 | @Singleton 35 | internal fun providesGameStateStore( 36 | @ApplicationContext context: Context, 37 | @Dispatcher(AppDispatchers.IO) ioDispatcher: CoroutineDispatcher, 38 | @ApplicationScope scope: CoroutineScope, 39 | ): NewGameStore = 40 | DataStoreFactory.create( 41 | serializer = NewGameSerializer, 42 | scope = CoroutineScope(scope.coroutineContext + ioDispatcher), 43 | migrations = listOf(), 44 | ) { 45 | context.dataStoreFile("new_game.pb") 46 | } 47 | } 48 | 49 | fun makeDefaultCounter(): ProtoGame.Counter { 50 | return counter { 51 | this.id = 0 52 | this.name = "hp" 53 | this.defaultValue = 100 54 | } 55 | } 56 | 57 | object NewGameSerializer : Serializer { 58 | override val defaultValue: ProtoNewGame.NewGame = ProtoNewGame.NewGame.newBuilder() 59 | .addCounter(makeDefaultCounter()).build() 60 | 61 | override suspend fun readFrom(input: InputStream): ProtoNewGame.NewGame { 62 | try { 63 | return ProtoNewGame.NewGame.parseFrom(input) 64 | } catch (exception: InvalidProtocolBufferException) { 65 | throw CorruptionException("Cannot read proto.", exception) 66 | } 67 | } 68 | 69 | override suspend fun writeTo(t: ProtoNewGame.NewGame, output: OutputStream) = t.writeTo(output) 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/AboutScreen.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material3.LocalTextStyle 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.unit.dp 18 | import androidx.navigation.NavController 19 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer 20 | import net.multun.gamecounter.BuildConfig 21 | import net.multun.gamecounter.R 22 | import net.multun.gamecounter.ui.main_menu.AppLogo 23 | 24 | @Composable 25 | fun AboutScreen(navController: NavController) { 26 | Scaffold( 27 | topBar = { 28 | GameCounterTopBar(stringResource(R.string.about), navController) 29 | } 30 | ) { contentPadding -> 31 | LibrariesContainer( 32 | modifier = Modifier 33 | .padding(contentPadding) 34 | .fillMaxSize(), 35 | header = { 36 | item { 37 | Column( 38 | horizontalAlignment = Alignment.CenterHorizontally, 39 | verticalArrangement = Arrangement.spacedBy(10.dp), 40 | modifier = Modifier.padding(20.dp).fillMaxWidth(), 41 | ) { 42 | AppLogo(Modifier.size(100.dp)) 43 | Text("Version %s".format(BuildConfig.VERSION_NAME), color = MaterialTheme.colorScheme.onSurfaceVariant) 44 | Text(stringResource(R.string.free_for_all_made_with_love)) 45 | 46 | Text(stringResource(R.string.source_code_is_available_under_the_terms_of_the_gplv3_license)) 47 | } 48 | } 49 | } 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/GameCounterTopBar.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 5 | import androidx.compose.material3.ExperimentalMaterial3Api 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.IconButton 8 | import androidx.compose.material3.Text 9 | import androidx.compose.material3.TopAppBar 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.navigation.NavController 13 | import net.multun.gamecounter.R 14 | 15 | @OptIn(ExperimentalMaterial3Api::class) 16 | @Composable 17 | fun GameCounterTopBar(title: String, navController: NavController) { 18 | TopAppBar( 19 | title = { Text(title) }, 20 | navigationIcon = { 21 | if (navController.previousBackStackEntry != null) { 22 | IconButton(onClick = { navController.navigateUp() }) { 23 | Icon( 24 | Icons.AutoMirrored.Filled.ArrowBack, 25 | contentDescription = stringResource(R.string.previous_screen) 26 | ) 27 | } 28 | } 29 | } 30 | ) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/BoardBottomBar.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.RowScope 6 | import androidx.compose.foundation.layout.WindowInsets 7 | import androidx.compose.foundation.layout.WindowInsetsSides 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.only 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.safeDrawing 13 | import androidx.compose.foundation.layout.windowInsetsPadding 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.Casino 16 | import androidx.compose.material.icons.filled.Clear 17 | import androidx.compose.material.icons.filled.Person 18 | import androidx.compose.material.icons.filled.Refresh 19 | import androidx.compose.material.icons.filled.Settings 20 | import androidx.compose.material3.Icon 21 | import androidx.compose.material3.IconButton 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.LaunchedEffect 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.snapshotFlow 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.unit.dp 30 | import com.sd.lib.compose.wheel_picker.FHorizontalWheelPicker 31 | import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState 32 | import net.multun.gamecounter.R 33 | 34 | 35 | val BOTTOM_BAR_PADDING_SIDES = 12.dp 36 | 37 | 38 | object BottomBarDefaults { 39 | val insets: WindowInsets 40 | @Composable get() = WindowInsets.safeDrawing.only( 41 | WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom 42 | ) 43 | } 44 | 45 | @Composable 46 | fun BottomBar( 47 | horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, 48 | windowInsets: WindowInsets, 49 | content: @Composable (RowScope.() -> Unit), 50 | ) { 51 | Row( 52 | Modifier 53 | .fillMaxWidth() 54 | .windowInsetsPadding(windowInsets) 55 | .padding(BOTTOM_BAR_PADDING_SIDES, 0.dp, BOTTOM_BAR_PADDING_SIDES, 0.dp), 56 | horizontalArrangement = horizontalArrangement, 57 | content = content, 58 | ) 59 | } 60 | 61 | @Composable 62 | fun CounterBottomBar( 63 | onRoll: () -> Unit, 64 | onOpenSettings: () -> Unit, 65 | windowInsets: WindowInsets = BottomBarDefaults.insets, 66 | ) { 67 | BottomBar(windowInsets = windowInsets) { 68 | IconButton(onClick = onRoll) { 69 | Icon(Icons.Filled.Casino, contentDescription = stringResource(R.string.roll_dice)) 70 | } 71 | 72 | IconButton(onClick = onOpenSettings) { 73 | Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings)) 74 | } 75 | } 76 | } 77 | 78 | 79 | @Composable 80 | fun PlayerNamesBottomBar( 81 | onClear: () -> Unit, 82 | windowInsets: WindowInsets = BottomBarDefaults.insets, 83 | ) { 84 | BottomBar(windowInsets = windowInsets, horizontalArrangement = Arrangement.End) { 85 | IconButton(onClick = onClear) { 86 | Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_player_names)) 87 | } 88 | } 89 | } 90 | 91 | private val DICE_OPTIONS = listOf(0, 4, 6, 8, 10, 12, 20, 30, 100) 92 | 93 | @Composable 94 | fun RollBottomBar( 95 | initialSelectedDice: Int, 96 | onSelectDice: (Int) -> Unit, 97 | onRoll: () -> Unit, 98 | onClear: () -> Unit, 99 | windowInsets: WindowInsets = BottomBarDefaults.insets, 100 | ) { 101 | BottomBar(windowInsets = windowInsets) { 102 | val initialIndex = remember { DICE_OPTIONS.indexOfFirst { it == initialSelectedDice } } 103 | 104 | val state = rememberFWheelPickerState(initialIndex) 105 | // Observe currentIndex. 106 | LaunchedEffect(state) { 107 | snapshotFlow { state.currentIndex } 108 | .collect { 109 | if (it != -1) 110 | onSelectDice(DICE_OPTIONS[it]) 111 | } 112 | } 113 | 114 | IconButton(onClick = onRoll) { 115 | Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.roll_dice)) 116 | } 117 | 118 | FHorizontalWheelPicker( 119 | modifier = Modifier.height(48.dp), 120 | state = state, 121 | count = DICE_OPTIONS.size, 122 | ) { index -> 123 | val diceSize = DICE_OPTIONS[index] 124 | if (diceSize == 0) 125 | Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.player_order)) 126 | else 127 | Text(diceSize.toString()) 128 | } 129 | 130 | IconButton(onClick = onClear) { 131 | Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_dice_roll)) 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/BoardLayout.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalLayoutApi::class) 2 | 3 | package net.multun.gamecounter.ui.board 4 | 5 | import android.util.Log 6 | import androidx.compose.foundation.horizontalScroll 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.BoxWithConstraints 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 12 | import androidx.compose.foundation.layout.FlowRow 13 | import androidx.compose.foundation.layout.Row 14 | import androidx.compose.foundation.layout.fillMaxHeight 15 | import androidx.compose.foundation.layout.fillMaxSize 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.layout.requiredHeight 19 | import androidx.compose.foundation.layout.requiredWidth 20 | import androidx.compose.foundation.layout.sizeIn 21 | import androidx.compose.foundation.rememberScrollState 22 | import androidx.compose.foundation.verticalScroll 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.unit.Dp 27 | import androidx.compose.ui.unit.dp 28 | 29 | 30 | @Composable 31 | fun FallbackLayout( 32 | itemCount: Int, 33 | callback: @Composable (Int, Modifier) -> Unit, 34 | modifier: Modifier = Modifier, 35 | padding: Dp = 8.dp, 36 | ) { 37 | FlowRow( 38 | modifier = modifier 39 | .verticalScroll(rememberScrollState()) 40 | .padding(padding), 41 | horizontalArrangement = Arrangement.spacedBy( 42 | space = padding * 2, 43 | alignment = Alignment.CenterHorizontally 44 | ), 45 | verticalArrangement = Arrangement.spacedBy( 46 | space = padding * 2, 47 | alignment = Alignment.CenterVertically 48 | ), 49 | ) { 50 | for (itemIndex in 0 until itemCount) 51 | callback(itemIndex, Modifier.sizeIn( 52 | minWidth = PLAYER_MIN_WIDTH, 53 | maxWidth = PLAYER_MIN_WIDTH, 54 | minHeight = PLAYER_MIN_HEIGHT, 55 | maxHeight = PLAYER_MIN_HEIGHT, 56 | )) 57 | } 58 | } 59 | 60 | 61 | @Composable 62 | fun ListLayout( 63 | plan: UprightLayoutPlan, 64 | callback: @Composable (Int, Modifier) -> Unit, 65 | modifier: Modifier = Modifier, 66 | padding: Dp = 8.dp, 67 | ) { 68 | var colModifier = modifier.padding(padding) 69 | colModifier = if (plan.scrollingNeeded) { 70 | colModifier.verticalScroll(rememberScrollState()) 71 | } else { 72 | colModifier.fillMaxSize() 73 | } 74 | 75 | Column(modifier = colModifier) { 76 | val rowModifier = if (plan.scrollingNeeded) 77 | Modifier.height(plan.rowHeight) 78 | else 79 | Modifier.weight(1f) 80 | 81 | for (rowIndex in 0 until plan.rowCount) 82 | Row(modifier = rowModifier) { 83 | val rowOffset = rowIndex * plan.itemsPerRow 84 | val rowSize = (plan.itemCount - rowOffset).coerceAtMost(plan.itemsPerRow) 85 | for (itemIndex in rowOffset until rowOffset + rowSize) 86 | callback(itemIndex, Modifier 87 | .padding(padding) 88 | .fillMaxHeight() 89 | .weight(1f)) 90 | } 91 | } 92 | } 93 | 94 | @Composable 95 | fun VerticalLayout( 96 | plan: CircularLayoutPlan, 97 | modifier: Modifier = Modifier, 98 | padding: Dp = 8.dp, 99 | callback: @Composable (Int, Modifier) -> Unit, 100 | ) { 101 | Column(modifier = modifier.padding(padding)) { 102 | var rowOffsetCursor = 0 103 | for ((rowIndex, rowType) in plan.rows.withIndex()) { 104 | val rowOffset = rowOffsetCursor 105 | rowOffsetCursor += rowType.slotCount 106 | 107 | Row( 108 | modifier = Modifier 109 | .fillMaxSize() 110 | .weight(plan.rowWeights[rowIndex].value), 111 | horizontalArrangement = Arrangement.SpaceAround, 112 | verticalAlignment = Alignment.CenterVertically, 113 | ) { 114 | for (colIndex in 0 until rowType.slotCount) { 115 | val orientation = rowType.orientations[colIndex] 116 | val slotIndex = rowOffset + colIndex 117 | val slotModifier = Modifier 118 | .weight(1f) 119 | .padding(padding) 120 | .rotateLayout(orientation) 121 | callback(plan.layoutOrder[slotIndex], slotModifier) 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | @Composable 129 | fun HorizontalLayout( 130 | plan: CircularLayoutPlan, 131 | modifier: Modifier = Modifier, 132 | padding: Dp = 8.dp, 133 | callback: @Composable (Int, Modifier) -> Unit, 134 | ) { 135 | Row(modifier = modifier.padding(padding)) { 136 | var rowOffsetCursor = 0 137 | for ((rowIndex, rowType) in plan.rows.withIndex()) { 138 | val rowOffset = rowOffsetCursor 139 | rowOffsetCursor += rowType.slotCount 140 | 141 | Column( 142 | modifier = Modifier 143 | .fillMaxSize() 144 | .weight(plan.rowWeights[rowIndex].value), 145 | verticalArrangement = Arrangement.SpaceAround, 146 | horizontalAlignment = Alignment.CenterHorizontally, 147 | ) { 148 | for (colIndex in (0 until rowType.slotCount).reversed()) { 149 | val orientation = rowType.orientations[colIndex] + Rotation.ROT_270 150 | val slotIndex = rowOffset + colIndex 151 | val slotModifier = Modifier 152 | .weight(1f) 153 | .padding(padding) 154 | .rotateLayout(orientation) 155 | callback(plan.layoutOrder[slotIndex], slotModifier) 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | private const val TAG = "BoardLayout" 163 | 164 | 165 | // TODO: investigate using FlowRow and animatePlacement: 166 | // https://developer.android.com/develop/ui/compose/layouts/flow 167 | // https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#(androidx.compose.ui.Modifier).onPlaced(kotlin.Function1) 168 | @Composable 169 | fun BoardLayout( 170 | itemCount: Int, 171 | modifier: Modifier = Modifier, 172 | alwaysUprightMode: Boolean = false, 173 | padding: Dp = 8.dp, 174 | callback: @Composable (Int, Modifier) -> Unit, 175 | ) { 176 | BoxWithConstraints(modifier = modifier) { 177 | Log.d(TAG, "canvas dimensions: minWidth: ${this.minWidth} maxWidth: ${this.maxWidth} minHeight: ${this.minHeight} maxHeight: ${this.maxHeight}") 178 | when (val layoutPlan = planLayout(alwaysUprightMode, itemCount, this.maxWidth, this.maxHeight, padding)) { 179 | FallbackPlan -> FallbackLayout( 180 | itemCount = itemCount, 181 | callback = callback, 182 | modifier = Modifier.fillMaxSize(), 183 | padding = padding 184 | ) 185 | is CircularLayoutPlan -> { 186 | when (layoutPlan.direction) { 187 | LayoutDirection.HORIZONTAL -> { 188 | var layoutModifier: Modifier = Modifier 189 | var boxModifier: Modifier = Modifier 190 | if (layoutPlan.scrollingNeeded) { 191 | val minWidth = layoutPlan.rowWeights.reduce { acc, dp -> acc + dp } 192 | layoutModifier = Modifier.requiredWidth(minWidth) 193 | boxModifier = Modifier.horizontalScroll(rememberScrollState()) 194 | } 195 | Box(modifier = boxModifier) { 196 | HorizontalLayout( 197 | plan = layoutPlan, 198 | padding = padding, 199 | callback = callback, 200 | modifier = layoutModifier, 201 | ) 202 | } 203 | } 204 | LayoutDirection.VERTICAL -> { 205 | var layoutModifier: Modifier = Modifier 206 | var boxModifier: Modifier = Modifier 207 | if (layoutPlan.scrollingNeeded) { 208 | val minHeight = layoutPlan.rowWeights.reduce { acc, dp -> acc + dp } 209 | layoutModifier = Modifier.requiredHeight(minHeight) 210 | boxModifier = Modifier.verticalScroll(rememberScrollState()) 211 | } 212 | Box(modifier = boxModifier) { 213 | VerticalLayout( 214 | plan = layoutPlan, 215 | padding = padding, 216 | callback = callback, 217 | modifier = layoutModifier, 218 | ) 219 | } 220 | } 221 | } 222 | } 223 | is UprightLayoutPlan -> { 224 | Log.i("ListLayout", layoutPlan.toString()) 225 | ListLayout( 226 | plan = layoutPlan, 227 | padding = padding, 228 | callback = callback, 229 | ) 230 | } 231 | } 232 | } 233 | } 234 | 235 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/BoardViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.collections.immutable.ImmutableList 8 | import kotlinx.collections.immutable.persistentMapOf 9 | import kotlinx.collections.immutable.toImmutableList 10 | import kotlinx.collections.immutable.toPersistentList 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.SharingStarted 14 | import kotlinx.coroutines.flow.combine 15 | import kotlinx.coroutines.flow.first 16 | import kotlinx.coroutines.flow.stateIn 17 | import kotlinx.coroutines.flow.update 18 | import kotlinx.coroutines.launch 19 | import net.multun.gamecounter.store.CounterId 20 | import net.multun.gamecounter.store.PlayerId 21 | import net.multun.gamecounter.store.GameRepository 22 | import javax.inject.Inject 23 | import kotlin.time.Duration.Companion.milliseconds 24 | 25 | 26 | // the public UI state 27 | sealed interface UIState 28 | data object StartupUI : UIState 29 | data object SetupRequired : UIState 30 | sealed interface BoardUI : UIState { 31 | val alwaysUprightMode: Boolean 32 | val players: ImmutableList 33 | } 34 | 35 | data class RollUI( 36 | val selectedDice: Int, 37 | override val alwaysUprightMode: Boolean, 38 | override val players: ImmutableList 39 | ) : BoardUI 40 | 41 | data class CounterBoardUI( 42 | override val alwaysUprightMode: Boolean, 43 | override val players: ImmutableList 44 | ) : BoardUI 45 | 46 | data class PlayerSettingsBoardUI( 47 | override val alwaysUprightMode: Boolean, 48 | override val players: ImmutableList 49 | ) : BoardUI 50 | 51 | sealed interface CardUIState { 52 | val id: PlayerId 53 | val color: Color 54 | val name: String 55 | } 56 | 57 | data class CounterCardUIState( 58 | override val id: PlayerId, 59 | override val color: Color, 60 | override val name: String, 61 | val selectedCounter: CounterId, 62 | val counters: List, 63 | ) : CardUIState 64 | 65 | data class CounterUIState( 66 | val id: CounterId, 67 | val name: String, 68 | val value: Int, 69 | val combo: Int, 70 | ) 71 | 72 | data class PlayerSettingsUIState( 73 | override val id: PlayerId, 74 | override val color: Color, 75 | override val name: String, 76 | ) : CardUIState 77 | 78 | data class RollCardUIState( 79 | override val id: PlayerId, 80 | override val color: Color, 81 | override val name: String, 82 | val isOrdinal: Boolean, 83 | val roll: Int, 84 | ) : CardUIState 85 | 86 | data class ComboCounterId(val player: PlayerId, val counter: CounterId) 87 | 88 | private sealed interface BoardState 89 | data object BoardCounters : BoardState 90 | data object BoardPlayerSettings : BoardState 91 | data class BoardRoll(val result: ImmutableList) : BoardState 92 | 93 | @HiltViewModel 94 | class BoardViewModel @Inject constructor(private val repository: GameRepository) : ViewModel() { 95 | // combo timers 96 | private val comboCounters = MutableStateFlow(persistentMapOf()) 97 | private val comboCountersTimers = UniqueJobPool(viewModelScope) 98 | private val boardState = MutableStateFlow(BoardCounters) 99 | 100 | fun playerSettings() { 101 | boardState.update { BoardPlayerSettings } 102 | } 103 | 104 | fun roll() { 105 | viewModelScope.launch { 106 | val currentState = repository.appState.first() 107 | val playerCount = currentState.players.size 108 | val diceSize = currentState.selectedDice 109 | val order: List 110 | val isOrdinal: Boolean 111 | if (diceSize <= 0) { 112 | order = (1 .. playerCount).shuffled() 113 | isOrdinal = true 114 | } else { 115 | order = (1 .. playerCount).map { (1..diceSize).random() } 116 | isOrdinal = false 117 | } 118 | val newRollResult = currentState.players.zip(order) { 119 | player, playerRoll -> 120 | RollCardUIState( 121 | id = player.id, 122 | color = player.color, 123 | name = player.name, 124 | roll = playerRoll, 125 | isOrdinal = isOrdinal, 126 | ) 127 | }.toPersistentList() 128 | boardState.update { BoardRoll(newRollResult) } 129 | } 130 | } 131 | 132 | fun selectDice(diceSize: Int) { 133 | viewModelScope.launch { 134 | repository.selectDice(diceSize) 135 | } 136 | } 137 | 138 | fun movePlayer(playerId: PlayerId, direction: Int) { 139 | viewModelScope.launch { 140 | repository.movePlayer(playerId, direction) 141 | } 142 | } 143 | 144 | fun clearMode() { 145 | boardState.update { BoardCounters } 146 | } 147 | 148 | val boardUIState = combine( 149 | repository.appState, 150 | comboCounters, 151 | boardState, 152 | ) { appState, combos, boardState -> 153 | if (!appState.isPlayable) 154 | return@combine SetupRequired 155 | 156 | when (boardState) { 157 | BoardCounters -> CounterBoardUI( 158 | alwaysUprightMode = appState.alwaysUprightMode, 159 | players = appState.players.map { player -> 160 | CounterCardUIState( 161 | id = player.id, 162 | color = player.color, 163 | name = player.name, 164 | counters = appState.counters.map { 165 | CounterUIState( 166 | id = it.id, 167 | name = it.name, 168 | value = player.counters[it.id]!!, 169 | combo = combos[ComboCounterId(player.id, it.id)] ?: 0 170 | ) 171 | }, 172 | selectedCounter = player.selectedCounter ?: appState.counters[0].id 173 | ) 174 | }.toPersistentList(), 175 | ) 176 | BoardPlayerSettings -> PlayerSettingsBoardUI( 177 | alwaysUprightMode = appState.alwaysUprightMode, 178 | players = appState.players.map { 179 | player -> PlayerSettingsUIState(player.id, player.color, player.name) 180 | }.toImmutableList() 181 | ) 182 | is BoardRoll -> RollUI( 183 | alwaysUprightMode = appState.alwaysUprightMode, 184 | players = boardState.result, 185 | selectedDice = appState.selectedDice, 186 | ) 187 | } 188 | 189 | 190 | }.stateIn( 191 | scope = viewModelScope, 192 | started = SharingStarted.WhileSubscribed(5_000), 193 | initialValue = StartupUI, 194 | ) 195 | 196 | fun resetGame() { 197 | viewModelScope.launch { 198 | // clear combo counters 199 | comboCounters.update { it.clear() } 200 | 201 | // stop combo reset timers 202 | comboCountersTimers.cancelAll() 203 | repository.resetPlayerCounters() 204 | } 205 | } 206 | 207 | fun addPlayer() { 208 | viewModelScope.launch { 209 | repository.addPlayers(1) 210 | } 211 | } 212 | 213 | fun setUlwaysUprightMode(alwaysUprightMode: Boolean) { 214 | viewModelScope.launch { 215 | repository.setAlwaysUprightMode(alwaysUprightMode) 216 | } 217 | } 218 | 219 | fun updateCounter(playerId: PlayerId, counterId: CounterId, counterDelta: Int) { 220 | viewModelScope.launch { 221 | // update the counter 222 | repository.updatePlayerCounter(playerId, counterId, counterDelta) 223 | 224 | val counterKey = ComboCounterId(playerId, counterId) 225 | // update the combo counter 226 | comboCounters.update { 227 | val oldCounter = it[counterKey] ?: 0 228 | it.put(counterKey, oldCounter + counterDelta) 229 | } 230 | 231 | // start the reset timer 232 | comboCountersTimers.launch(counterKey) { 233 | delay(2500.milliseconds) 234 | // reset the combo counter once the timer expires 235 | comboCounters.update { 236 | it.remove(counterKey) 237 | } 238 | } 239 | } 240 | } 241 | 242 | fun selectCounter(playerId: PlayerId, counterId: CounterId) { 243 | viewModelScope.launch { 244 | repository.selectCounter(playerId, counterId) 245 | } 246 | } 247 | 248 | fun removePlayer(playerId: PlayerId) { 249 | viewModelScope.launch { 250 | repository.removePlayer(playerId) 251 | } 252 | } 253 | 254 | fun setPlayerColor(playerId: PlayerId, color: Color) { 255 | viewModelScope.launch { 256 | repository.setPlayerColor(playerId, color) 257 | } 258 | } 259 | 260 | fun setPlayerName(playerId: PlayerId, name: String) { 261 | viewModelScope.launch { 262 | repository.setPlayerName(playerId, name) 263 | } 264 | } 265 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/ConfirmDialog.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TextButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.res.stringResource 8 | import net.multun.gamecounter.R 9 | 10 | @Composable 11 | fun ConfirmDialog( 12 | dialogText: String, 13 | dialogTitle: String? = null, 14 | icon: @Composable () -> Unit = {}, 15 | onDismissRequest: () -> Unit = {}, 16 | onConfirmation: () -> Unit = {}, 17 | ) { 18 | AlertDialog( 19 | icon = icon, 20 | title = dialogTitle?.let {{ 21 | Text(text = it) 22 | }}, 23 | text = { 24 | Text(text = dialogText) 25 | }, 26 | onDismissRequest = onDismissRequest, 27 | confirmButton = { 28 | TextButton(onClick = onConfirmation) { 29 | Text(stringResource(R.string.confirm)) 30 | } 31 | }, 32 | dismissButton = { 33 | TextButton(onClick = onDismissRequest) { 34 | Text(stringResource(R.string.cancel)) 35 | } 36 | } 37 | ) 38 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/CounterUpdateButton.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.foundation.interaction.MutableInteractionSource 4 | import androidx.compose.foundation.interaction.collectIsPressedAsState 5 | import androidx.compose.material3.IconButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Modifier 13 | import kotlinx.coroutines.delay 14 | import kotlin.time.Duration.Companion.milliseconds 15 | 16 | 17 | private val INITIAL_DELAY = 700.milliseconds 18 | private val BUMP_DELAY = 400.milliseconds 19 | 20 | 21 | sealed interface CounterUpdateEvent 22 | data object SmallCounterUpdate : CounterUpdateEvent 23 | data object BigCounterUpdate : CounterUpdateEvent 24 | 25 | fun CounterUpdateEvent.stepSize(): Int { 26 | return when (this) { 27 | BigCounterUpdate -> 10 28 | SmallCounterUpdate -> 1 29 | } 30 | } 31 | 32 | @Composable 33 | fun CounterUpdateButton( 34 | modifier: Modifier = Modifier, 35 | onUpdateCounter: (CounterUpdateEvent) -> Unit, 36 | content: @Composable () -> Unit, 37 | ) { 38 | val minusInteractionSource = remember { MutableInteractionSource() } 39 | val isPressed by minusInteractionSource.collectIsPressedAsState() 40 | var longPressed by remember { mutableStateOf(false) } 41 | 42 | LaunchedEffect(isPressed) { 43 | if (!isPressed) 44 | return@LaunchedEffect 45 | 46 | delay(INITIAL_DELAY) 47 | longPressed = true 48 | while (true) { 49 | onUpdateCounter(BigCounterUpdate) 50 | delay(BUMP_DELAY) 51 | } 52 | } 53 | 54 | IconButton( 55 | interactionSource = minusInteractionSource, 56 | modifier = modifier, 57 | content = content, 58 | onClick = { 59 | if (longPressed) { 60 | longPressed = false 61 | } else { 62 | onUpdateCounter(SmallCounterUpdate) 63 | } 64 | }, 65 | ) 66 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/FontScale.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.material3.LocalTextStyle 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.CompositionLocalProvider 6 | import androidx.compose.ui.platform.LocalDensity 7 | import androidx.compose.ui.text.TextStyle 8 | import androidx.compose.ui.unit.Dp 9 | import androidx.compose.ui.unit.TextUnit 10 | import kotlin.math.min 11 | 12 | 13 | @JvmInline 14 | value class FontScale(val value: Float) 15 | 16 | data class FontSizeClass(val base: Dp, val max: Dp) 17 | 18 | fun counterScale(maxWidth: Dp, maxHeight: Dp): FontScale { 19 | val verticalScale = maxHeight / PLAYER_MIN_HEIGHT 20 | val horizontalScale = maxWidth / PLAYER_MIN_WIDTH 21 | return FontScale(min(verticalScale, horizontalScale)) 22 | } 23 | 24 | @Composable 25 | fun FontScale.applyDp(sizeClass: FontSizeClass): Dp { 26 | var res = sizeClass.base * value 27 | if (res > sizeClass.max) 28 | res = sizeClass.max 29 | return res 30 | } 31 | 32 | @Composable 33 | fun FontScale.apply(sizeClass: FontSizeClass): TextUnit { 34 | return with(LocalDensity.current) { 35 | this@apply.applyDp(sizeClass).toSp() 36 | } 37 | } 38 | 39 | 40 | 41 | @Composable 42 | fun WithFontSize( 43 | fontSize: TextUnit, 44 | lineHeight: Float = 1.15f, 45 | baseStyle: TextStyle = LocalTextStyle.current, 46 | content: @Composable () -> Unit 47 | ) { 48 | val newStyle = baseStyle.copy(fontSize = fontSize, lineHeight = fontSize * lineHeight) 49 | CompositionLocalProvider(LocalTextStyle provides newStyle, content = content) 50 | } 51 | 52 | 53 | @Composable 54 | fun WithScaledFontSize( 55 | scale: FontScale, 56 | sizeClass: FontSizeClass, 57 | lineHeight: Float = 1.15f, 58 | baseStyle: TextStyle = LocalTextStyle.current, 59 | content: @Composable () -> Unit 60 | ) { 61 | WithFontSize(scale.apply(sizeClass), lineHeight, baseStyle, content) 62 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/NumberFormat.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import android.icu.number.NumberFormatter 4 | import android.icu.text.DecimalFormat 5 | import android.icu.text.DecimalFormatSymbols 6 | import android.os.Build 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.text.AnnotatedString 10 | import androidx.compose.ui.text.SpanStyle 11 | import androidx.compose.ui.text.buildAnnotatedString 12 | import androidx.compose.ui.text.withStyle 13 | import androidx.compose.ui.unit.TextUnit 14 | import java.util.Locale 15 | import kotlin.streams.toList 16 | 17 | 18 | fun formatNumber(number: Int, pattern: String, locale: Locale = Locale.getDefault()): String { 19 | try { 20 | val formatter = android.icu.text.MessageFormat(pattern, locale) 21 | return formatter.format(arrayOf(number)) 22 | } catch (e: IllegalArgumentException) { 23 | return number.toString() 24 | } 25 | } 26 | 27 | fun formatOrdinal(number: Int, locale: Locale = Locale.getDefault()): String { 28 | return formatNumber(number, "{0,ordinal}", locale) 29 | } 30 | 31 | fun formatInteger(number: Int, locale: Locale = Locale.getDefault()): String { 32 | return formatNumber(number, "{0,number,integer}", locale) 33 | } 34 | 35 | fun formatCombo(number: Int, locale: Locale = Locale.getDefault()): String { 36 | // we can't use {0, number, :: +?}, as this is not available in nougat 37 | val formatter = DecimalFormat("0", DecimalFormatSymbols(locale)) 38 | if (number != 0) 39 | formatter.positivePrefix = "+" 40 | return formatter.format(number) 41 | } 42 | 43 | @Composable 44 | fun ordinalAnnotatedString(ordinal: String, ordFontSize: TextUnit): AnnotatedString { 45 | val codePoints = ordinal.codePoints().toList() 46 | val firstDigitCPIndex = codePoints.indexOfFirst { Character.isDigit(it) } 47 | val lastDigitCPIndex = codePoints.indexOfLast { Character.isDigit(it) } 48 | val digitsStart = ordinal.offsetByCodePoints(0, firstDigitCPIndex) 49 | val digitsEnd = ordinal.offsetByCodePoints(digitsStart, lastDigitCPIndex - digitsStart + 1) 50 | 51 | val prefix = ordinal.substring(0, digitsStart) 52 | val digits = ordinal.substring(digitsStart, digitsEnd) 53 | val suffix = ordinal.substring(digitsEnd, ordinal.length) 54 | 55 | val smallTextStyle = SpanStyle( 56 | fontSize = ordFontSize, 57 | color = MaterialTheme.colorScheme.onSurfaceVariant 58 | ) 59 | return buildAnnotatedString { 60 | withStyle(style = smallTextStyle) { 61 | append(prefix) 62 | } 63 | append(digits) 64 | withStyle(style = smallTextStyle) { 65 | append(suffix) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/PlayerCard.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.BoxWithConstraints 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.ChevronLeft 14 | import androidx.compose.material.icons.filled.ChevronRight 15 | import androidx.compose.material.icons.filled.Delete 16 | import androidx.compose.material3.Card 17 | import androidx.compose.material3.CardColors 18 | import androidx.compose.material3.CardDefaults 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.IconButton 21 | import androidx.compose.material3.LocalTextStyle 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Text 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.CompositionLocalProvider 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.mutableStateOf 28 | import androidx.compose.runtime.saveable.rememberSaveable 29 | import androidx.compose.runtime.setValue 30 | import androidx.compose.ui.Alignment 31 | import androidx.compose.ui.Modifier 32 | import androidx.compose.ui.graphics.Color 33 | import androidx.compose.ui.res.stringResource 34 | import androidx.compose.ui.tooling.preview.Preview 35 | import androidx.compose.ui.unit.Dp 36 | import androidx.compose.ui.unit.dp 37 | import com.sd.lib.compose.wheel_picker.DefaultWheelPickerDisplay 38 | import com.sd.lib.compose.wheel_picker.FVerticalWheelPicker 39 | import com.sd.lib.compose.wheel_picker.FWheelPickerContentScope 40 | import com.sd.lib.compose.wheel_picker.FWheelPickerDisplayScope 41 | import com.sd.lib.compose.wheel_picker.FWheelPickerFocusVertical 42 | import com.sd.lib.compose.wheel_picker.FWheelPickerState 43 | import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState 44 | import net.multun.gamecounter.PaletteColor 45 | import net.multun.gamecounter.R 46 | import net.multun.gamecounter.store.CounterId 47 | import net.multun.gamecounter.store.PlayerId 48 | import net.multun.gamecounter.toDisplayColor 49 | 50 | 51 | val MAIN_CARD_TEXT = FontSizeClass(base = 45.dp, max = 112.5.dp) 52 | val UPDATE_CARD_TEXT = FontSizeClass(base = 35.dp, max = 87.5.dp) 53 | val ORDINAL_CARD_TEXT = FontSizeClass(base = 30.dp, max = 75.dp) 54 | val SUB_CARD_TEXT = FontSizeClass(base = 18.dp, max = 28.dp) 55 | val EXP_CARD_TEXT = FontSizeClass(base = 18.dp, max = 45.dp) 56 | val NAME_CARD_TEXT = FontSizeClass(base = 14.dp, max = 20.dp) 57 | 58 | 59 | enum class PlayerModal { 60 | PALETTE, 61 | COUNTER_UPDATE, 62 | } 63 | 64 | fun cyclicalStartingPoint(cycleLength: Int): Int { 65 | val midpoint = Int.MAX_VALUE / 2 66 | return midpoint - midpoint % cycleLength 67 | } 68 | 69 | @Composable 70 | fun FCyclicalVerticalWheelPicker( 71 | modifier: Modifier = Modifier, 72 | state: FWheelPickerState = rememberFWheelPickerState(), 73 | key: ((index: Int) -> Any)? = null, 74 | itemHeight: Dp = 35.dp, 75 | unfocusedCount: Int = 2, 76 | userScrollEnabled: Boolean = true, 77 | reverseLayout: Boolean = false, 78 | debug: Boolean = false, 79 | focus: @Composable () -> Unit = { FWheelPickerFocusVertical() }, 80 | display: @Composable FWheelPickerDisplayScope.(index: Int) -> Unit = { DefaultWheelPickerDisplay(it) }, 81 | content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, 82 | ) { 83 | val fakeCount = Int.MAX_VALUE - (unfocusedCount * 2) 84 | FVerticalWheelPicker( 85 | modifier, 86 | fakeCount, 87 | state, 88 | key, 89 | itemHeight, 90 | unfocusedCount, 91 | userScrollEnabled, 92 | reverseLayout, 93 | debug, 94 | focus, 95 | display, 96 | content, 97 | ) 98 | } 99 | 100 | 101 | @Composable 102 | fun Player( 103 | player: CardUIState, 104 | onSetColor: (Color) -> Unit, 105 | onEditName: () -> Unit, 106 | onDelete: () -> Unit, 107 | onMove: (Int) -> Unit, 108 | onUpdateCounter: (CounterId, Int) -> Unit, 109 | onSelectCounter: (CounterId) -> Unit, 110 | modifier: Modifier = Modifier, 111 | ) { 112 | var modal: PlayerModal? by rememberSaveable { mutableStateOf(null) } 113 | val sign = rememberFWheelPickerState(cyclicalStartingPoint(2)) 114 | 115 | GameCard( 116 | baseColor = player.color, 117 | modifier = modifier 118 | ) { 119 | BoxWithConstraints { 120 | val counterScale = counterScale(this.maxWidth, this.maxHeight) 121 | when (player) { 122 | is RollCardUIState -> { 123 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 124 | PlayerName(counterScale, player.name, modifier = Modifier 125 | .align(Alignment.TopStart) 126 | .fillMaxWidth()) 127 | 128 | val baseStyle = LocalTextStyle.current 129 | val digitsFontSize = counterScale.apply(MAIN_CARD_TEXT) 130 | val ordFontSize = counterScale.apply(ORDINAL_CARD_TEXT) 131 | WithFontSize(digitsFontSize, baseStyle = baseStyle) { 132 | if (player.isOrdinal) { 133 | val ordinal = formatOrdinal(player.roll) 134 | Text(ordinalAnnotatedString(ordinal, ordFontSize)) 135 | } else { 136 | Text(formatInteger(player.roll)) 137 | } 138 | } 139 | } 140 | } 141 | is CounterCardUIState -> { 142 | if (modal == PlayerModal.PALETTE) { 143 | PlayerCardPalette( 144 | currentPlayerColor = player.color, 145 | onExit = { modal = null }, 146 | onSetColor = onSetColor, 147 | ) 148 | return@BoxWithConstraints 149 | } 150 | 151 | if (modal == PlayerModal.COUNTER_UPDATE) { 152 | PlayerCounterUpdateMenu( 153 | sign, player, counterScale, 154 | onUpdateCounter, onClose = { modal = null } 155 | ) 156 | return@BoxWithConstraints 157 | } 158 | 159 | PlayerCounter( 160 | player = player, 161 | modifier = Modifier.fillMaxSize(), 162 | counterScale = counterScale, 163 | onEditCounter = { modal = PlayerModal.COUNTER_UPDATE }, 164 | onUpdateCounter = onUpdateCounter, 165 | onSelectCounter = onSelectCounter, 166 | onEditColor = { modal = PlayerModal.PALETTE } 167 | ) 168 | } 169 | 170 | is PlayerSettingsUIState -> { 171 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 172 | WithScaledFontSize(counterScale, SUB_CARD_TEXT) { 173 | Row( 174 | modifier = Modifier 175 | .clickable(onClick = onEditName) 176 | .fillMaxSize(), 177 | verticalAlignment = Alignment.CenterVertically, 178 | horizontalArrangement = Arrangement.Center, 179 | ) { 180 | val playerName = player.name 181 | if (playerName.isEmpty()) { 182 | Text( 183 | text = stringResource(R.string.player_name), 184 | color = MaterialTheme.colorScheme.onSurfaceVariant 185 | ) 186 | } else { 187 | Text( 188 | text = playerName, 189 | color = MaterialTheme.colorScheme.onSurface 190 | ) 191 | } 192 | } 193 | } 194 | 195 | IconButton(onClick = onDelete, modifier = Modifier.align(Alignment.TopEnd)) { 196 | Icon(Icons.Default.Delete, stringResource(R.string.delete_player)) 197 | } 198 | 199 | IconButton(onClick = { onMove(-1) }, modifier = Modifier.align(Alignment.BottomStart)) { 200 | Icon(Icons.Default.ChevronLeft, stringResource(R.string.move_left)) 201 | } 202 | 203 | IconButton(onClick = { onMove(1) }, modifier = Modifier.align(Alignment.BottomEnd)) { 204 | Icon(Icons.Default.ChevronRight, stringResource(R.string.move_right)) 205 | } 206 | } 207 | } 208 | } 209 | } 210 | } 211 | } 212 | 213 | 214 | @Composable 215 | private fun gameCardColors(backgroundColor: Color): CardColors { 216 | return CardDefaults 217 | .cardColors() 218 | .copy(containerColor = backgroundColor) 219 | } 220 | 221 | @Composable 222 | fun GameCard(baseColor: Color, modifier: Modifier = Modifier, content: @Composable () -> Unit) { 223 | Card( 224 | modifier = modifier, 225 | colors = gameCardColors(baseColor.toDisplayColor()) 226 | ) { 227 | content() 228 | } 229 | } 230 | 231 | // clickable variant 232 | @Composable 233 | fun GameCard(baseColor: Color, modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) { 234 | Card( 235 | onClick = onClick, 236 | modifier = modifier, 237 | colors = gameCardColors(baseColor.toDisplayColor()) 238 | ) { 239 | content() 240 | } 241 | } 242 | 243 | @Composable 244 | fun GameButton( 245 | baseColor: Color, 246 | onClick: () -> Unit, 247 | modifier: Modifier = Modifier, 248 | padding: Dp = 20.dp, 249 | content: @Composable () -> Unit, 250 | ) { 251 | GameCard( 252 | baseColor = baseColor, 253 | modifier = modifier, 254 | onClick = onClick, 255 | content = { 256 | val newTextStyle = LocalTextStyle.current.merge(MaterialTheme.typography.labelLarge) 257 | CompositionLocalProvider(LocalTextStyle provides newTextStyle) { 258 | Column(modifier = Modifier.padding(padding)) { 259 | content() 260 | } 261 | } 262 | } 263 | ) 264 | } 265 | 266 | 267 | @Composable 268 | fun GameIconButton( 269 | baseColor: Color, 270 | onClick: () -> Unit, 271 | modifier: Modifier = Modifier, 272 | content: @Composable () -> Unit, 273 | ) { 274 | GameButton(baseColor, onClick, modifier, 15.dp, content) 275 | } 276 | 277 | 278 | @Preview(widthDp = 150, heightDp = 150, fontScale = 1f) 279 | @Composable 280 | fun PlayerCardPreview() { 281 | Player( 282 | player = CounterCardUIState( 283 | id = PlayerId(0), 284 | color = PaletteColor.Blue.color, 285 | name = "Alice", 286 | counters = listOf( 287 | CounterUIState(CounterId(0), "test", 100, 1) 288 | ), 289 | selectedCounter = CounterId(0), 290 | ), 291 | onUpdateCounter = { _, _ -> }, 292 | onSelectCounter = {}, 293 | onSetColor = {}, 294 | onEditName = {}, 295 | onDelete = {}, 296 | onMove = { _ -> }, 297 | ) 298 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/PlayerCardPalette.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalLayoutApi::class) 2 | 3 | package net.multun.gamecounter.ui.board 4 | 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 12 | import androidx.compose.foundation.layout.FlowRow 13 | import androidx.compose.foundation.layout.Row 14 | import androidx.compose.foundation.layout.Spacer 15 | import androidx.compose.foundation.layout.fillMaxSize 16 | import androidx.compose.foundation.layout.fillMaxWidth 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.layout.size 19 | import androidx.compose.foundation.rememberScrollState 20 | import androidx.compose.foundation.shape.RoundedCornerShape 21 | import androidx.compose.foundation.verticalScroll 22 | import androidx.compose.material.icons.Icons 23 | import androidx.compose.material.icons.filled.Check 24 | import androidx.compose.material.icons.filled.Clear 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.IconButton 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.getValue 29 | import androidx.compose.runtime.mutableStateOf 30 | import androidx.compose.runtime.remember 31 | import androidx.compose.runtime.setValue 32 | import androidx.compose.ui.Alignment 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.draw.clip 35 | import androidx.compose.ui.graphics.Color 36 | import androidx.compose.ui.res.stringResource 37 | import androidx.compose.ui.tooling.preview.Preview 38 | import androidx.compose.ui.unit.dp 39 | import net.multun.gamecounter.PALETTE 40 | import net.multun.gamecounter.PaletteColor 41 | import net.multun.gamecounter.R 42 | import net.multun.gamecounter.toDisplayColor 43 | 44 | 45 | @Composable 46 | fun PlayerCardPalette( 47 | currentPlayerColor: Color, 48 | onExit: () -> Unit, 49 | onSetColor: (Color) -> Unit, 50 | ) { 51 | Box(modifier = Modifier.fillMaxSize()) { 52 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 53 | CardSettingsTopBar(onBack = onExit) 54 | ColorPicker(currentPlayerColor, onSetColor) 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | fun CardSettingsTopBar(onBack: () -> Unit) { 61 | // top row 62 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 63 | // back button 64 | IconButton(onClick = onBack) { 65 | Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.previous_screen)) 66 | } 67 | } 68 | } 69 | 70 | @Composable 71 | private fun ColorPicker( 72 | currentPlayerColor: Color, 73 | onSetColor: (Color) -> Unit, 74 | ) { 75 | FlowRow( 76 | modifier = Modifier 77 | .fillMaxWidth() 78 | .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), 79 | horizontalArrangement = Arrangement.spacedBy( 80 | space = 4.dp, 81 | alignment = Alignment.CenterHorizontally 82 | ), 83 | verticalArrangement = Arrangement.spacedBy( 84 | space = 4.dp, 85 | alignment = Alignment.CenterVertically 86 | ), 87 | ) { 88 | for (color in PALETTE) { 89 | PaletteItem(color.toDisplayColor(), selected = color == currentPlayerColor) { 90 | onSetColor(color) 91 | } 92 | } 93 | } 94 | } 95 | 96 | @Composable 97 | fun PaletteItem(color: Color, modifier: Modifier = Modifier, selected: Boolean = false, onClick: () -> Unit) { 98 | Box(contentAlignment = Alignment.Center, modifier = modifier) { 99 | Spacer(modifier = Modifier 100 | .size(40.dp) 101 | .clip(RoundedCornerShape(8.dp)) 102 | .background(color) 103 | .border(width = 1.5.dp, color = Color.DarkGray, shape = RoundedCornerShape(8.dp)) 104 | .clickable(onClick = onClick) 105 | ) 106 | if (selected) { 107 | Icon(Icons.Default.Check, null) 108 | } 109 | } 110 | } 111 | 112 | 113 | @Preview(widthDp = 150, heightDp = 150) 114 | @Composable 115 | fun PreviewPlayerSettings() { 116 | var playerColor by remember { mutableStateOf(PaletteColor.Green.color) } 117 | GameCard(baseColor = playerColor) { 118 | PlayerCardPalette( 119 | currentPlayerColor = playerColor, 120 | onExit = {}, 121 | onSetColor = { playerColor = it }, 122 | ) 123 | } 124 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/PlayerCounter.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.AnimatedContentTransitionScope 5 | import androidx.compose.animation.ContentTransform 6 | import androidx.compose.animation.SizeTransform 7 | import androidx.compose.animation.fadeIn 8 | import androidx.compose.animation.fadeOut 9 | import androidx.compose.animation.slideInVertically 10 | import androidx.compose.animation.slideOutVertically 11 | import androidx.compose.animation.togetherWith 12 | import androidx.compose.foundation.clickable 13 | import androidx.compose.foundation.layout.Arrangement 14 | import androidx.compose.foundation.layout.Box 15 | import androidx.compose.foundation.layout.Row 16 | import androidx.compose.foundation.layout.fillMaxWidth 17 | import androidx.compose.foundation.layout.height 18 | import androidx.compose.foundation.layout.padding 19 | import androidx.compose.foundation.layout.wrapContentHeight 20 | import androidx.compose.material.icons.Icons 21 | import androidx.compose.material.icons.filled.Add 22 | import androidx.compose.material.icons.filled.Remove 23 | import androidx.compose.material.icons.outlined.Palette 24 | import androidx.compose.material.icons.outlined.PersonOutline 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.IconButton 27 | import androidx.compose.material3.IconButtonDefaults 28 | import androidx.compose.material3.LocalTextStyle 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.Text 31 | import androidx.compose.material3.minimumInteractiveComponentSize 32 | import androidx.compose.runtime.Composable 33 | import androidx.compose.runtime.LaunchedEffect 34 | import androidx.compose.runtime.remember 35 | import androidx.compose.runtime.snapshotFlow 36 | import androidx.compose.ui.Alignment 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.platform.LocalDensity 39 | import androidx.compose.ui.res.stringResource 40 | import androidx.compose.ui.text.TextMeasurer 41 | import androidx.compose.ui.text.TextStyle 42 | import androidx.compose.ui.text.rememberTextMeasurer 43 | import androidx.compose.ui.text.style.TextOverflow 44 | import androidx.compose.ui.unit.Density 45 | import androidx.compose.ui.unit.Dp 46 | import androidx.compose.ui.unit.dp 47 | import androidx.constraintlayout.compose.ChainStyle 48 | import androidx.constraintlayout.compose.ConstraintLayout 49 | import androidx.constraintlayout.compose.ConstraintSet 50 | import androidx.constraintlayout.compose.layoutId 51 | import com.sd.lib.compose.wheel_picker.FHorizontalWheelPicker 52 | import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState 53 | import net.multun.gamecounter.R 54 | import net.multun.gamecounter.store.CounterId 55 | 56 | 57 | @Composable 58 | fun CounterSelector( 59 | counterScale: FontScale, 60 | selectedCounterId: CounterId, 61 | counters: List, 62 | onSelectCounter: (CounterId) -> Unit, 63 | modifier: Modifier = Modifier, 64 | ) { 65 | val state = rememberFWheelPickerState(counters.indexOfFirst { it.id == selectedCounterId }) 66 | 67 | LaunchedEffect(state, counters.map { it.id }) { 68 | snapshotFlow { state.currentIndex } 69 | .collect { 70 | if (it != -1) 71 | onSelectCounter(counters[it].id) 72 | } 73 | } 74 | 75 | val fontSize = counterScale.applyDp(SUB_CARD_TEXT) 76 | val fontSizeSp = with(LocalDensity.current) { fontSize.toSp() } 77 | WithFontSize(fontSizeSp) { 78 | val textStyle = LocalTextStyle.current 79 | val density = LocalDensity.current 80 | val counterNames = counters.map { it.name } 81 | val textMeasurer = rememberTextMeasurer() 82 | val itemWidth = remember(textMeasurer, textStyle, counterNames) { 83 | counterNames.selectorWidth(textMeasurer, textStyle, density) 84 | } 85 | FHorizontalWheelPicker( 86 | modifier = modifier.height(fontSize * 1.4f), 87 | state = state, 88 | count = counters.size, 89 | itemWidth = itemWidth, 90 | ) { index -> 91 | Text( 92 | counters[index].name, 93 | maxLines = 1, 94 | overflow = TextOverflow.Clip, 95 | ) 96 | } 97 | } 98 | } 99 | 100 | fun List.selectorWidth( 101 | textMeasurer: TextMeasurer, 102 | textStyle: TextStyle, 103 | density: Density, 104 | padding: Float = 0.2f, 105 | minWidth: Float = 2f, 106 | maxWidth: Float = 5f, 107 | ): Dp { 108 | val maxCounterNameWidthPx = this.maxOf { 109 | textMeasurer.measure( 110 | it, 111 | textStyle 112 | ).size.width 113 | } 114 | 115 | val maxCounterNameWidth: Dp 116 | val fontSize: Dp 117 | with(density) { 118 | maxCounterNameWidth = maxCounterNameWidthPx.toDp() 119 | fontSize = textStyle.fontSize.toDp() 120 | } 121 | 122 | var itemWidth = maxCounterNameWidth + fontSize * padding * 2 123 | val maxItemWidth = fontSize * maxWidth 124 | val minItemWidth = fontSize * minWidth 125 | if (itemWidth > maxItemWidth) 126 | itemWidth = maxItemWidth 127 | if (itemWidth < minItemWidth) 128 | itemWidth = minItemWidth 129 | return itemWidth 130 | } 131 | 132 | @Composable 133 | fun PlayerTopRowButton(onClick: () -> Unit, muted: Boolean = true, content: @Composable () -> Unit) { 134 | var colors = IconButtonDefaults.iconButtonColors() 135 | if (muted) { 136 | colors = colors.copy( 137 | contentColor = MaterialTheme.colorScheme.onSurfaceVariant 138 | ) 139 | } 140 | IconButton(onClick = onClick, colors = colors, content = content) 141 | } 142 | 143 | @Composable 144 | fun PlayerTopRow( 145 | playerName: String, 146 | counterScale: FontScale, 147 | modifier: Modifier = Modifier, 148 | buttons: @Composable () -> Unit 149 | ) { 150 | // the top row, with the player name at the left and edit button at the right 151 | Row( 152 | modifier = modifier.fillMaxWidth(), 153 | horizontalArrangement = Arrangement.SpaceBetween, 154 | ) { 155 | // adding a weight causes the row item size to be measured after unweighted items, 156 | // which allows the edit button to keep its size despite being after the player name 157 | PlayerName(counterScale, playerName, modifier = Modifier.weight(1f)) 158 | 159 | buttons() 160 | } 161 | } 162 | 163 | 164 | @Composable 165 | fun PlayerCounter( 166 | player: CounterCardUIState, 167 | onUpdateCounter: (CounterId, Int) -> Unit, 168 | onSelectCounter: (CounterId) -> Unit, 169 | onEditCounter: () -> Unit, 170 | onEditColor: () -> Unit, 171 | modifier: Modifier = Modifier, 172 | counterScale: FontScale, 173 | ) { 174 | ConstraintLayout(playerCounterLayout(), modifier = modifier) { 175 | val counter = player.counters.find { it.id == player.selectedCounter }!! 176 | 177 | // the top row, with the player name at the left and edit button at the right 178 | PlayerTopRow(player.name, counterScale, Modifier.layoutId("topRow")) { 179 | PlayerTopRowButton(onClick = onEditColor) { 180 | Icon( 181 | Icons.Outlined.Palette, 182 | contentDescription = stringResource(R.string.player_settings) 183 | ) 184 | } 185 | } 186 | 187 | // minus 188 | CounterUpdateButton( 189 | onUpdateCounter = { onUpdateCounter(player.selectedCounter, -it.stepSize()) }, 190 | modifier = Modifier.layoutId("decr"), 191 | ) { 192 | Icon( 193 | Icons.Default.Remove, 194 | contentDescription = stringResource(R.string.decrease_counter) 195 | ) 196 | } 197 | 198 | // plus 199 | CounterUpdateButton( 200 | onUpdateCounter = { onUpdateCounter(player.selectedCounter, it.stepSize()) }, 201 | modifier = Modifier.layoutId("incr"), 202 | ) { 203 | Icon( 204 | Icons.Default.Add, 205 | contentDescription = stringResource(R.string.increase_counter) 206 | ) 207 | } 208 | 209 | // counter 210 | WithScaledFontSize(counterScale, MAIN_CARD_TEXT, lineHeight = 1f) { 211 | Text( 212 | text = formatInteger(counter.value), 213 | modifier = Modifier.layoutId("counterValue").clickable(onClick = onEditCounter) 214 | ) 215 | } 216 | 217 | // combo counter 218 | AnimatedContent( 219 | label = "combo animation", 220 | modifier = Modifier.layoutId("combo"), 221 | contentAlignment = Alignment.BottomEnd, 222 | targetState = counter.combo, 223 | transitionSpec = { comboCounterAnimation() }, 224 | ) { targetCount -> 225 | if (targetCount != 0) { 226 | val comboText = formatCombo(targetCount) 227 | WithScaledFontSize(counterScale, EXP_CARD_TEXT) { 228 | Text(text = comboText) 229 | } 230 | } 231 | } 232 | 233 | // the box is used to avoid this warning: "Nothing to measure for widget: counterSelector" 234 | Box(Modifier.layoutId("counterSelector")) { 235 | if (player.counters.size > 1) { 236 | CounterSelector( 237 | counterScale = counterScale, 238 | selectedCounterId = player.selectedCounter, 239 | counters = player.counters, 240 | onSelectCounter = onSelectCounter, 241 | ) 242 | } 243 | } 244 | } 245 | } 246 | 247 | @Composable 248 | fun PlayerName(scale: FontScale, name: String, modifier: Modifier = Modifier) { 249 | WithScaledFontSize(scale, NAME_CARD_TEXT, lineHeight = 1f) { 250 | Text( 251 | text = name, 252 | color = MaterialTheme.colorScheme.onSurfaceVariant, 253 | maxLines = 1, 254 | softWrap = false, 255 | overflow = TextOverflow.Ellipsis, 256 | modifier = modifier 257 | .padding(start = 14.dp) 258 | // this is done to match the height of the edit button 259 | .minimumInteractiveComponentSize() 260 | .wrapContentHeight(align = Alignment.CenterVertically) 261 | ) 262 | } 263 | } 264 | 265 | private fun playerCounterLayout(): ConstraintSet { 266 | return ConstraintSet { 267 | val decr = createRefFor("decr") 268 | val incr = createRefFor("incr") 269 | val counterValue = createRefFor("counterValue") 270 | val combo = createRefFor("combo") 271 | val counterSelector = createRefFor("counterSelector") 272 | val topRow = createRefFor("topRow") 273 | 274 | createHorizontalChain(decr, counterValue, incr, chainStyle = ChainStyle.Spread) 275 | 276 | for (centeredItem in listOf(decr, incr, counterValue)) { 277 | constrain(centeredItem) { 278 | centerVerticallyTo(parent) 279 | } 280 | } 281 | 282 | constrain(counterSelector) { 283 | top.linkTo(counterValue.bottom) 284 | centerHorizontallyTo(parent) 285 | } 286 | 287 | constrain(counterValue) { 288 | centerVerticallyTo(parent) 289 | } 290 | 291 | constrain(combo) { 292 | bottom.linkTo(counterValue.top) 293 | end.linkTo(counterValue.end, margin = -(10.dp)) 294 | } 295 | 296 | constrain(topRow) { 297 | top.linkTo(parent.top, margin = 0.dp) 298 | } 299 | } 300 | } 301 | 302 | fun AnimatedContentTransitionScope.comboCounterAnimation(): ContentTransform { 303 | return if (targetState > initialState) { 304 | // If the target number is larger, it slides up and fades in 305 | // while the initial (smaller) number slides up and fades out. 306 | slideInVertically { height -> height } + fadeIn() togetherWith 307 | slideOutVertically { height -> -height } + fadeOut() 308 | } else { 309 | // If the target number is smaller, it slides down and fades in 310 | // while the initial number slides down and fades out. 311 | slideInVertically { height -> -height } + fadeIn() togetherWith 312 | slideOutVertically { height -> height } + fadeOut() 313 | }.using( 314 | // Disable clipping since the faded slide-in/out should 315 | // be displayed out of bounds. 316 | SizeTransform(clip = false) 317 | ) 318 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/PlayerCounterUpdateMenu.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Check 9 | import androidx.compose.material.icons.filled.Clear 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.material3.LocalTextStyle 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.text.rememberTextMeasurer 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.text.style.TextOverflow 22 | import androidx.compose.ui.unit.DpSize 23 | import androidx.compose.ui.unit.IntSize 24 | import androidx.compose.ui.unit.em 25 | import com.sd.lib.compose.wheel_picker.FWheelPickerState 26 | import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState 27 | import net.multun.gamecounter.R 28 | import net.multun.gamecounter.store.CounterId 29 | import kotlin.math.max 30 | 31 | @Composable 32 | fun PlayerCounterUpdateMenu( 33 | signState: FWheelPickerState, 34 | player: CounterCardUIState, 35 | counterScale: FontScale, 36 | onUpdateCounter: (CounterId, Int) -> Unit, 37 | onClose: () -> Unit 38 | ) { 39 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 40 | val textMeasurer = rememberTextMeasurer() 41 | 42 | PlayerTopRow(player.name, counterScale, Modifier.align(Alignment.TopStart)) { 43 | PlayerTopRowButton(muted = false, onClick = onClose) { 44 | Icon( 45 | Icons.Filled.Clear, 46 | contentDescription = stringResource(R.string.close_menu) 47 | ) 48 | } 49 | } 50 | 51 | val integersText = (0 until 10).map { formatInteger(it) } 52 | val charSet = listOf("+", "-") + integersText 53 | val digits = (0 until 4).map { rememberFWheelPickerState(cyclicalStartingPoint(10)) } 54 | WithScaledFontSize(counterScale, UPDATE_CARD_TEXT, lineHeight = 1f) { 55 | // measure the size of text 56 | val minSizePx = charSet 57 | .map { textMeasurer.measure(it, LocalTextStyle.current).size } 58 | .reduce { a, b -> IntSize( 59 | width = max(a.width, b.width), 60 | height = max(a.height, b.height) 61 | ) 62 | } 63 | val minSizeDp = with(LocalDensity.current) { DpSize( 64 | width = minSizePx.width.toDp(), 65 | height = minSizePx.height.toDp()) 66 | } 67 | 68 | Row { 69 | FCyclicalVerticalWheelPicker( 70 | state = signState, 71 | itemHeight = minSizeDp.height, 72 | unfocusedCount = 1, 73 | focus = {}, 74 | modifier = Modifier.width(minSizeDp.width) 75 | ) { index -> 76 | Text(if (index % 2 == 0) "+" else "-", overflow = TextOverflow.Visible, textAlign = TextAlign.Center) 77 | } 78 | 79 | for (i in digits.indices) { 80 | FCyclicalVerticalWheelPicker( 81 | state = digits[i], 82 | itemHeight = minSizeDp.height, 83 | unfocusedCount = 1, 84 | focus = {}, 85 | modifier = Modifier.width(minSizeDp.width) 86 | ) { index -> 87 | Text(integersText[index % 10], overflow = TextOverflow.Visible, textAlign = TextAlign.Center) 88 | } 89 | } 90 | } 91 | } 92 | 93 | IconButton(onClick = { 94 | var total = 0 95 | for (digit in digits) { 96 | total = total * 10 + digit.currentIndex % 10 97 | } 98 | if ((signState.currentIndex % 2) != 0) 99 | total = -total 100 | onUpdateCounter(player.selectedCounter, total) 101 | onClose() 102 | }, Modifier.align(Alignment.BottomEnd)) { 103 | Icon(Icons.Filled.Check, null) 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/RotateLayout.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.draw.rotate 6 | import androidx.compose.ui.graphics.graphicsLayer 7 | import androidx.compose.ui.layout.IntrinsicMeasurable 8 | import androidx.compose.ui.layout.IntrinsicMeasureScope 9 | import androidx.compose.ui.layout.LayoutModifier 10 | import androidx.compose.ui.layout.Measurable 11 | import androidx.compose.ui.layout.MeasureResult 12 | import androidx.compose.ui.layout.MeasureScope 13 | import androidx.compose.ui.unit.Constraints 14 | 15 | 16 | @Immutable 17 | enum class Rotation(private val index: Int, val degrees: Float) { 18 | ROT_0(0, 0f), 19 | ROT_90(1, 90f), 20 | ROT_180(2, 180f), 21 | ROT_270(3, 270f); 22 | 23 | operator fun plus(rotation: Rotation): Rotation { 24 | return when ((this.index + rotation.index) % 4) { 25 | 0 -> ROT_0 26 | 1 -> ROT_90 27 | 2 -> ROT_180 28 | 3 -> ROT_270 29 | else -> error("") 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Rotates the composable by 90 degrees increments, taking layout into account: the composable 36 | * is rendered taking into account the fact usable space changes as the composable rotates. 37 | * 38 | * Usage of this API renders this composable into a separate graphics layer. 39 | 40 | * @see Modifier.rotate 41 | * @see graphicsLayer 42 | */ 43 | fun Modifier.rotateLayout(rotation: Rotation): Modifier { 44 | return when (rotation) { 45 | Rotation.ROT_0 -> this 46 | Rotation.ROT_180 -> rotate(rotation.degrees) 47 | Rotation.ROT_90, Rotation.ROT_270 -> then(HorizontalLayoutModifier).rotate(rotation.degrees) 48 | } 49 | } 50 | 51 | /** Swap horizontal and vertical constraints */ 52 | private fun Constraints.transpose(): Constraints { 53 | return copy( 54 | minWidth = minHeight, 55 | maxWidth = maxHeight, 56 | minHeight = minWidth, 57 | maxHeight = maxWidth 58 | ) 59 | } 60 | 61 | private object HorizontalLayoutModifier : LayoutModifier { 62 | override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { 63 | val placeable = measurable.measure(constraints.transpose()) 64 | return layout(placeable.height, placeable.width) { 65 | placeable.place( 66 | x = -(placeable.width / 2 - placeable.height / 2), 67 | y = -(placeable.height / 2 - placeable.width / 2) 68 | ) 69 | } 70 | } 71 | 72 | override fun IntrinsicMeasureScope.minIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int { 73 | return measurable.maxIntrinsicWidth(width) 74 | } 75 | 76 | override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurable: IntrinsicMeasurable, width: Int): Int { 77 | return measurable.maxIntrinsicWidth(width) 78 | } 79 | 80 | override fun IntrinsicMeasureScope.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int { 81 | return measurable.minIntrinsicHeight(height) 82 | } 83 | 84 | override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int { 85 | return measurable.maxIntrinsicHeight(height) 86 | } 87 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/board/UniqueJobPool.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.board 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.launch 6 | import kotlin.coroutines.cancellation.CancellationException 7 | 8 | 9 | data object OverrideCancellation : CancellationException(null) { 10 | private fun readResolve(): Any = OverrideCancellation 11 | } 12 | 13 | class UniqueJobPool(private val scope: CoroutineScope) { 14 | private val jobs = mutableMapOf() 15 | 16 | fun launch(key: T, block: suspend CoroutineScope.() -> Unit) { 17 | jobs.compute(key) { _, oldJob -> 18 | // stop the old job and invoke its completion handler 19 | oldJob?.cancel(OverrideCancellation) 20 | 21 | // start the new job 22 | val newJob = scope.launch(block = block) 23 | 24 | // setup the new job to remove itself from the map once completed 25 | newJob.invokeOnCompletion { 26 | // do not attempt to automatically remove the job on cancellation, 27 | // as it will cause concurrent modifications: 28 | // jobs.compute() -> oldJob.cancel() -> jobs.remove(key, oldJob) 29 | if (it == OverrideCancellation) 30 | return@invokeOnCompletion 31 | jobs.remove(key, newJob) 32 | } 33 | newJob 34 | } 35 | } 36 | 37 | fun cancelAll() { 38 | for (job in jobs.values) 39 | job.cancel() 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/counter_settings/CounterSettingsScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package net.multun.gamecounter.ui.counter_settings 4 | 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.IntrinsicSize 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.foundation.text.KeyboardOptions 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.filled.Add 18 | import androidx.compose.material.icons.filled.Delete 19 | import androidx.compose.material.icons.filled.Edit 20 | import androidx.compose.material.icons.filled.MoveDown 21 | import androidx.compose.material.icons.filled.MoveUp 22 | import androidx.compose.material3.AlertDialog 23 | import androidx.compose.material3.Card 24 | import androidx.compose.material3.ExperimentalMaterial3Api 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.IconButton 27 | import androidx.compose.material3.OutlinedTextField 28 | import androidx.compose.material3.Scaffold 29 | import androidx.compose.material3.Text 30 | import androidx.compose.material3.TextButton 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.runtime.mutableStateOf 34 | import androidx.compose.runtime.remember 35 | import androidx.compose.runtime.setValue 36 | import androidx.compose.ui.Alignment 37 | import androidx.compose.ui.Modifier 38 | import androidx.compose.ui.res.stringResource 39 | import androidx.compose.ui.text.input.KeyboardType 40 | import androidx.compose.ui.text.style.TextOverflow 41 | import androidx.compose.ui.unit.dp 42 | import androidx.compose.ui.window.Dialog 43 | import androidx.navigation.NavController 44 | import kotlinx.collections.immutable.ImmutableList 45 | import net.multun.gamecounter.PaletteColor 46 | import net.multun.gamecounter.R 47 | import net.multun.gamecounter.store.CounterId 48 | import net.multun.gamecounter.ui.GameCounterTopBar 49 | import net.multun.gamecounter.ui.board.GameIconButton 50 | import net.multun.gamecounter.ui.theme.Typography 51 | 52 | data class CounterSettingsUIState( 53 | val id: CounterId, 54 | val name: String, 55 | val defaultValue: Int, 56 | ) 57 | 58 | sealed class CounterSettingsDialog 59 | data object AddDialog : CounterSettingsDialog() 60 | data class EditDialog(val counter: CounterSettingsUIState) : CounterSettingsDialog() 61 | data class ConfirmDeleteDialog(val counter: CounterSettingsUIState) : CounterSettingsDialog() 62 | 63 | @Composable 64 | fun CounterSettingsScreen( 65 | counters: ImmutableList, 66 | viewModel: GameCounterSettingsViewModel, 67 | navController: NavController, 68 | ) { 69 | var dialog by remember { mutableStateOf(null) } 70 | Scaffold( 71 | topBar = { 72 | GameCounterTopBar(stringResource(R.string.counters_settings), navController) 73 | }, 74 | floatingActionButton = { 75 | GameIconButton( 76 | PaletteColor.Indigo.color, 77 | onClick = remember { { dialog = AddDialog } } 78 | ) { 79 | Icon(Icons.Filled.Add, stringResource(R.string.new_counter)) 80 | } 81 | } 82 | ) { contentPadding -> 83 | CounterSettingsList( 84 | counters = counters, 85 | onMoveUp = remember { { viewModel.moveCounterUp(it)} }, 86 | onMoveDown = remember { { viewModel.moveCounterDown(it) } }, 87 | onDialog = remember { { dialog = it } }, 88 | modifier = Modifier.padding(contentPadding), 89 | ) 90 | } 91 | 92 | val curDialog = dialog 93 | if (curDialog != null) { 94 | CounterSettingsDialog( 95 | curDialog, 96 | onDelete = remember { { viewModel.deleteCounter(it) } }, 97 | onAddCounter = remember { { name, defaultVal -> viewModel.addCounter(name, defaultVal) } }, 98 | onUpdateCounter = remember { { id, name, defaultVal -> viewModel.updateCounter(id, name, defaultVal) } }, 99 | onClearDialog = remember { { dialog = null } }, 100 | ) 101 | } 102 | } 103 | 104 | @Composable 105 | fun CounterSettingsList( 106 | counters: ImmutableList, 107 | onMoveUp: (CounterId) -> Unit, 108 | onMoveDown: (CounterId) -> Unit, 109 | onDialog: (CounterSettingsDialog) -> Unit, 110 | modifier: Modifier = Modifier 111 | ) { 112 | LazyColumn( 113 | verticalArrangement = Arrangement.spacedBy(10.dp), 114 | modifier = modifier.padding(10.dp), 115 | ) { 116 | for (counterIndex in 0 until counters.size) { 117 | val counter = counters[counterIndex] 118 | val isFirst = counterIndex == 0 119 | val isLast = counterIndex == counters.size - 1 120 | item(counter.id.value) { 121 | CounterSettingsLine( 122 | counter.name, 123 | isFirst, 124 | isLast, 125 | onEdit = { onDialog(EditDialog(counter)) }, 126 | onMoveUp = remember { { onMoveUp(counter.id) } }, 127 | onMoveDown = remember { { onMoveDown(counter.id) } }, 128 | onDelete = { onDialog(ConfirmDeleteDialog(counter)) }, 129 | modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), 130 | ) 131 | } 132 | } 133 | } 134 | } 135 | 136 | @Composable 137 | fun CounterSettingsDialog( 138 | dialog: CounterSettingsDialog, 139 | onDelete: (CounterId) -> Unit, 140 | onAddCounter: (String, Int) -> Unit, 141 | onUpdateCounter: (CounterId, String, Int) -> Unit, 142 | onClearDialog: () -> Unit, 143 | ) { 144 | when (dialog) { 145 | AddDialog -> CounterChangeDialog( 146 | title = stringResource(R.string.new_counter), 147 | action = stringResource(R.string.add), 148 | onDismissRequest = onClearDialog, 149 | onCounterAdded = remember { { name, defaultValue -> 150 | onAddCounter(name, defaultValue) 151 | onClearDialog() 152 | } } 153 | ) 154 | is EditDialog -> CounterChangeDialog( 155 | title = stringResource(R.string.edit_a_counter), 156 | action = stringResource(R.string.save), 157 | initialName = dialog.counter.name, 158 | initialDefaultValue = dialog.counter.defaultValue, 159 | onDismissRequest = onClearDialog, 160 | onCounterAdded = remember { { name, defaultValue -> 161 | val counterId = dialog.counter.id 162 | onUpdateCounter(counterId, name, defaultValue) 163 | onClearDialog() 164 | } } 165 | ) 166 | is ConfirmDeleteDialog -> AlertDialog( 167 | icon = { Icon(Icons.Filled.Delete, contentDescription = null) }, 168 | text = { 169 | Text( 170 | stringResource( 171 | R.string.confirm_delete_counter, 172 | dialog.counter.name 173 | ) 174 | ) 175 | }, 176 | onDismissRequest = onClearDialog, 177 | confirmButton = { 178 | TextButton(onClick = remember { { 179 | onDelete(dialog.counter.id) 180 | onClearDialog() 181 | } }) { 182 | Text(stringResource(R.string.confirm)) 183 | } 184 | }, 185 | dismissButton = { 186 | TextButton(onClick = onClearDialog) { 187 | Text(stringResource(R.string.cancel)) 188 | } 189 | }) 190 | } 191 | } 192 | 193 | 194 | @Composable 195 | fun CounterChangeDialog( 196 | title: String, 197 | action: String, 198 | onDismissRequest: () -> Unit, 199 | onCounterAdded: (String, Int) -> Unit, 200 | initialName: String = "", 201 | initialDefaultValue: Int? = null, 202 | ) { 203 | var counterName by remember { mutableStateOf(initialName) } 204 | var counterDefaultValue by remember { mutableStateOf(initialDefaultValue?.toString() ?: "") } 205 | val parsedDefaultValue = counterDefaultValue.toIntOrNull() 206 | 207 | Dialog(onDismissRequest = onDismissRequest) { 208 | Card(shape = RoundedCornerShape(16.dp), modifier = Modifier.width(IntrinsicSize.Min)) { 209 | Column( 210 | modifier = Modifier.padding(16.dp), 211 | verticalArrangement = Arrangement.spacedBy(10.dp), 212 | horizontalAlignment = Alignment.Start, 213 | ) { 214 | Text(title, style = Typography.bodyLarge) 215 | 216 | val nameError = counterName.isBlank() 217 | val defaultValueError = parsedDefaultValue == null 218 | 219 | OutlinedTextField( 220 | value = counterName, 221 | isError = nameError, 222 | onValueChange = { counterName = it }, 223 | label = { Text(stringResource(R.string.name)) }, 224 | singleLine = true, 225 | ) 226 | 227 | OutlinedTextField( 228 | value = counterDefaultValue, 229 | onValueChange = { counterDefaultValue = it }, 230 | label = { Text(stringResource(R.string.counter_default_value)) }, 231 | isError = defaultValueError, 232 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), 233 | singleLine = true, 234 | ) 235 | 236 | Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { 237 | TextButton( 238 | onClick = { onDismissRequest() }, 239 | modifier = Modifier.padding(8.dp), 240 | ) { 241 | Text(stringResource(R.string.cancel)) 242 | } 243 | TextButton( 244 | enabled = !(nameError || defaultValueError), 245 | onClick = { onCounterAdded(counterName.trim(), parsedDefaultValue!!) }, 246 | modifier = Modifier.padding(8.dp), 247 | ) { 248 | Text(action) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | 256 | @Composable 257 | fun CounterSettingsLine( 258 | name: String, 259 | isFirst: Boolean, 260 | isLast: Boolean, 261 | onEdit: () -> Unit, 262 | onMoveUp: () -> Unit, 263 | onMoveDown: () -> Unit, 264 | onDelete: () -> Unit, 265 | modifier: Modifier = Modifier, 266 | ) { 267 | Card(modifier = modifier 268 | .height(50.dp) 269 | .fillMaxWidth()) { 270 | Row( 271 | modifier = Modifier.padding(start = 20.dp, end = 10.dp), 272 | verticalAlignment = Alignment.CenterVertically 273 | ) { 274 | Text( 275 | name, 276 | style = Typography.bodyLarge, 277 | maxLines = 1, 278 | overflow = TextOverflow.Ellipsis, 279 | modifier = Modifier.weight(1f), 280 | ) 281 | 282 | Row(horizontalArrangement = Arrangement.End) { 283 | IconButton(onClick = onEdit) { 284 | Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit_a_counter)) 285 | } 286 | IconButton(enabled = !isFirst, onClick = onMoveUp) { 287 | Icon(Icons.Filled.MoveUp, contentDescription = stringResource(R.string.move_up)) 288 | } 289 | IconButton(enabled = !isLast, onClick = onMoveDown) { 290 | Icon(Icons.Filled.MoveDown, contentDescription = stringResource(R.string.move_down)) 291 | } 292 | IconButton(enabled = !(isFirst && isLast), onClick = onDelete) { 293 | Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete_counter)) 294 | } 295 | } 296 | } 297 | } 298 | } 299 | 300 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/counter_settings/GameCounterSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.counter_settings 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.collections.immutable.persistentListOf 7 | import kotlinx.collections.immutable.toImmutableList 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.stateIn 11 | import kotlinx.coroutines.launch 12 | import net.multun.gamecounter.store.CounterId 13 | import net.multun.gamecounter.store.GameRepository 14 | import javax.inject.Inject 15 | 16 | 17 | // the counter settings for the currently running game 18 | @HiltViewModel 19 | class GameCounterSettingsViewModel @Inject constructor(private val repository: GameRepository) : ViewModel() { 20 | val settingsUIState = repository.appState.map { appState -> 21 | appState.counters.map { 22 | CounterSettingsUIState(it.id, it.name, it.defaultValue) 23 | }.toImmutableList() 24 | }.stateIn( 25 | scope = viewModelScope, 26 | started = SharingStarted.WhileSubscribed(5_000), 27 | initialValue = persistentListOf(), 28 | ) 29 | 30 | fun addCounter(counterName: String, defaultValue: Int) { 31 | viewModelScope.launch { 32 | repository.addCounter(defaultValue, counterName) 33 | } 34 | } 35 | 36 | fun deleteCounter(counterId: CounterId) { 37 | viewModelScope.launch { 38 | repository.removeCounter(counterId) 39 | } 40 | } 41 | 42 | fun moveCounterUp(counterId: CounterId) { 43 | viewModelScope.launch { 44 | repository.moveCounter(counterId, -1) 45 | } 46 | } 47 | 48 | fun moveCounterDown(counterId: CounterId) { 49 | viewModelScope.launch { 50 | repository.moveCounter(counterId, 1) 51 | } 52 | } 53 | 54 | fun updateCounter(counterId: CounterId, name: String, defaultVal: Int) { 55 | viewModelScope.launch { 56 | repository.updateCounter(counterId, name, defaultVal) 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/main_menu/MainMenu.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.main_menu 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.IntrinsicSize 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.Info 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.IconButton 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.geometry.Offset 25 | import androidx.compose.ui.graphics.Color 26 | import androidx.compose.ui.graphics.drawscope.scale 27 | import androidx.compose.ui.res.colorResource 28 | import androidx.compose.ui.res.painterResource 29 | import androidx.compose.ui.res.stringResource 30 | import androidx.compose.ui.text.style.TextAlign 31 | import androidx.compose.ui.unit.dp 32 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 33 | import androidx.navigation.NavController 34 | import net.multun.gamecounter.PaletteColor 35 | import net.multun.gamecounter.R 36 | import net.multun.gamecounter.Screens 37 | import net.multun.gamecounter.ui.board.GameButton 38 | 39 | 40 | @Composable 41 | fun MainMenuItem(name: String, baseColor: Color, onClick: () -> Unit) { 42 | GameButton(baseColor, onClick = onClick) { 43 | Text(name, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) 44 | } 45 | } 46 | 47 | @Composable 48 | fun AppLogo(modifier: Modifier = Modifier) { 49 | Surface( 50 | shape = RoundedCornerShape(16.dp), 51 | shadowElevation = 4.dp, 52 | modifier = modifier, 53 | ) { 54 | val logo = painterResource(R.drawable.ic_launcher_foreground) 55 | val bgColor = colorResource(R.color.ic_launcher_background) 56 | Canvas(modifier = Modifier.fillMaxSize()) { 57 | scale(1.5f, pivot = Offset(size.width / 2, size.height / 2)) { 58 | with(logo){ 59 | drawRect(color = bgColor) 60 | draw(size = this@Canvas.size) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | fun MainMenu(viewModel: MainMenuViewModel, navController: NavController, modifier: Modifier = Modifier) { 69 | val state = viewModel.uiState.collectAsStateWithLifecycle() 70 | val currentState = state.value ?: return 71 | 72 | Scaffold(modifier = modifier) { innerPadding -> 73 | Box(contentAlignment = Alignment.Center, modifier = Modifier 74 | .padding(innerPadding) 75 | .fillMaxSize()) { 76 | 77 | Column(verticalArrangement = Arrangement.spacedBy(50.dp), horizontalAlignment = Alignment.CenterHorizontally) { 78 | AppLogo(Modifier.size(150.dp)) 79 | 80 | Column( 81 | horizontalAlignment = Alignment.Start, 82 | modifier = Modifier.width(IntrinsicSize.Max), 83 | verticalArrangement = Arrangement.spacedBy(16.dp) 84 | ) { 85 | if (currentState.canContinue) { 86 | MainMenuItem(stringResource(R.string.continue_), PaletteColor.Red.color) { 87 | navController.navigate(Screens.Board.route) 88 | } 89 | } 90 | 91 | MainMenuItem(stringResource(R.string.new_game), PaletteColor.Purple.color) { 92 | navController.navigate(Screens.NewGameMenu.route) 93 | } 94 | } 95 | } 96 | 97 | IconButton( 98 | onClick = { navController.navigate(Screens.About.route) }, 99 | modifier = Modifier.padding(10.dp).align(Alignment.BottomEnd) 100 | ) { 101 | Icon(Icons.Filled.Info, stringResource(R.string.about)) 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/main_menu/MainMenuViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.main_menu 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.map 8 | import kotlinx.coroutines.flow.stateIn 9 | import net.multun.gamecounter.store.GameRepository 10 | import javax.inject.Inject 11 | 12 | 13 | data class MainMenuUI(val canContinue: Boolean) 14 | 15 | 16 | @HiltViewModel 17 | class MainMenuViewModel @Inject constructor(private val repository: GameRepository) : ViewModel() { 18 | val uiState = repository.appState.map { appState -> 19 | MainMenuUI( 20 | canContinue = appState.isPlayable, 21 | ) 22 | }.stateIn( 23 | scope = viewModelScope, 24 | started = SharingStarted.WhileSubscribed(5_000), 25 | initialValue = null, 26 | ) 27 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/new_game_menu/NewGameMenu.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.new_game_menu 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Add 14 | import androidx.compose.material.icons.filled.Done 15 | import androidx.compose.material.icons.filled.Exposure 16 | import androidx.compose.material.icons.filled.ManageAccounts 17 | import androidx.compose.material3.BottomAppBar 18 | import androidx.compose.material3.BottomAppBarDefaults 19 | import androidx.compose.material3.Button 20 | import androidx.compose.material3.ButtonDefaults 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.ExtendedFloatingActionButton 23 | import androidx.compose.material3.FloatingActionButtonDefaults 24 | import androidx.compose.material3.Icon 25 | import androidx.compose.material3.PrimaryTabRow 26 | import androidx.compose.material3.Scaffold 27 | import androidx.compose.material3.Tab 28 | import androidx.compose.material3.Text 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.LaunchedEffect 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableIntStateOf 33 | import androidx.compose.runtime.mutableStateOf 34 | import androidx.compose.runtime.remember 35 | import androidx.compose.runtime.saveable.rememberSaveable 36 | import androidx.compose.runtime.setValue 37 | import androidx.compose.runtime.snapshotFlow 38 | import androidx.compose.ui.Alignment 39 | import androidx.compose.ui.Modifier 40 | import androidx.compose.ui.graphics.vector.ImageVector 41 | import androidx.compose.ui.res.stringResource 42 | import androidx.compose.ui.text.style.TextOverflow 43 | import androidx.compose.ui.unit.dp 44 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 45 | import androidx.navigation.NavController 46 | import androidx.navigation.compose.NavHost 47 | import androidx.navigation.compose.composable 48 | import androidx.navigation.compose.rememberNavController 49 | import com.sd.lib.compose.wheel_picker.FHorizontalWheelPicker 50 | import com.sd.lib.compose.wheel_picker.rememberFWheelPickerState 51 | import net.multun.gamecounter.R 52 | import net.multun.gamecounter.Screens 53 | import net.multun.gamecounter.ui.GameCounterTopBar 54 | import net.multun.gamecounter.ui.counter_settings.AddDialog 55 | import net.multun.gamecounter.ui.counter_settings.CounterSettingsDialog 56 | import net.multun.gamecounter.ui.counter_settings.CounterSettingsList 57 | 58 | 59 | enum class NewGameTabs( 60 | val route: String, 61 | val labelResource: Int, 62 | val icon: ImageVector, 63 | ) { 64 | PLAYERS("players", R.string.players, Icons.Default.ManageAccounts), 65 | COUNTERS("counters", R.string.counters, Icons.Default.Exposure), 66 | } 67 | 68 | 69 | @OptIn(ExperimentalMaterial3Api::class) 70 | @Composable 71 | fun NewGameMenu(viewModel: NewGameViewModel, navController: NavController, modifier: Modifier = Modifier) { 72 | val tabNavController = rememberNavController() 73 | val startDestination = NewGameTabs.PLAYERS 74 | var selectedDestination by rememberSaveable { mutableIntStateOf(startDestination.ordinal) } 75 | var dialog by remember { mutableStateOf(null) } 76 | 77 | Scaffold( 78 | modifier = modifier, 79 | bottomBar = { 80 | BottomAppBar( 81 | actions = { 82 | AnimatedVisibility(selectedDestination == NewGameTabs.COUNTERS.ordinal) { 83 | Button( 84 | onClick = { dialog = AddDialog }, 85 | colors = ButtonDefaults.textButtonColors() 86 | ) { 87 | Icon(Icons.Filled.Add, null) 88 | Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) 89 | Text(stringResource(R.string.add_counter)) 90 | } 91 | } 92 | }, 93 | floatingActionButton = { 94 | ExtendedFloatingActionButton( 95 | onClick = { 96 | viewModel.startGame() 97 | navController.navigate(Screens.Board.route) 98 | }, 99 | icon = { Icon(Icons.Filled.Done, null) }, 100 | text = { Text(text = stringResource(R.string.start_game)) }, 101 | containerColor = BottomAppBarDefaults.bottomAppBarFabColor, 102 | elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() 103 | ) 104 | }, 105 | ) 106 | }, 107 | topBar = { 108 | GameCounterTopBar(stringResource(R.string.new_game), navController) 109 | }, 110 | ) { contentPadding -> 111 | Column(modifier = Modifier.padding(contentPadding)) { 112 | PrimaryTabRow(selectedTabIndex = selectedDestination) { 113 | NewGameTabs.entries.forEachIndexed { index, destination -> 114 | Tab( 115 | selected = selectedDestination == index, 116 | onClick = { 117 | tabNavController.navigate(route = destination.route) 118 | selectedDestination = index 119 | }, 120 | text = { 121 | Text( 122 | text = stringResource(destination.labelResource), 123 | maxLines = 2, 124 | overflow = TextOverflow.Ellipsis 125 | ) 126 | } 127 | ) 128 | } 129 | } 130 | NavHost( 131 | tabNavController, 132 | startDestination = startDestination.route 133 | ) { 134 | NewGameTabs.entries.forEach { destination -> 135 | composable(destination.route) { 136 | when (destination) { 137 | NewGameTabs.PLAYERS -> PlayerSetupScreen(viewModel) 138 | NewGameTabs.COUNTERS -> { 139 | val counters by viewModel.counterSettingsUI.collectAsStateWithLifecycle() 140 | CounterSettingsList( 141 | counters = counters, 142 | onMoveUp = remember { { viewModel.moveCounterUp(it)} }, 143 | onMoveDown = remember { { viewModel.moveCounterDown(it) } }, 144 | onDialog = remember { { dialog = it } }, 145 | modifier = Modifier.fillMaxSize(), 146 | ) 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | val curDialog = dialog 156 | if (curDialog != null) { 157 | CounterSettingsDialog( 158 | curDialog, 159 | onDelete = remember { { viewModel.deleteCounter(it) } }, 160 | onAddCounter = remember { { name, defaultVal -> viewModel.addCounter(name, defaultVal) } }, 161 | onUpdateCounter = remember { { id, name, defaultVal -> viewModel.updateCounter(id, name, defaultVal) } }, 162 | onClearDialog = remember { { dialog = null } }, 163 | ) 164 | } 165 | } 166 | 167 | 168 | 169 | @Composable 170 | fun PlayerSetupScreen( 171 | viewModel: NewGameViewModel, 172 | ) { 173 | val state = viewModel.uiState.collectAsStateWithLifecycle() 174 | val currentState = state.value ?: return 175 | 176 | // initialize the player count to either 2 or whatever was saved before 177 | val playerCount = rememberFWheelPickerState(remember { 178 | if (currentState.playerCount == 0) 179 | return@remember 1 // 2 players by default 180 | currentState.playerCount - 1 181 | }) 182 | 183 | // when the index is changed, save to disk 184 | LaunchedEffect(playerCount) { 185 | snapshotFlow { playerCount.currentIndex } 186 | .collect { 187 | viewModel.setPlayerCount(playerCount.currentIndex + 1) 188 | } 189 | } 190 | 191 | Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { 192 | Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { 193 | Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { 194 | FHorizontalWheelPicker( 195 | modifier = Modifier.height(48.dp), 196 | state = playerCount, 197 | count = 100, 198 | ) { index -> 199 | Text((index + 1).toString()) 200 | } 201 | } 202 | } 203 | } 204 | } 205 | 206 | -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/new_game_menu/NewGameViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.new_game_menu 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.collections.immutable.persistentListOf 7 | import kotlinx.collections.immutable.toImmutableList 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.stateIn 12 | import kotlinx.coroutines.launch 13 | import net.multun.gamecounter.proto.counter 14 | import net.multun.gamecounter.store.CounterId 15 | import net.multun.gamecounter.store.GameRepository 16 | import net.multun.gamecounter.store.NewGameRepository 17 | import net.multun.gamecounter.store.makeDefaultCounter 18 | import net.multun.gamecounter.ui.counter_settings.CounterSettingsUIState 19 | import javax.inject.Inject 20 | 21 | 22 | data class NewGameUI( 23 | val playerCount: Int, 24 | val needsCounters: Boolean, 25 | ) 26 | 27 | @HiltViewModel 28 | class NewGameViewModel @Inject constructor( 29 | private val currentGame: GameRepository, 30 | private val newGame: NewGameRepository, 31 | ) : ViewModel() { 32 | val uiState = newGame.appState.map { appState -> 33 | NewGameUI( 34 | playerCount = appState.playerCount, 35 | needsCounters = appState.counters.isEmpty(), 36 | ) 37 | }.stateIn( 38 | scope = viewModelScope, 39 | started = SharingStarted.WhileSubscribed(5_000), 40 | initialValue = null, 41 | ) 42 | 43 | fun setPlayerCount(playerCount: Int) { 44 | viewModelScope.launch { 45 | newGame.setPlayerCount(playerCount) 46 | } 47 | } 48 | 49 | fun startGame() { 50 | viewModelScope.launch { 51 | val newGameSettings = newGame.appState.first() 52 | val counters = if (newGameSettings.counters.isEmpty()) 53 | listOf(makeDefaultCounter()) 54 | else 55 | newGameSettings.counters.map { 56 | counter { 57 | this.id = it.id.value 58 | this.name = it.name 59 | this.defaultValue = it.defaultValue 60 | } 61 | } 62 | currentGame.startGame( 63 | playerCount = newGameSettings.playerCount, 64 | counters = counters, 65 | ) 66 | } 67 | } 68 | 69 | val counterSettingsUI = newGame.appState.map { appState -> 70 | appState.counters.map { 71 | CounterSettingsUIState(it.id, it.name, it.defaultValue) 72 | }.toImmutableList() 73 | }.stateIn( 74 | scope = viewModelScope, 75 | started = SharingStarted.WhileSubscribed(5_000), 76 | initialValue = persistentListOf(), 77 | ) 78 | 79 | fun addCounter(counterName: String, defaultValue: Int) { 80 | viewModelScope.launch { 81 | newGame.addCounter(defaultValue, counterName) 82 | } 83 | } 84 | 85 | fun deleteCounter(counterId: CounterId) { 86 | viewModelScope.launch { 87 | newGame.removeCounter(counterId) 88 | } 89 | } 90 | 91 | fun moveCounterUp(counterId: CounterId) { 92 | viewModelScope.launch { 93 | newGame.moveCounter(counterId, -1) 94 | } 95 | } 96 | 97 | fun moveCounterDown(counterId: CounterId) { 98 | viewModelScope.launch { 99 | newGame.moveCounter(counterId, 1) 100 | } 101 | } 102 | 103 | fun updateCounter(counterId: CounterId, name: String, defaultVal: Int) { 104 | viewModelScope.launch { 105 | newGame.updateCounter(counterId, name, defaultVal) 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | private val DarkColorScheme = darkColorScheme( 14 | primary = Purple80, 15 | secondary = PurpleGrey80, 16 | tertiary = Pink80 17 | ) 18 | 19 | private val LightColorScheme = lightColorScheme( 20 | primary = Purple40, 21 | secondary = PurpleGrey40, 22 | tertiary = Pink40 23 | 24 | /* Other default colors to override 25 | background = Color(0xFFFFFBFE), 26 | surface = Color(0xFFFFFBFE), 27 | onPrimary = Color.White, 28 | onSecondary = Color.White, 29 | onTertiary = Color.White, 30 | onBackground = Color(0xFF1C1B1F), 31 | onSurface = Color(0xFF1C1B1F), 32 | */ 33 | ) 34 | 35 | @Composable 36 | fun GamecounterTheme( 37 | darkTheme: Boolean = isSystemInDarkTheme(), 38 | // Dynamic color is available on Android 12+ 39 | dynamicColor: Boolean = true, 40 | content: @Composable () -> Unit 41 | ) { 42 | val colorScheme = when { 43 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 44 | val context = LocalContext.current 45 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 46 | } 47 | 48 | darkTheme -> DarkColorScheme 49 | else -> LightColorScheme 50 | } 51 | 52 | MaterialTheme( 53 | colorScheme = colorScheme, 54 | typography = Typography, 55 | content = content 56 | ) 57 | } -------------------------------------------------------------------------------- /app/src/main/java/net/multun/gamecounter/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | 10 | // Set of Material typography styles to start with 11 | val Typography = Typography( 12 | bodyLarge = TextStyle( 13 | fontFamily = FontFamily.Default, 14 | fontWeight = FontWeight.Normal, 15 | fontSize = 16.sp, 16 | lineHeight = 24.sp, 17 | letterSpacing = 0.5.sp 18 | ) 19 | /* Other default text styles to override 20 | titleLarge = TextStyle( 21 | fontFamily = FontFamily.Default, 22 | fontWeight = FontWeight.Normal, 23 | fontSize = 22.sp, 24 | lineHeight = 28.sp, 25 | letterSpacing = 0.sp 26 | ), 27 | labelSmall = TextStyle( 28 | fontFamily = FontFamily.Default, 29 | fontWeight = FontWeight.Medium, 30 | fontSize = 11.sp, 31 | lineHeight = 16.sp, 32 | letterSpacing = 0.5.sp 33 | ) 34 | */ 35 | ) -------------------------------------------------------------------------------- /app/src/main/proto/game.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "net.multun.gamecounter.proto"; 4 | option java_outer_classname = "ProtoGame"; 5 | 6 | message Player { 7 | int32 id = 1; 8 | uint64 color = 2; 9 | int32 selectedCounter = 3; 10 | map counters = 4; 11 | string name = 5; 12 | } 13 | 14 | message Counter { 15 | int32 id = 1; 16 | string name = 2; 17 | int32 defaultValue = 3; 18 | } 19 | 20 | message Game { 21 | repeated Player player = 1; 22 | repeated Counter counter = 2; 23 | int32 selectedDice = 4; 24 | bool alwaysUprightMode = 5; 25 | } -------------------------------------------------------------------------------- /app/src/main/proto/new_game.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "game.proto"; 4 | 5 | option java_package = "net.multun.gamecounter.proto"; 6 | option java_outer_classname = "ProtoNewGame"; 7 | 8 | message NewGame { 9 | int32 playerCount = 1; 10 | repeated Counter counter = 2; 11 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 17 | 24 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/icon-foreground.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 43 | 50 | 51 | 53 | 61 | 67 | 72 | 78 | 84 | 90 | 91 | 99 | 105 | 110 | 116 | 122 | 128 | 129 | 137 | 143 | 148 | 154 | 160 | 166 | 167 | 168 | 172 | 182 | 190 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Game Counter 4 | Commencer la partie 5 | Nombre de joueurs 6 | Paramètres des compteurs 7 | Nouvelle partie 8 | Continuer 9 | Nouveau compteur 10 | Ajouter 11 | Sauvegarder 12 | Voulez-vous vraiment supprimer %1$s ? 13 | Confirmer 14 | Annuler 15 | Nom 16 | Valeur initiale 17 | Page précédente 18 | Paramètres du joueur 19 | Augmenter la valeur 20 | Diminuer la valeur 21 | Supprimer 22 | Couleur 23 | Nouveau joueur 24 | Réinitialiser la partie 25 | Retour au menu principal 26 | Fermer le menu 27 | Êtes-vous sûr·e de vouloir réinitialiser les compteurs ? 28 | Retour au jeu 29 | Ordre des joueurs 30 | Lancer un dé 31 | Paramètres 32 | Supprimer le compteur 33 | Déplacer vers le bas 34 | Déplacer vers le haut 35 | Paramètres du compteur 36 | Retour au jeu 37 | Nom du joueur 38 | Mettre à jour le nom du joueur 39 | À propos 40 | Gratuit pour tous, fait avec amour 41 | Code source disponible sous la license GPLv3 42 | Paramètres des joueurs 43 | Supprimer le joueur 44 | Tuiles toujours à l\'endroit 45 | Déplacer à gauche 46 | Déplacer à droite 47 | Voulez-vous vraiment supprimer ce joueur? 48 | Joueurs 49 | Compteurs 50 | Ajouter un compteur 51 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/test/java/net/multun/gamecounter/TestBoardLayout.kt: -------------------------------------------------------------------------------- 1 | package net.multun.gamecounter 2 | 3 | import net.multun.gamecounter.ui.board.makeLayoutOrder 4 | import net.multun.gamecounter.ui.board.slotToLayoutOrder 5 | import org.junit.Test 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class TestBoardLayout { 13 | @Test 14 | fun circularLayoutOrder() { 15 | assert(makeLayoutOrder(1) == slotToLayoutOrder(listOf(0))) 16 | assert(makeLayoutOrder(2) == slotToLayoutOrder(listOf(0, 1))) 17 | assert(makeLayoutOrder(3) == slotToLayoutOrder(listOf(0, 2, 1))) 18 | assert(makeLayoutOrder(4) == slotToLayoutOrder(listOf(0, 2, 3, 1))) 19 | assert(makeLayoutOrder(5) == slotToLayoutOrder(listOf(0, 2, 4, 3, 1))) 20 | } 21 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | alias(libs.plugins.ksp) apply false 4 | alias(libs.plugins.jetbrains.kotlin.android) apply false 5 | alias(libs.plugins.compose.compiler) apply false 6 | alias(libs.plugins.protobuf) apply false 7 | alias(libs.plugins.hilt) apply false 8 | alias(libs.plugins.android.application) apply false 9 | alias(libs.plugins.aboutlibraries) apply false 10 | } -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file("gamecounter-fastlane.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one 2 | package_name("net.multun.gamecounter.playstore") # e.g. com.krausefx.app 3 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | desc "Deploy a new version to the Google Play" 20 | lane :deploy do 21 | gradle(task: "clean bundlePlaystoreRelease") 22 | upload_to_play_store 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Android 17 | 18 | ### android deploy 19 | 20 | ```sh 21 | [bundle exec] fastlane android deploy 22 | ``` 23 | 24 | Deploy a new version to the Google Play 25 | 26 | ---- 27 | 28 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 29 | 30 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 31 | 32 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 33 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/1.txt: -------------------------------------------------------------------------------- 1 | Fully usable initial release: 2 | - keep track of multiple counters per player 3 | - customizable counter name and initial value 4 | - long press plus or minus for quick updates 5 | - players can change card colors 6 | - roll dices of any size, or pick player order -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/2.txt: -------------------------------------------------------------------------------- 1 | - select player counters using a wheel instead of arrows 2 | - support player names 3 | - increase large update step to 10 4 | - don't show the counter name when it's the only counter 5 | - improve the splashscreen, avoiding a white flash on dark mode 6 | - slightly improve the color palette for better contrast 7 | - fix various minor rendering bugs -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/3.txt: -------------------------------------------------------------------------------- 1 | - support large updates by clicking the counter value 2 | - add an about page -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/4.txt: -------------------------------------------------------------------------------- 1 | the screen is now kept on during games -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/7.txt: -------------------------------------------------------------------------------- 1 | - add an always upright tiles option 2 | - revamp the new game menu 3 | - consolidate most player settings in a separate mode -------------------------------------------------------------------------------- /fastlane/metadata/en-US/changelogs/8.txt: -------------------------------------------------------------------------------- 1 | fix an issue with upright layout where there could be an empty row of tiles -------------------------------------------------------------------------------- /fastlane/metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | An app for counting points at board, card, or role playing games: 2 | - keep track of multiple counters per player 3 | - customizable counter name and initial value 4 | - long press plus or minus for quick updates 5 | - players can change card colors 6 | - roll dices of any size, or pick player order 7 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/phoneScreenshots/board_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/phoneScreenshots/board_dark.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/phoneScreenshots/board_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/phoneScreenshots/board_light.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/phoneScreenshots/counter_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/phoneScreenshots/counter_settings.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/images/phoneScreenshots/random.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/fastlane/metadata/en-US/images/phoneScreenshots/random.png -------------------------------------------------------------------------------- /fastlane/metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | A customizable game point counter -------------------------------------------------------------------------------- /fastlane/metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Game Counter -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/changelogs/2.txt: -------------------------------------------------------------------------------- 1 | - sélection des compteurs des joueurs avec une roue plutôt que des flèches 2 | - support des noms de joueurs 3 | - incrément rapide de 10 en 10 plutôt que de 5 en 5 4 | - le nom du compteur est caché quand il n'y en a qu'un seul 5 | - meilleur écran de démarrage, pas de flash blanc en mode sombre 6 | - palette de couleur modifiée, meilleur contraste 7 | - corrections de problèmes de rendu mineur -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/changelogs/3.txt: -------------------------------------------------------------------------------- 1 | - support de grosses mises à jour en cliquant sur la valeur du compteur 2 | - ajout d'une page "À propos" -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/changelogs/4.txt: -------------------------------------------------------------------------------- 1 | l'écran est maintenant maintenu allumé pendant les parties -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/changelogs/7.txt: -------------------------------------------------------------------------------- 1 | - ajout d'un mode tuiles toujours à l'endroit 2 | - refonte du menu nouvelle partie 3 | - la plupart des paramètres par joueurs ont été rassemblés dans un mode séparé -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/changelogs/8.txt: -------------------------------------------------------------------------------- 1 | Correction d'un problème avec l'agencement à l'endroit où une ligne de tuiles pouvait être vide -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/full_description.txt: -------------------------------------------------------------------------------- 1 | Un compteur de points pour jeux de société et jeux de rôle : 2 | - suivi de plusieur compteurs par joueur 3 | - compteurs configurables 4 | - changement rapide des compteurs avec un appui long 5 | - les joueurs peuvent changer la couleur de leur carte 6 | - lancé de dé et choix de l'ordre des joueurs intégré 7 | -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/short_description.txt: -------------------------------------------------------------------------------- 1 | Un compteur de points pour jeux de rôle et de société -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | 8 | # Specifies the JVM arguments used for the daemon process. 9 | # The setting is particularly useful for tweaking memory settings. 10 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 11 | # When configured, Gradle will run in incubating parallel mode. 12 | # This option should only be used with decoupled projects. For more details, visit 13 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 14 | org.gradle.parallel=true 15 | # AndroidX package structure to make it clearer which packages are bundled with the 16 | # Android operating system, and which are packaged with your app's APK 17 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 18 | android.useAndroidX=true 19 | # Kotlin code style for this project: "official" or "obsolete": 20 | kotlin.code.style=official 21 | # Enables namespacing of each library's R class so that its R class includes only the 22 | # resources declared in the library itself and none from the library's dependencies, 23 | # thereby reducing the size of the R class for that library 24 | android.nonTransitiveRClass=true 25 | android.javaCompile.suppressSourceTargetDeprecationWarning=true 26 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.8.1" 3 | kotlin = "2.1.0" 4 | ksp = "2.1.0-1.0.29" 5 | coreKtx = "1.15.0" 6 | coreSplash = "1.0.1" 7 | datastore = "1.1.2" 8 | junit = "4.13.2" 9 | junitVersion = "1.2.1" 10 | espressoCore = "3.6.1" 11 | lifecycleRuntimeKtx = "2.8.7" 12 | activityCompose = "1.10.0" 13 | composeBom = "2025.02.00" 14 | collectionsImmutable = "0.3.7" 15 | lifecycleRuntimeComposeAndroid = "2.8.7" 16 | hilt = "2.55" 17 | constraintLayout = "1.1.0" 18 | protobufPlugin = "0.9.4" 19 | protobuf = "4.27.3" 20 | navigationCompose = "2.8.7" 21 | composeWheelPicker = "1.0.0-rc02" 22 | aboutLibraries = "11.6.3" 23 | 24 | [libraries] 25 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 26 | androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplash" } 27 | junit = { group = "junit", name = "junit", version.ref = "junit" } 28 | androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } 29 | protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" } 30 | protobuf-java = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" } 31 | protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } 32 | 33 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 34 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 35 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } 36 | androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } 37 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintLayout" } 38 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 39 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 40 | androidx-ui = { group = "androidx.compose.ui", name = "ui" } 41 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 42 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 43 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 44 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 45 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 46 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 47 | androidx-material3-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } 48 | kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collectionsImmutable" } 49 | androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" } 50 | hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } 51 | hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } 52 | androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } 53 | compose-wheel-picker = { group = "com.github.zj565061763", name = "compose-wheel-picker", version.ref = "composeWheelPicker"} 54 | 55 | aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } 56 | aboutlibraries-compose-core = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } 57 | aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } 58 | 59 | [plugins] 60 | android-application = { id = "com.android.application", version.ref = "agp" } 61 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 62 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 63 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 64 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 65 | protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } 66 | aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/multun/gamecounter/e3d69d61e486a7a3176ce2f7429dc319b49371bc/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Aug 01 15:40:52 CEST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | content { 5 | includeGroupByRegex("com\\.android.*") 6 | includeGroupByRegex("com\\.google.*") 7 | includeGroupByRegex("androidx.*") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | google() 18 | mavenCentral() 19 | maven { url = uri("https://jitpack.io") } 20 | } 21 | } 22 | 23 | rootProject.name = "gamecounter" 24 | include(":app") 25 | --------------------------------------------------------------------------------