├── .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 |
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 | [ ](https://f-droid.org/packages/net.multun.gamecounter.fdroid/)
15 |
16 | # Preview
17 |
18 |
19 |
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 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #F6F6F6
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Game Counter
3 | Start game
4 | Player count
5 | Counter settings
6 | New game
7 | Continue
8 | New counter
9 | Add
10 | Save
11 | Do you really want to delete counter %1$s?
12 | Confirm
13 | Cancel
14 | Name
15 | Default value
16 | Previous screen
17 | Player settings
18 | Increase counter
19 | Decrease counter
20 | Delete
21 | Color
22 | Add new player
23 | Reset game
24 | Leave to main menu
25 | Close menu
26 | Are you sure you want to reset all counters to their default value?
27 | Clear dice roll
28 | Player order
29 | Roll dice
30 | Settings
31 | Delete counter
32 | Move down
33 | Move up
34 | Edit counter
35 | Clear player names
36 | Player name
37 | Update player name
38 | About
39 | Free for all, made with Love
40 | Source code is available under the terms of the GPLv3 license.
41 | Player settings
42 | Delete player
43 | Always upright tiles
44 | Move left
45 | Move right
46 | Are you sure you want to remove this player?
47 | Players
48 | Counters
49 | Add counter
50 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------