androidApp
src
main
assets
kotlin
res
mipmap-anydpi-v26
mipmap-hdpi
mipmap-mdpi
mipmap-xhdpi
mipmap-xxhdpi
mipmap-xxxhdpi
values
composeApp
src
androidMain
commonMain
kotlin
com
derakkuma
atproto
ui
components
tabs
theme
util
iosMain
gradle
···
1
1
+
import com.android.build.api.dsl.ApplicationExtension
2
2
+
import org.gradle.api.file.RegularFileProperty
3
3
+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
4
4
+
import java.util.Properties
5
5
+
6
6
+
plugins {
7
7
+
alias(libs.plugins.android.application)
8
8
+
alias(libs.plugins.compose.multiplatform)
9
9
+
alias(libs.plugins.compose.compiler)
10
10
+
id("io.github.tarasovvp.kmp-secrets-plugin") version "1.3.0"
11
11
+
}
12
12
+
13
13
+
val localProperties =
14
14
+
Properties().apply {
15
15
+
val file = rootProject.file("local.properties")
16
16
+
if (file.isFile) {
17
17
+
file.inputStream().use(::load)
18
18
+
}
19
19
+
}
20
20
+
21
21
+
fun signingProperty(name: String): String? =
22
22
+
providers.gradleProperty(name).orNull
23
23
+
?: providers.environmentVariable(name).orNull
24
24
+
?: localProperties.getProperty(name)
25
25
+
26
26
+
val derakkumaKeystoreFile = rootProject.file("derakkuma.keystore")
27
27
+
val derakkumaKeystorePassword = signingProperty("KEYSTORE_PASSWORD")
28
28
+
val derakkumaKeyAlias = signingProperty("KEY_ALIAS") ?: "derakkuma"
29
29
+
val derakkumaKeyPassword = signingProperty("KEY_PASSWORD") ?: derakkumaKeystorePassword
30
30
+
val requestedReleaseBuild =
31
31
+
gradle.startParameter.taskNames.any { taskName ->
32
32
+
taskName.contains("Release", ignoreCase = true)
33
33
+
}
34
34
+
35
35
+
if (requestedReleaseBuild) {
36
36
+
require(derakkumaKeystoreFile.isFile) {
37
37
+
"Release builds must be signed with ${derakkumaKeystoreFile.path}"
38
38
+
}
39
39
+
require(!derakkumaKeystorePassword.isNullOrBlank()) {
40
40
+
"Set KEYSTORE_PASSWORD in local.properties, Gradle properties, or the environment"
41
41
+
}
42
42
+
require(!derakkumaKeyPassword.isNullOrBlank()) {
43
43
+
"Set KEY_PASSWORD in local.properties, Gradle properties, or the environment"
44
44
+
}
45
45
+
}
46
46
+
47
47
+
kotlin {
48
48
+
target {
49
49
+
compilerOptions {
50
50
+
jvmTarget.set(JvmTarget.JVM_17)
51
51
+
freeCompilerArgs.add("-Xexpect-actual-classes")
52
52
+
}
53
53
+
}
54
54
+
}
55
55
+
56
56
+
dependencies {
57
57
+
implementation(project(":composeApp"))
58
58
+
implementation(libs.androidx.activity.compose)
59
59
+
implementation(libs.posthog.kmp)
60
60
+
}
61
61
+
62
62
+
android {
63
63
+
namespace = "com.derakkuma"
64
64
+
compileSdk = 37
65
65
+
66
66
+
signingConfigs {
67
67
+
create("release") {
68
68
+
storeFile = derakkumaKeystoreFile
69
69
+
storePassword = derakkumaKeystorePassword.orEmpty()
70
70
+
keyAlias = derakkumaKeyAlias
71
71
+
keyPassword = derakkumaKeyPassword.orEmpty()
72
72
+
}
73
73
+
}
74
74
+
75
75
+
defaultConfig {
76
76
+
applicationId = "com.derakkuma"
77
77
+
minSdk = 26
78
78
+
targetSdk = 36
79
79
+
versionCode = 2
80
80
+
versionName = "0.1.1"
81
81
+
}
82
82
+
83
83
+
compileOptions {
84
84
+
sourceCompatibility = JavaVersion.VERSION_17
85
85
+
targetCompatibility = JavaVersion.VERSION_17
86
86
+
}
87
87
+
88
88
+
buildFeatures {
89
89
+
compose = true
90
90
+
}
91
91
+
92
92
+
buildTypes {
93
93
+
debug {
94
94
+
applicationIdSuffix = ".debug"
95
95
+
isDebuggable = true
96
96
+
}
97
97
+
release {
98
98
+
signingConfig = signingConfigs.getByName("release")
99
99
+
isMinifyEnabled = true
100
100
+
isShrinkResources = false
101
101
+
proguardFiles(
102
102
+
getDefaultProguardFile("proguard-android-optimize.txt"),
103
103
+
"../composeApp/proguard-rules.pro",
104
104
+
)
105
105
+
}
106
106
+
}
107
107
+
}
108
108
+
109
109
+
val emptyProguardMapFile = layout.buildDirectory.file("outputs/mapping/release/empty-proguard.map")
110
110
+
val writeEmptyProguardMap =
111
111
+
tasks.register("writeEmptyProguardMap") {
112
112
+
outputs.file(emptyProguardMapFile)
113
113
+
doLast {
114
114
+
emptyProguardMapFile.get().asFile.writeText("")
115
115
+
}
116
116
+
}
117
117
+
118
118
+
tasks.matching { it.name == "packageReleaseBundle" }.configureEach {
119
119
+
dependsOn(writeEmptyProguardMap)
120
120
+
val mappingFileProperty =
121
121
+
javaClass.methods
122
122
+
.firstOrNull { it.name == "getObsfuscationMappingFile" }
123
123
+
?.invoke(this) as? RegularFileProperty
124
124
+
mappingFileProperty?.set(emptyProguardMapFile)
125
125
+
}
···
4
4
alias(libs.plugins.compose.multiplatform) apply false
5
5
alias(libs.plugins.compose.compiler) apply false
6
6
alias(libs.plugins.android.application) apply false
7
7
+
alias(libs.plugins.android.multiplatform.library) apply false
7
8
}
···
1
1
import org.gradle.api.tasks.Copy
2
2
import org.gradle.api.tasks.Delete
3
3
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
4
4
-
import org.gradle.api.file.RegularFileProperty
5
5
-
import java.util.Properties
6
4
7
5
plugins {
8
6
alias(libs.plugins.kotlin.multiplatform)
9
7
alias(libs.plugins.kotlin.serialization)
10
8
alias(libs.plugins.compose.multiplatform)
11
9
alias(libs.plugins.compose.compiler)
12
12
-
alias(libs.plugins.android.application)
10
10
+
alias(libs.plugins.android.multiplatform.library)
13
11
alias(libs.plugins.sqldelight)
14
12
id("io.github.tarasovvp.kmp-secrets-plugin") version "1.3.0"
15
13
id("com.diffplug.spotless") version "8.2.1"
16
14
}
17
15
18
18
-
val localProperties =
19
19
-
Properties().apply {
20
20
-
val file = rootProject.file("local.properties")
21
21
-
if (file.isFile) {
22
22
-
file.inputStream().use(::load)
23
23
-
}
24
24
-
}
25
25
-
26
26
-
fun signingProperty(name: String): String? =
27
27
-
providers.gradleProperty(name).orNull
28
28
-
?: providers.environmentVariable(name).orNull
29
29
-
?: localProperties.getProperty(name)
30
30
-
31
31
-
val derakkumaKeystoreFile = rootProject.file("derakkuma.keystore")
32
32
-
val derakkumaKeystorePassword = signingProperty("KEYSTORE_PASSWORD")
33
33
-
val derakkumaKeyAlias = signingProperty("KEY_ALIAS") ?: "derakkuma"
34
34
-
val derakkumaKeyPassword = signingProperty("KEY_PASSWORD") ?: derakkumaKeystorePassword
35
35
-
val requestedReleaseBuild =
36
36
-
gradle.startParameter.taskNames.any { taskName ->
37
37
-
taskName.contains("Release", ignoreCase = true)
38
38
-
}
39
39
-
40
40
-
if (requestedReleaseBuild) {
41
41
-
require(derakkumaKeystoreFile.isFile) {
42
42
-
"Release builds must be signed with ${derakkumaKeystoreFile.path}"
43
43
-
}
44
44
-
require(!derakkumaKeystorePassword.isNullOrBlank()) {
45
45
-
"Set KEYSTORE_PASSWORD in local.properties, Gradle properties, or the environment"
46
46
-
}
47
47
-
require(!derakkumaKeyPassword.isNullOrBlank()) {
48
48
-
"Set KEY_PASSWORD in local.properties, Gradle properties, or the environment"
49
49
-
}
50
50
-
}
51
51
-
52
16
spotless {
53
17
kotlin {
54
18
target("**/*.kt")
···
62
26
}
63
27
64
28
kotlin {
65
65
-
androidTarget {
29
29
+
androidLibrary {
30
30
+
namespace = "com.derakkuma.library"
31
31
+
compileSdk = 37
32
32
+
minSdk = 26
33
33
+
34
34
+
androidResources {
35
35
+
enable = true
36
36
+
}
37
37
+
66
38
compilerOptions {
67
39
jvmTarget.set(JvmTarget.JVM_17)
68
40
}
···
89
61
commonMain.dependencies {
90
62
implementation(compose.runtime)
91
63
implementation(compose.foundation)
92
92
-
implementation(compose.material3)
64
64
+
implementation("org.jetbrains.compose.material3:material3:1.12.0-alpha01")
93
65
implementation(compose.ui)
94
66
implementation(compose.components.resources)
95
67
implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3")
···
120
92
}
121
93
122
94
androidMain.dependencies {
123
123
-
implementation("androidx.browser:browser:1.8.0")
124
124
-
implementation("androidx.core:core-ktx:1.17.0")
95
95
+
implementation(libs.androidx.browser)
96
96
+
implementation(libs.androidx.core.ktx)
125
97
implementation(libs.ktor.client.okhttp)
126
98
implementation(libs.sqldelight.android)
127
99
implementation(libs.androidx.activity.compose)
···
129
101
implementation(libs.crypto.jdk)
130
102
}
131
103
132
132
-
androidUnitTest.dependencies {
133
133
-
implementation(kotlin("test"))
134
134
-
}
135
135
-
136
104
iosMain.dependencies {
137
105
implementation(libs.ktor.client.darwin)
138
106
implementation(libs.sqldelight.native)
···
141
109
}
142
110
}
143
111
144
144
-
android {
145
145
-
namespace = "com.derakkuma"
146
146
-
compileSdk = 36
147
147
-
148
148
-
signingConfigs {
149
149
-
create("release") {
150
150
-
storeFile = derakkumaKeystoreFile
151
151
-
storePassword = derakkumaKeystorePassword.orEmpty()
152
152
-
keyAlias = derakkumaKeyAlias
153
153
-
keyPassword = derakkumaKeyPassword.orEmpty()
154
154
-
}
155
155
-
}
156
156
-
157
157
-
defaultConfig {
158
158
-
applicationId = "com.derakkuma"
159
159
-
minSdk = 26
160
160
-
targetSdk = 36
161
161
-
versionCode = 2
162
162
-
versionName = "0.1.1"
163
163
-
}
164
164
-
165
165
-
compileOptions {
166
166
-
sourceCompatibility = JavaVersion.VERSION_17
167
167
-
targetCompatibility = JavaVersion.VERSION_17
168
168
-
}
169
169
-
170
170
-
buildFeatures {
171
171
-
compose = true
172
172
-
}
173
173
-
174
174
-
buildTypes {
175
175
-
debug {
176
176
-
applicationIdSuffix = ".debug"
177
177
-
isDebuggable = true
178
178
-
}
179
179
-
release {
180
180
-
signingConfig = signingConfigs.getByName("release")
181
181
-
isMinifyEnabled = true
182
182
-
isShrinkResources = false
183
183
-
proguardFiles(
184
184
-
getDefaultProguardFile("proguard-android-optimize.txt"),
185
185
-
"proguard-rules.pro",
186
186
-
)
187
187
-
}
188
188
-
}
189
189
-
}
190
190
-
191
112
sqldelight {
192
113
databases {
193
114
create("DerakkumaDb") {
···
226
147
delete(staleAppIconPreviewResourceTree())
227
148
}
228
149
229
229
-
val emptyProguardMapFile = layout.buildDirectory.file("outputs/mapping/release/empty-proguard.map")
230
230
-
val writeEmptyProguardMap =
231
231
-
tasks.register("writeEmptyProguardMap") {
232
232
-
outputs.file(emptyProguardMapFile)
233
233
-
doLast {
234
234
-
emptyProguardMapFile.get().asFile.writeText("")
235
235
-
}
236
236
-
}
237
237
-
238
150
tasks.configureEach {
239
151
val taskName = name
240
152
val copiesComposeResourcesToAndroid = taskName.matches(Regex("copy.*ComposeResourcesToAndroidAssets"))
···
255
167
dependsOn(deleteStaleAppIconPreviewResources)
256
168
}
257
169
}
258
258
-
259
259
-
tasks.matching { it.name == "packageReleaseBundle" }.configureEach {
260
260
-
dependsOn(writeEmptyProguardMap)
261
261
-
val mappingFileProperty =
262
262
-
javaClass.methods
263
263
-
.firstOrNull { it.name == "getObsfuscationMappingFile" }
264
264
-
?.invoke(this) as? RegularFileProperty
265
265
-
mappingFileProperty?.set(emptyProguardMapFile)
266
266
-
}
···
24
24
return Color(0xFFEADDFF) to Color(0xFF4F378B)
25
25
}
26
26
27
27
-
actual suspend fun loadAppIconPreviewAsset(path: String): ImageBitmap? =
28
28
-
runCatching {
29
29
-
DerakkumaApp.context.assets.open("app-icon-assets/$path").use { stream ->
30
30
-
stream.readBytes().decodeToImageBitmap()
31
31
-
}
32
32
-
}.getOrNull()
27
27
+
actual suspend fun loadAppIconPreviewAsset(path: String): ImageBitmap? = runCatching {
28
28
+
DerakkumaApp.context.assets.open("app-icon-assets/$path").use { stream ->
29
29
+
stream.readBytes().decodeToImageBitmap()
30
30
+
}
31
31
+
}.getOrNull()
33
32
34
33
actual fun setAppIcon(option: AppIconOption): Boolean {
35
34
val context = DerakkumaApp.context
···
3
3
import coil3.request.ImageRequest
4
4
import coil3.request.allowHardware
5
5
6
6
-
actual fun ImageRequest.Builder.configureQrLogoRequest(): ImageRequest.Builder =
7
7
-
allowHardware(false)
6
6
+
actual fun ImageRequest.Builder.configureQrLogoRequest(): ImageRequest.Builder = allowHardware(false)
···
1
1
package com.derakkuma
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
4
4
+
import com.derakkuma.ui.components.ExpressiveBadgedBox
5
5
+
import com.derakkuma.ui.components.ExpressiveBadge
6
6
+
import com.derakkuma.ui.components.ExpressiveScaffold
7
7
+
import com.derakkuma.ui.components.expressiveNavigationBarItemColors
8
8
+
import com.derakkuma.ui.components.ExpressiveNavigationBarItem
9
9
+
import com.derakkuma.ui.components.ExpressiveNavigationBar
10
10
+
import com.derakkuma.ui.components.ExpressiveTopLoadingIndicator
11
11
+
import com.derakkuma.ui.components.expressiveScreenBackground
3
12
import androidx.compose.animation.core.LinearEasing
4
13
import androidx.compose.animation.core.animateFloat
5
14
import androidx.compose.animation.core.infiniteRepeatable
···
61
70
import com.derakkuma.ui.images.MaimaiAssets
62
71
import com.derakkuma.ui.images.UiIconPreloadImages
63
72
import com.derakkuma.ui.tabs.*
64
64
-
import com.derakkuma.ui.theme.DarkText
65
73
import com.derakkuma.ui.theme.DerakkumaTheme
66
66
-
import com.derakkuma.ui.theme.MaimaiBlue
67
67
-
import com.derakkuma.ui.theme.MutedText
74
74
+
import com.derakkuma.ui.theme.ExpressiveBubblegum
75
75
+
import com.derakkuma.ui.theme.ExpressiveLemon
68
76
import com.swmansion.kmpmaps.core.MapConfiguration
69
77
import kotlinx.coroutines.launch
70
78
import kotlinx.serialization.Serializable
···
220
228
DerakkumaTheme {
221
229
if (checkingAuth || !uiIconsPreloaded) {
222
230
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
223
223
-
CircularProgressIndicator(color = MaterialTheme.colorScheme.onBackground)
231
231
+
ExpressiveLoadingIndicator(color = MaterialTheme.colorScheme.onBackground)
224
232
}
225
233
return@DerakkumaTheme
226
234
}
···
257
265
)
258
266
} else if (refreshingSession) {
259
267
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
260
260
-
CircularProgressIndicator(color = MaterialTheme.colorScheme.onBackground)
268
268
+
ExpressiveLoadingIndicator(color = MaterialTheme.colorScheme.onBackground)
261
269
}
262
270
} else {
263
271
val cookie = auth!!.cookie
···
328
336
}
329
337
}
330
338
331
331
-
Scaffold(
339
339
+
ExpressiveScaffold(
332
340
bottomBar = {
333
341
Box {
334
334
-
NavigationBar(
342
342
+
val navigationContentColor = MaterialTheme.colorScheme.onPrimary
343
343
+
ExpressiveNavigationBar(
335
344
containerColor = Color(0xFF7CBC29),
336
336
-
contentColor = Color.White,
345
345
+
contentColor = navigationContentColor,
337
346
) {
338
347
Tab.entries.forEach { tab ->
339
339
-
NavigationBarItem(
348
348
+
val selectedContainer = navigationContentColor.copy(alpha = if (selectedTab == tab) 0.38f else 0.18f)
349
349
+
ExpressiveNavigationBarItem(
340
350
selected = selectedTab == tab,
341
351
onClick = {
342
352
navController.navigate(tab.route) {
···
349
359
},
350
360
label = { Text(tab.label) },
351
361
icon = {
352
352
-
BadgedBox(
362
362
+
ExpressiveBadgedBox(
353
363
badge = {
354
364
if (tab == Tab.Friends && pendingFriendRequestCount > 0) {
355
355
-
Badge(containerColor = Color(0xFFFB4B78), contentColor = Color.White) {
365
365
+
ExpressiveBadge {
356
366
Text(pendingFriendRequestCount.toString())
357
367
}
358
368
}
···
372
382
imageVector = tab.flatIcon,
373
383
contentDescription = tab.label,
374
384
modifier = Modifier.size(26.dp).alpha(if (selectedTab == tab) 1f else 0.6f),
375
375
-
tint = Color.White,
385
385
+
tint = navigationContentColor,
376
386
)
377
387
}
378
388
}
379
389
},
380
380
-
colors = NavigationBarItemDefaults.colors(
381
381
-
indicatorColor = Color.White.copy(alpha = 0.2f),
382
382
-
selectedIconColor = Color.White,
383
383
-
selectedTextColor = Color.White,
384
384
-
unselectedIconColor = Color.White.copy(alpha = 0.6f),
385
385
-
unselectedTextColor = Color.White.copy(alpha = 0.6f),
390
390
+
colors = expressiveNavigationBarItemColors(
391
391
+
contentColor = navigationContentColor,
392
392
+
selectedContainerColor = selectedContainer,
386
393
),
387
394
)
388
395
}
···
391
398
}
392
399
},
393
400
) { padding ->
394
394
-
Surface(
395
395
-
modifier = Modifier.fillMaxSize().padding(padding),
396
396
-
color = MaterialTheme.colorScheme.background,
401
401
+
Box(
402
402
+
modifier = Modifier.fillMaxSize().padding(padding).expressiveScreenBackground(),
397
403
) {
398
404
Column(Modifier.fillMaxSize()) {
405
405
+
if (refreshingSession) {
406
406
+
ExpressiveTopLoadingIndicator(color = MaterialTheme.colorScheme.primary)
407
407
+
}
399
408
if (maintenanceMode) {
400
409
MaintenanceBanner()
401
410
}
···
1
1
package com.derakkuma.atproto
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveButton
4
4
+
import com.derakkuma.ui.components.ExpressiveGlassCard
5
5
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
6
6
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
7
7
+
import com.derakkuma.ui.components.ExpressiveOutlinedButton
8
8
+
import com.derakkuma.ui.components.ExpressiveSwitch
9
9
+
import com.derakkuma.ui.components.ExpressiveTextButton
10
10
+
import com.derakkuma.ui.components.ExpressiveAlertDialog
11
11
+
import com.derakkuma.ui.components.ExpressiveDropdownMenu
12
12
+
import com.derakkuma.ui.components.ExpressiveDropdownMenuItem
13
13
+
import com.derakkuma.ui.components.ExpressiveTextField
3
14
import androidx.compose.animation.core.animateFloatAsState
4
15
import androidx.compose.animation.core.tween
5
16
import androidx.compose.foundation.layout.*
6
6
-
import androidx.compose.foundation.shape.CircleShape
7
17
import androidx.compose.foundation.shape.RoundedCornerShape
8
18
import androidx.compose.material.icons.Icons
9
19
import androidx.compose.material.icons.filled.CloudSync
···
13
23
import androidx.compose.runtime.*
14
24
import androidx.compose.ui.Alignment
15
25
import androidx.compose.ui.Modifier
26
26
+
import androidx.compose.ui.graphics.Color
16
27
import androidx.compose.ui.draw.clip
17
28
import androidx.compose.ui.layout.ContentScale
18
29
import androidx.compose.ui.text.font.FontWeight
···
215
226
verticalAlignment = Alignment.CenterVertically,
216
227
) {
217
228
Column(modifier = Modifier.weight(1f)) {
218
218
-
Text("Linked Account", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
219
219
-
Text("@${account!!.handle}", fontSize = 12.sp, color = MutedText)
229
229
+
Text("Linked Account", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
230
230
+
Text("@${account!!.handle}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
220
231
}
221
221
-
OutlinedButton(
232
232
+
ExpressiveOutlinedButton(
222
233
onClick = {
223
234
scope.launch {
224
235
try {
···
232
243
}
233
244
}
234
245
},
235
235
-
shape = RoundedCornerShape(8.dp),
236
236
-
colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed),
246
246
+
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
237
247
) {
238
248
Icon(Icons.Default.LinkOff, contentDescription = null, modifier = Modifier.size(16.dp))
239
249
Spacer(Modifier.width(4.dp))
···
241
251
}
242
252
}
243
253
244
244
-
if (showConfig) HorizontalDivider(color = MutedText.copy(alpha = 0.12f))
254
254
+
if (showConfig) ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
245
255
246
256
if (hasSynced) {
247
247
-
OutlinedButton(
257
257
+
ExpressiveOutlinedButton(
248
258
onClick = { configExpanded = !configExpanded },
249
259
modifier = Modifier.fillMaxWidth(),
250
250
-
shape = RoundedCornerShape(8.dp),
251
260
) {
252
261
Text(if (configExpanded) "Hide configuration" else "Configure", fontSize = 13.sp)
253
262
}
···
255
264
256
265
// Publish toggles
257
266
if (showConfig) {
258
258
-
Text("Publish to AT Protocol", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
259
259
-
Text("Control what data gets published to your PDS", fontSize = 11.sp, color = MutedText)
267
267
+
Text("Publish to AT Protocol", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
268
268
+
Text("Control what data gets published to your PDS", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
260
269
261
270
PublishToggle("Profile", "Player name, rating, images, comment", settings.publishProfile) { checked ->
262
271
settings = settings.copy(publishProfile = checked, publishFriendCode = if (checked) settings.publishFriendCode else false)
···
302
311
configureAtProtoBackgroundSync(account != null && checked)
303
312
}
304
313
if (settings.backgroundSyncEnabled && !canScheduleAtProtoBackgroundSync()) {
305
305
-
Text("Android needs exact alarm permission for reliable 12-hour background sync.", fontSize = 11.sp, color = MutedText)
306
306
-
TextButton(onClick = { openAtProtoBackgroundSyncSettings() }) {
314
314
+
Text("Android needs exact alarm permission for reliable 12-hour background sync.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
315
315
+
ExpressiveTextButton(onClick = { openAtProtoBackgroundSyncSettings() }) {
307
316
Text("Open alarm permission settings", fontSize = 12.sp)
308
317
}
309
318
}
310
319
}
311
320
312
312
-
if (showConfig) HorizontalDivider(color = MutedText.copy(alpha = 0.12f))
321
321
+
if (showConfig) ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
313
322
314
323
// Manual sync button
315
315
-
Button(
324
324
+
ExpressiveButton(
316
325
onClick = {
317
326
scope.launch {
318
327
syncing = true
···
346
355
}
347
356
},
348
357
modifier = Modifier.fillMaxWidth(),
349
349
-
shape = RoundedCornerShape(8.dp),
350
350
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
358
358
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
351
359
enabled = !syncing,
352
360
) {
353
361
Icon(Icons.Default.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp))
···
362
370
animationSpec = tween(durationMillis = 450),
363
371
label = "atproto-sync-progress",
364
372
)
365
365
-
LinearProgressIndicator(
373
373
+
ExpressiveLinearProgressIndicator(
366
374
progress = { animatedProgress },
367
375
modifier = Modifier.fillMaxWidth(),
368
368
-
color = PrimaryBlue,
376
376
+
color = MaterialTheme.colorScheme.primary,
369
377
)
370
370
-
Text(syncProgress!!.status, fontSize = 11.sp, color = MutedText)
371
371
-
Text("Don't close the app while syncing!", fontSize = 11.sp, color = DangerRed)
378
378
+
Text(syncProgress!!.status, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
379
379
+
Text("Don't close the app while syncing!", fontSize = 11.sp, color = MaterialTheme.colorScheme.error)
372
380
}
373
381
374
382
// Sync status
375
383
if (showConfig && syncState.lastSyncTime.isNotEmpty()) {
376
376
-
Text("Last synced: ${syncState.lastSyncTime.take(19)}", fontSize = 11.sp, color = MutedText)
384
384
+
Text("Last synced: ${syncState.lastSyncTime.take(19)}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
377
385
val plays = syncState.publishedPlayCount
378
386
val bests = syncState.publishedBestCount
379
387
val friends = syncState.publishedFriendCount
380
388
val favorites = syncState.publishedFavoriteSongCount
381
389
val circles = syncState.publishedCircleRecordCount
382
390
if (plays > 0 || bests > 0 || friends > 0 || favorites > 0 || circles > 0) {
383
383
-
Text("Total published: $plays plays, $bests bests, $friends friends, $favorites favorites, $circles circle records", fontSize = 11.sp, color = MutedText)
391
391
+
Text("Total published: $plays plays, $bests bests, $friends friends, $favorites favorites, $circles circle records", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
384
392
}
385
393
val queued = cacheStore.getPendingAtProtoWrites().size
386
394
if (queued > 0) {
387
387
-
Text("$queued write${if (queued == 1) "" else "s"} queued for retry", fontSize = 11.sp, color = MutedText)
395
395
+
Text("$queued write${if (queued == 1) "" else "s"} queued for retry", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
388
396
}
389
397
if (syncState.lastError.isNotBlank()) {
390
390
-
Text("Last ATProto error: ${syncState.lastError}", fontSize = 11.sp, color = DangerRed)
398
398
+
Text("Last ATProto error: ${syncState.lastError}", fontSize = 11.sp, color = MaterialTheme.colorScheme.error)
391
399
}
392
400
}
393
401
}
···
431
439
} else {
432
440
// Not linked
433
441
SectionCard {
434
434
-
Text("Link your Bluesky/ATProtocol account to publish your plays on the Atmosphere and connect with friends!", fontSize = 12.sp, color = MutedText)
442
442
+
Text("Link your Bluesky/ATProtocol account to publish your plays on the Atmosphere and connect with friends!", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
435
443
Box(modifier = Modifier.fillMaxWidth()) {
436
436
-
OutlinedTextField(
444
444
+
ExpressiveTextField(
437
445
value = handleOrDid,
438
446
onValueChange = { handleOrDid = it },
439
447
modifier = Modifier.fillMaxWidth(),
440
448
singleLine = true,
441
449
label = { Text("Handle or DID") },
442
450
placeholder = { Text("you.bsky.social") },
443
443
-
)
444
444
-
DropdownMenu(
451
451
+
)
452
452
+
ExpressiveDropdownMenu(
445
453
expanded = handleSuggestions.isNotEmpty(),
446
454
onDismissRequest = { handleSuggestions = emptyList() },
447
455
modifier = Modifier.fillMaxWidth(0.92f),
448
456
) {
449
449
-
handleSuggestions.forEach { suggestion ->
450
450
-
DropdownMenuItem(
457
457
+
handleSuggestions.forEachIndexed { index, suggestion ->
458
458
+
ExpressiveDropdownMenuItem(
459
459
+
selected = false,
460
460
+
index = index,
461
461
+
lastIndex = handleSuggestions.lastIndex,
451
462
text = {
452
463
Row(
453
464
verticalAlignment = Alignment.CenterVertically,
···
456
467
if (suggestion.avatar.isNotBlank()) {
457
468
MaimaiRemoteImage(
458
469
url = suggestion.avatar,
459
459
-
modifier = Modifier.size(34.dp).clip(CircleShape),
470
470
+
modifier = Modifier.size(34.dp).clip(RoundedCornerShape(2.dp)),
460
471
contentDescription = suggestion.displayName.ifBlank { suggestion.handle },
461
472
contentScale = ContentScale.Crop,
462
473
)
463
474
}
464
475
Column {
465
476
Text("@${suggestion.handle}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
466
466
-
if (suggestion.displayName.isNotBlank()) Text(suggestion.displayName, fontSize = 11.sp, color = MutedText)
477
477
+
if (suggestion.displayName.isNotBlank()) Text(suggestion.displayName, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
467
478
}
468
479
}
469
480
},
···
475
486
}
476
487
}
477
488
}
478
478
-
Button(
489
489
+
ExpressiveButton(
479
490
onClick = {
480
491
scope.launch {
481
492
linking = true
···
499
510
}
500
511
},
501
512
modifier = Modifier.fillMaxWidth(),
502
502
-
shape = RoundedCornerShape(8.dp),
503
503
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
513
513
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
504
514
enabled = !linking,
505
515
) {
506
516
Icon(Icons.Default.Link, contentDescription = null, modifier = Modifier.size(18.dp))
···
512
522
513
523
snackMsg?.let {
514
524
SectionCard(contentPadding = 12.dp) {
515
515
-
Text(it, fontSize = 12.sp, color = if (it.startsWith("Error:")) DangerRed else MutedText)
525
525
+
Text(it, fontSize = 12.sp, color = if (it.startsWith("Error:")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
516
526
}
517
527
}
518
528
} else {
519
529
// Platform not supported (iOS stub)
520
530
SectionCard {
521
521
-
Text("AT Protocol integration is unavailable in this build.", fontSize = 12.sp, color = MutedText)
531
531
+
Text("AT Protocol integration is unavailable in this build.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
522
532
}
523
533
}
524
534
}
···
532
542
}
533
543
534
544
if (showFriendCodePublishConfirm) {
535
535
-
AlertDialog(
545
545
+
ExpressiveAlertDialog(
536
546
onDismissRequest = { showFriendCodePublishConfirm = false },
537
547
title = { Text("Publish friend code?") },
538
548
text = { Text("This will add your maimai friend code to your public AT Protocol profile, which lets Bluesky friends discover you and send maimai friend requests through Derakkuma. Even if you remove it later, other apps or indexers may have already copied it.") },
539
549
confirmButton = {
540
540
-
TextButton(
550
550
+
ExpressiveTextButton(
541
551
onClick = {
542
552
settings = settings.copy(publishProfile = true, publishFriendCode = true)
543
553
cacheStore.putAtProtoPublishSettings(settings)
···
546
556
) { Text("Publish") }
547
557
},
548
558
dismissButton = {
549
549
-
TextButton(onClick = { showFriendCodePublishConfirm = false }) { Text("Cancel") }
559
559
+
ExpressiveTextButton(onClick = { showFriendCodePublishConfirm = false }) { Text("Cancel") }
550
560
},
551
561
)
552
562
}
···
582
592
contentPadding: androidx.compose.ui.unit.Dp = 16.dp,
583
593
content: @Composable ColumnScope.() -> Unit,
584
594
) {
585
585
-
Card(
595
595
+
ExpressiveGlassCard(
586
596
modifier = Modifier.fillMaxWidth(),
587
587
-
shape = RoundedCornerShape(8.dp),
588
588
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
597
597
+
cornerRadius = 24.dp,
598
598
+
containerColor = Color.White,
589
599
) {
590
600
Column(
591
601
modifier = Modifier.padding(contentPadding),
···
602
612
error: String?,
603
613
) {
604
614
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
605
605
-
Text("Atmosphere Activity", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
615
615
+
Text("Atmosphere Activity", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
606
616
if (error != null) {
607
617
Text(error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
608
618
return@Column
···
614
624
SmallStat("Avg", if (stats.bestCount > 0) "${formatScore(stats.averageAchievement)}%" else "--")
615
625
}
616
626
}
617
617
-
Text("Record Feed", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
618
618
-
Text("Recent records from people you follow plus everyone on the Atmosphere.", fontSize = 11.sp, color = MutedText)
627
627
+
Text("Record Feed", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
628
628
+
Text("Recent records from people you follow plus everyone on the Atmosphere.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
619
629
if (feed.isEmpty()) {
620
620
-
Text("No record activity yet... get 'em on Derakkuma! :D", fontSize = 12.sp, color = MutedText)
630
630
+
Text("No record activity yet... get 'em on Derakkuma! :D", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
621
631
} else {
622
632
feed.take(20).forEach { play ->
623
633
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
624
624
-
Text(play.songName.ifBlank { "Unknown song" }, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = DarkText, maxLines = 1)
634
634
+
Text(play.songName.ifBlank { "Unknown song" }, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
625
635
val player = play.playerName.ifBlank { play.did.take(16) + "…" }
626
636
val source = play.source.takeIf { it.isNotBlank() }?.let { " • $it" }.orEmpty()
627
627
-
Text("$player • ${play.difficulty} ${play.level} • ${formatScore(play.achievement.toDoubleOrNull() ?: 0.0)}%$source", fontSize = 11.sp, color = MutedText, maxLines = 1)
637
637
+
Text("$player • ${play.difficulty} ${play.level} • ${formatScore(play.achievement.toDoubleOrNull() ?: 0.0)}%$source", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
628
638
}
629
639
}
630
640
}
···
634
644
@Composable
635
645
private fun SmallStat(label: String, value: String) {
636
646
Column(horizontalAlignment = Alignment.CenterHorizontally) {
637
637
-
Text(value, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue)
638
638
-
Text(label, fontSize = 10.sp, color = MutedText)
647
647
+
Text(value, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
648
648
+
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
639
649
}
640
650
}
641
651
···
649
659
onRequest: (AtProtoSocialProfile) -> Unit,
650
660
) {
651
661
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
652
652
-
Text("Social Bridge", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
653
653
-
Text("Discovery uses published Derakkuma profiles.", fontSize = 11.sp, color = MutedText)
662
662
+
Text("Social Bridge", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
663
663
+
Text("Discovery uses published Derakkuma profiles.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
654
664
655
655
-
if (loading) LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = PrimaryBlue)
665
665
+
if (loading) ExpressiveLinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary)
656
666
if (error != null) Text(error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
657
667
658
658
-
Text("Maimai friends on Bluesky", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
668
668
+
Text("Maimai friends on Bluesky", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
659
669
if (maimaiFriendMatches.isEmpty()) {
660
660
-
Text(if (loading) "Looking for matches..." else "No maimai friends matched a published Bluesky profile yet.", fontSize = 12.sp, color = MutedText)
670
670
+
Text(if (loading) "Looking for matches..." else "No maimai friends matched a published Bluesky profile yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
661
671
} else {
662
672
maimaiFriendMatches.forEach { match ->
663
673
SocialActionRow(
···
670
680
}
671
681
}
672
682
673
673
-
Text("Bluesky follows on maimai", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
683
683
+
Text("Bluesky follows on maimai", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
674
684
if (bskyFollowMatches.isEmpty()) {
675
675
-
Text(if (loading) "Looking for matches..." else "No Bluesky follows matched a published Derakkuma profile yet.", fontSize = 12.sp, color = MutedText)
685
685
+
Text(if (loading) "Looking for matches..." else "No Bluesky follows matched a published Derakkuma profile yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
676
686
} else {
677
687
bskyFollowMatches.forEach { match ->
678
688
val label = match.profile.playerName.ifBlank { match.follow.displayName.ifBlank { match.follow.handle } }
···
698
708
) {
699
709
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
700
710
Column(Modifier.weight(1f)) {
701
701
-
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = DarkText, maxLines = 1)
702
702
-
Text(subtitle, fontSize = 11.sp, color = MutedText, maxLines = 1)
711
711
+
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
712
712
+
Text(subtitle, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
703
713
}
704
704
-
OutlinedButton(onClick = onClick, enabled = enabled, shape = RoundedCornerShape(8.dp)) {
714
714
+
ExpressiveOutlinedButton(onClick = onClick, enabled = enabled) {
705
715
Text(action, fontSize = 12.sp)
706
716
}
707
717
}
···
721
731
verticalAlignment = Alignment.CenterVertically,
722
732
) {
723
733
Column(modifier = Modifier.weight(1f)) {
724
724
-
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
725
725
-
Text(description, fontSize = 11.sp, color = MutedText)
734
734
+
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
735
735
+
Text(description, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
726
736
}
727
727
-
Switch(
737
737
+
ExpressiveSwitch(
728
738
checked = checked,
729
739
onCheckedChange = onCheckedChange,
730
730
-
enabled = enabled,
731
731
-
colors = SwitchDefaults.colors(checkedTrackColor = PrimaryBlue),
740
740
+
enabled = enabled
732
741
)
733
742
}
734
743
}
···
10
10
import androidx.compose.foundation.layout.*
11
11
import androidx.compose.foundation.rememberScrollState
12
12
import androidx.compose.foundation.shape.CircleShape
13
13
-
import androidx.compose.foundation.shape.RoundedCornerShape
14
13
import androidx.compose.foundation.verticalScroll
15
14
import androidx.compose.material.icons.Icons
16
15
import androidx.compose.material3.*
···
26
25
import androidx.compose.ui.unit.sp
27
26
import com.derakkuma.atproto.openOAuthUrl
28
27
import com.derakkuma.ui.components.MaintenanceBanner
28
28
+
import com.derakkuma.ui.components.ExpressiveButton
29
29
+
import com.derakkuma.ui.components.ExpressiveGlassCard
30
30
+
import com.derakkuma.ui.components.ExpressiveIconSurface
31
31
+
import com.derakkuma.ui.components.ExpressiveCheckbox
32
32
+
import com.derakkuma.ui.components.ExpressiveTextButton
33
33
+
import com.derakkuma.ui.components.expressiveScreenBackground
29
34
import com.derakkuma.ui.images.BundledIcon
30
35
import com.derakkuma.ui.images.BundledImage
31
36
import com.derakkuma.ui.images.DerakkumaGroupAssets
32
32
-
import com.derakkuma.ui.theme.CardWhite
33
33
-
import com.derakkuma.ui.theme.DarkText
34
34
-
import com.derakkuma.ui.theme.MaimaiBlue
35
35
-
import com.derakkuma.ui.theme.MutedText
36
36
-
import com.derakkuma.ui.theme.PrimaryBlue
37
37
38
38
@Composable
39
39
fun OnboardingScreen(onComplete: () -> Unit, maintenanceMode: Boolean = false) {
40
40
var accepted by remember { mutableStateOf(false) }
41
41
42
42
-
Surface(color = MaimaiBlue, modifier = Modifier.fillMaxSize()) {
42
42
+
Box(modifier = Modifier.fillMaxSize().expressiveScreenBackground()) {
43
43
Column(
44
44
modifier = Modifier
45
45
.fillMaxSize()
···
53
53
MaintenanceBanner()
54
54
}
55
55
56
56
-
Card(
57
57
-
shape = RoundedCornerShape(28.dp),
58
58
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
56
56
+
ExpressiveGlassCard(
59
57
modifier = Modifier.fillMaxWidth(),
60
58
) {
61
59
Column(
···
66
64
DerakkumaGroupMascots()
67
65
68
66
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
69
69
-
Text("Welcome to Derakkuma", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 22.sp, textAlign = TextAlign.Center)
67
67
+
Text("Welcome to Derakkuma", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 22.sp, textAlign = TextAlign.Center)
70
68
Text(
71
69
"Your maimai DX NET companion!",
72
72
-
color = MutedText,
70
70
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
73
71
fontSize = 14.sp,
74
72
lineHeight = 19.sp,
75
73
textAlign = TextAlign.Center,
···
92
90
text = "Your maimai DX NET session and cached data are stored only on this device. They never leave your device unless you explicitly enable sharing/syncing features.",
93
91
)
94
92
95
95
-
Card(
96
96
-
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.08f)),
97
97
-
shape = RoundedCornerShape(18.dp),
93
93
+
ExpressiveGlassCard(
98
94
modifier = Modifier.fillMaxWidth(),
95
95
+
cornerRadius = 28.dp,
96
96
+
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.42f),
99
97
) {
100
98
Column(Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 2.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
101
101
-
Text("Before we start", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 12.sp)
99
99
+
Text("Before we start", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 12.sp)
102
100
Text(
103
101
"Derakkuma is an unofficial maimai DX NET client and is not affiliated with or endorsed by SEGA. By continuing, you agree that your use of maimai DX NET through this app follows maimai DX NET Terms of Service and Privacy Policy.",
104
104
-
color = MutedText,
102
102
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
105
103
fontSize = 10.sp,
106
104
lineHeight = 17.sp,
107
105
)
108
106
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
109
109
-
TextButton(onClick = { openOAuthUrl("https://derakkuma.com/terms") }) {
107
107
+
ExpressiveTextButton(onClick = { openOAuthUrl("https://derakkuma.com/terms") }) {
110
108
Text("Terms of Service", fontSize = 11.sp)
111
109
}
112
112
-
TextButton(onClick = { openOAuthUrl("https://derakkuma.com/privacy") }) {
110
110
+
ExpressiveTextButton(onClick = { openOAuthUrl("https://derakkuma.com/privacy") }) {
113
111
Text("Privacy Policy", fontSize = 11.sp)
114
112
}
115
113
}
116
114
Row(verticalAlignment = Alignment.CenterVertically) {
117
117
-
Checkbox(checked = accepted, onCheckedChange = { accepted = it })
118
118
-
Text("I understand and agree", color = DarkText, fontSize = 13.sp)
115
115
+
ExpressiveCheckbox(checked = accepted, onCheckedChange = { accepted = it })
116
116
+
Text("I understand and agree", color = MaterialTheme.colorScheme.onSurface, fontSize = 13.sp)
119
117
}
120
118
}
121
119
}
122
120
123
123
-
Button(
121
121
+
ExpressiveButton(
124
122
onClick = onComplete,
125
123
enabled = accepted && !maintenanceMode,
126
126
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
124
124
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
127
125
modifier = Modifier.fillMaxWidth().height(52.dp),
128
128
-
shape = RoundedCornerShape(16.dp),
129
126
) {
130
127
Text("Get started", fontWeight = FontWeight.Bold)
131
128
}
···
203
200
@Composable
204
201
private fun OnboardingPoint(icon: Any, title: String, text: String) {
205
202
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
206
206
-
Surface(shape = CircleShape, color = PrimaryBlue.copy(alpha = 0.12f), modifier = Modifier.size(40.dp)) {
203
203
+
ExpressiveIconSurface(size = 40.dp, containerColor = MaterialTheme.colorScheme.primaryContainer) {
207
204
Box(contentAlignment = Alignment.Center) {
208
205
when (icon) {
209
209
-
is androidx.compose.ui.graphics.vector.ImageVector -> Icon(icon, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(21.dp))
206
206
+
is androidx.compose.ui.graphics.vector.ImageVector -> Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(21.dp))
210
207
is BundledImage -> BundledIcon(icon, contentDescription = null, modifier = Modifier.size(24.dp))
211
208
}
212
209
}
213
210
}
214
211
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
215
215
-
Text(title, color = DarkText, fontWeight = FontWeight.Bold, fontSize = 14.sp)
216
216
-
Text(text, color = MutedText, fontSize = 12.sp, lineHeight = 17.sp)
212
212
+
Text(title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp)
213
213
+
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, lineHeight = 17.sp)
217
214
}
218
215
}
219
216
}
···
1
1
+
package com.derakkuma.ui.components
2
2
+
3
3
+
import androidx.compose.animation.core.AnimationSpec
4
4
+
import androidx.compose.animation.core.Spring
5
5
+
import androidx.compose.animation.core.spring
6
6
+
import androidx.compose.animation.core.tween
7
7
+
import androidx.compose.foundation.interaction.MutableInteractionSource
8
8
+
import androidx.compose.foundation.interaction.collectIsPressedAsState
9
9
+
import androidx.compose.foundation.layout.ColumnScope
10
10
+
import androidx.compose.foundation.layout.BoxScope
11
11
+
import androidx.compose.foundation.layout.fillMaxWidth
12
12
+
import androidx.compose.foundation.layout.height
13
13
+
import androidx.compose.foundation.layout.PaddingValues
14
14
+
import androidx.compose.foundation.layout.RowScope
15
15
+
import androidx.compose.foundation.layout.WindowInsets
16
16
+
import androidx.compose.foundation.layout.safeDrawing
17
17
+
import androidx.compose.foundation.layout.size
18
18
+
import androidx.compose.material3.Button
19
19
+
import androidx.compose.material3.ButtonColors
20
20
+
import androidx.compose.material3.ButtonDefaults
21
21
+
import androidx.compose.material3.AssistChip
22
22
+
import androidx.compose.material3.AssistChipDefaults
23
23
+
import androidx.compose.material3.AlertDialog
24
24
+
import androidx.compose.material3.Badge
25
25
+
import androidx.compose.material3.BadgedBox
26
26
+
import androidx.compose.material3.Checkbox
27
27
+
import androidx.compose.material3.CheckboxDefaults
28
28
+
import androidx.compose.material3.CircularProgressIndicator
29
29
+
import androidx.compose.material3.ExperimentalMaterial3Api
30
30
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
31
31
+
import androidx.compose.material3.DropdownMenu
32
32
+
import androidx.compose.material3.DropdownMenuItem
33
33
+
import androidx.compose.material3.FilterChip
34
34
+
import androidx.compose.material3.FilterChipDefaults
35
35
+
import androidx.compose.material3.FabPosition
36
36
+
import androidx.compose.material3.FilledTonalIconButton
37
37
+
import androidx.compose.material3.FloatingActionButton
38
38
+
import androidx.compose.material3.HorizontalDivider
39
39
+
import androidx.compose.material3.LinearWavyProgressIndicator
40
40
+
import androidx.compose.material3.MaterialTheme
41
41
+
import androidx.compose.material3.MenuDefaults
42
42
+
import androidx.compose.material3.NavigationBar
43
43
+
import androidx.compose.material3.NavigationBarItem
44
44
+
import androidx.compose.material3.NavigationBarItemColors
45
45
+
import androidx.compose.material3.NavigationBarItemDefaults
46
46
+
import androidx.compose.material3.OutlinedButton
47
47
+
import androidx.compose.material3.Slider
48
48
+
import androidx.compose.material3.SliderDefaults
49
49
+
import androidx.compose.material3.Snackbar
50
50
+
import androidx.compose.material3.Switch
51
51
+
import androidx.compose.material3.SwitchDefaults
52
52
+
import androidx.compose.material3.TextButton
53
53
+
import androidx.compose.material3.TextField
54
54
+
import androidx.compose.material3.TextFieldColors
55
55
+
import androidx.compose.material3.TextFieldDefaults
56
56
+
import androidx.compose.material3.ToggleButton
57
57
+
import androidx.compose.material3.ToggleButtonShapes
58
58
+
import androidx.compose.material3.WavyProgressIndicatorDefaults
59
59
+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
60
60
+
import androidx.compose.runtime.Composable
61
61
+
import androidx.compose.runtime.getValue
62
62
+
import androidx.compose.runtime.remember
63
63
+
import androidx.compose.ui.Modifier
64
64
+
import androidx.compose.ui.draw.scale
65
65
+
import androidx.compose.ui.graphics.Color
66
66
+
import androidx.compose.ui.graphics.Shape
67
67
+
import androidx.compose.ui.text.input.VisualTransformation
68
68
+
import androidx.compose.ui.unit.Dp
69
69
+
import androidx.compose.ui.unit.DpSize
70
70
+
import androidx.compose.ui.unit.dp
71
71
+
72
72
+
import androidx.compose.material3.SearchBar
73
73
+
import androidx.compose.material3.SearchBarColors
74
74
+
import androidx.compose.material3.SearchBarDefaults
75
75
+
import androidx.compose.material3.Scaffold
76
76
+
77
77
+
@Composable
78
78
+
fun expressiveTextFieldColors(): TextFieldColors = TextFieldDefaults.colors(
79
79
+
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
80
80
+
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
81
81
+
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
82
82
+
focusedIndicatorColor = Color.Transparent,
83
83
+
unfocusedIndicatorColor = Color.Transparent,
84
84
+
disabledIndicatorColor = Color.Transparent,
85
85
+
)
86
86
+
87
87
+
@Composable
88
88
+
fun ExpressiveTextField(
89
89
+
value: String,
90
90
+
onValueChange: (String) -> Unit,
91
91
+
modifier: Modifier = Modifier,
92
92
+
enabled: Boolean = true,
93
93
+
readOnly: Boolean = false,
94
94
+
label: @Composable (() -> Unit)? = null,
95
95
+
placeholder: @Composable (() -> Unit)? = null,
96
96
+
leadingIcon: @Composable (() -> Unit)? = null,
97
97
+
trailingIcon: @Composable (() -> Unit)? = null,
98
98
+
supportingText: @Composable (() -> Unit)? = null,
99
99
+
singleLine: Boolean = false,
100
100
+
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
101
101
+
visualTransformation: VisualTransformation = VisualTransformation.None,
102
102
+
) {
103
103
+
TextField(
104
104
+
value = value,
105
105
+
onValueChange = onValueChange,
106
106
+
modifier = modifier,
107
107
+
enabled = enabled,
108
108
+
readOnly = readOnly,
109
109
+
label = label,
110
110
+
placeholder = placeholder,
111
111
+
leadingIcon = leadingIcon,
112
112
+
trailingIcon = trailingIcon,
113
113
+
supportingText = supportingText,
114
114
+
singleLine = singleLine,
115
115
+
maxLines = maxLines,
116
116
+
visualTransformation = visualTransformation,
117
117
+
shape = MaterialTheme.shapes.extraLarge,
118
118
+
colors = expressiveTextFieldColors(),
119
119
+
)
120
120
+
}
121
121
+
122
122
+
object ExpressiveMotion {
123
123
+
val Spatial: AnimationSpec<Float> = spring(
124
124
+
dampingRatio = Spring.DampingRatioMediumBouncy,
125
125
+
stiffness = Spring.StiffnessMediumLow,
126
126
+
)
127
127
+
128
128
+
val Effects: AnimationSpec<Float> = tween(durationMillis = 220)
129
129
+
}
130
130
+
131
131
+
@Composable
132
132
+
fun Modifier.expressivePressScale(interactionSource: MutableInteractionSource): Modifier {
133
133
+
val pressed by interactionSource.collectIsPressedAsState()
134
134
+
val scale by androidx.compose.animation.core.animateFloatAsState(
135
135
+
targetValue = if (pressed) 0.96f else 1f,
136
136
+
animationSpec = ExpressiveMotion.Spatial,
137
137
+
label = "expressive-press-scale",
138
138
+
)
139
139
+
return scale(scale)
140
140
+
}
141
141
+
142
142
+
@Composable
143
143
+
fun ExpressiveButton(
144
144
+
onClick: () -> Unit,
145
145
+
modifier: Modifier = Modifier,
146
146
+
enabled: Boolean = true,
147
147
+
colors: ButtonColors = ButtonDefaults.buttonColors(),
148
148
+
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
149
149
+
content: @Composable RowScope.() -> Unit,
150
150
+
) {
151
151
+
val interactionSource = remember { MutableInteractionSource() }
152
152
+
Button(
153
153
+
onClick = onClick,
154
154
+
modifier = modifier.expressivePressScale(interactionSource),
155
155
+
enabled = enabled,
156
156
+
shape = MaterialTheme.shapes.extraLarge,
157
157
+
colors = colors,
158
158
+
contentPadding = contentPadding,
159
159
+
interactionSource = interactionSource,
160
160
+
content = content,
161
161
+
)
162
162
+
}
163
163
+
164
164
+
@Composable
165
165
+
fun ExpressiveOutlinedButton(
166
166
+
onClick: () -> Unit,
167
167
+
modifier: Modifier = Modifier,
168
168
+
enabled: Boolean = true,
169
169
+
colors: ButtonColors = ButtonDefaults.outlinedButtonColors(),
170
170
+
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
171
171
+
content: @Composable RowScope.() -> Unit,
172
172
+
) {
173
173
+
val interactionSource = remember { MutableInteractionSource() }
174
174
+
OutlinedButton(
175
175
+
onClick = onClick,
176
176
+
modifier = modifier.expressivePressScale(interactionSource),
177
177
+
enabled = enabled,
178
178
+
shape = MaterialTheme.shapes.extraLarge,
179
179
+
colors = colors,
180
180
+
contentPadding = contentPadding,
181
181
+
interactionSource = interactionSource,
182
182
+
content = content,
183
183
+
)
184
184
+
}
185
185
+
186
186
+
@Composable
187
187
+
fun ExpressiveTextButton(
188
188
+
onClick: () -> Unit,
189
189
+
modifier: Modifier = Modifier,
190
190
+
enabled: Boolean = true,
191
191
+
contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding,
192
192
+
content: @Composable RowScope.() -> Unit,
193
193
+
) {
194
194
+
val interactionSource = remember { MutableInteractionSource() }
195
195
+
TextButton(
196
196
+
onClick = onClick,
197
197
+
modifier = modifier.expressivePressScale(interactionSource),
198
198
+
enabled = enabled,
199
199
+
shape = MaterialTheme.shapes.extraLarge,
200
200
+
contentPadding = contentPadding,
201
201
+
interactionSource = interactionSource,
202
202
+
content = content,
203
203
+
)
204
204
+
}
205
205
+
206
206
+
@Composable
207
207
+
fun ExpressiveSwitch(
208
208
+
checked: Boolean,
209
209
+
onCheckedChange: ((Boolean) -> Unit)?,
210
210
+
modifier: Modifier = Modifier,
211
211
+
enabled: Boolean = true,
212
212
+
) {
213
213
+
val interactionSource = remember { MutableInteractionSource() }
214
214
+
Switch(
215
215
+
checked = checked,
216
216
+
onCheckedChange = onCheckedChange,
217
217
+
modifier = modifier.expressivePressScale(interactionSource),
218
218
+
enabled = enabled,
219
219
+
colors = SwitchDefaults.colors(
220
220
+
checkedTrackColor = MaterialTheme.colorScheme.primary,
221
221
+
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
222
222
+
checkedBorderColor = MaterialTheme.colorScheme.primary,
223
223
+
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
224
224
+
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
225
225
+
uncheckedBorderColor = MaterialTheme.colorScheme.outlineVariant,
226
226
+
),
227
227
+
interactionSource = interactionSource,
228
228
+
)
229
229
+
}
230
230
+
231
231
+
@Composable
232
232
+
fun ExpressiveCheckbox(
233
233
+
checked: Boolean,
234
234
+
onCheckedChange: ((Boolean) -> Unit)?,
235
235
+
modifier: Modifier = Modifier,
236
236
+
enabled: Boolean = true,
237
237
+
) {
238
238
+
val interactionSource = remember { MutableInteractionSource() }
239
239
+
Checkbox(
240
240
+
checked = checked,
241
241
+
onCheckedChange = onCheckedChange,
242
242
+
modifier = modifier.expressivePressScale(interactionSource),
243
243
+
enabled = enabled,
244
244
+
colors = CheckboxDefaults.colors(
245
245
+
checkedColor = MaterialTheme.colorScheme.primary,
246
246
+
uncheckedColor = MaterialTheme.colorScheme.outline,
247
247
+
checkmarkColor = MaterialTheme.colorScheme.onPrimary,
248
248
+
),
249
249
+
interactionSource = interactionSource,
250
250
+
)
251
251
+
}
252
252
+
253
253
+
@Composable
254
254
+
fun ExpressiveFilterChip(
255
255
+
selected: Boolean,
256
256
+
onClick: () -> Unit,
257
257
+
label: @Composable () -> Unit,
258
258
+
modifier: Modifier = Modifier,
259
259
+
enabled: Boolean = true,
260
260
+
selectedColor: Color = MaterialTheme.colorScheme.primary,
261
261
+
shape: Shape = MaterialTheme.shapes.extraLarge,
262
262
+
) {
263
263
+
val interactionSource = remember { MutableInteractionSource() }
264
264
+
FilterChip(
265
265
+
selected = selected,
266
266
+
onClick = onClick,
267
267
+
label = label,
268
268
+
modifier = modifier.expressivePressScale(interactionSource),
269
269
+
enabled = enabled,
270
270
+
shape = shape,
271
271
+
colors = FilterChipDefaults.filterChipColors(
272
272
+
containerColor = Color.White,
273
273
+
labelColor = MaterialTheme.colorScheme.onSurface,
274
274
+
selectedContainerColor = selectedColor,
275
275
+
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
276
276
+
),
277
277
+
border = FilterChipDefaults.filterChipBorder(
278
278
+
enabled = enabled,
279
279
+
selected = selected,
280
280
+
borderColor = Color.Transparent,
281
281
+
selectedBorderColor = selectedColor,
282
282
+
),
283
283
+
interactionSource = interactionSource,
284
284
+
)
285
285
+
}
286
286
+
287
287
+
@Composable
288
288
+
fun ExpressiveAssistChip(
289
289
+
onClick: () -> Unit,
290
290
+
label: @Composable () -> Unit,
291
291
+
modifier: Modifier = Modifier,
292
292
+
enabled: Boolean = true,
293
293
+
containerColor: Color = MaterialTheme.colorScheme.secondaryContainer,
294
294
+
labelColor: Color = MaterialTheme.colorScheme.onSecondaryContainer,
295
295
+
) {
296
296
+
val interactionSource = remember { MutableInteractionSource() }
297
297
+
AssistChip(
298
298
+
onClick = onClick,
299
299
+
label = label,
300
300
+
modifier = modifier.expressivePressScale(interactionSource),
301
301
+
enabled = enabled,
302
302
+
shape = MaterialTheme.shapes.extraLarge,
303
303
+
colors = AssistChipDefaults.assistChipColors(
304
304
+
containerColor = containerColor,
305
305
+
labelColor = labelColor,
306
306
+
),
307
307
+
border = AssistChipDefaults.assistChipBorder(enabled = enabled, borderColor = Color.Transparent),
308
308
+
interactionSource = interactionSource,
309
309
+
)
310
310
+
}
311
311
+
312
312
+
@Composable
313
313
+
fun ExpressiveSnackbar(
314
314
+
modifier: Modifier = Modifier,
315
315
+
action: @Composable (() -> Unit)? = null,
316
316
+
content: @Composable () -> Unit,
317
317
+
) {
318
318
+
Snackbar(
319
319
+
modifier = modifier,
320
320
+
shape = MaterialTheme.shapes.extraLarge,
321
321
+
containerColor = MaterialTheme.colorScheme.inverseSurface,
322
322
+
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
323
323
+
actionContentColor = MaterialTheme.colorScheme.inversePrimary,
324
324
+
action = action,
325
325
+
content = content,
326
326
+
)
327
327
+
}
328
328
+
329
329
+
@Composable
330
330
+
fun ExpressiveFloatingActionButton(
331
331
+
onClick: () -> Unit,
332
332
+
modifier: Modifier = Modifier,
333
333
+
containerColor: Color = MaterialTheme.colorScheme.primary,
334
334
+
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
335
335
+
content: @Composable () -> Unit,
336
336
+
) {
337
337
+
val interactionSource = remember { MutableInteractionSource() }
338
338
+
FloatingActionButton(
339
339
+
onClick = onClick,
340
340
+
modifier = modifier.expressivePressScale(interactionSource),
341
341
+
shape = MaterialTheme.shapes.extraLarge,
342
342
+
containerColor = containerColor,
343
343
+
contentColor = contentColor,
344
344
+
interactionSource = interactionSource,
345
345
+
content = content,
346
346
+
)
347
347
+
}
348
348
+
349
349
+
@OptIn(ExperimentalMaterial3Api::class)
350
350
+
@Composable
351
351
+
fun ExpressiveSearchBar(
352
352
+
inputField: @Composable () -> Unit,
353
353
+
expanded: Boolean,
354
354
+
onExpandedChange: (Boolean) -> Unit,
355
355
+
modifier: Modifier = Modifier,
356
356
+
colors: SearchBarColors = SearchBarDefaults.colors(
357
357
+
containerColor = Color.White,
358
358
+
dividerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f),
359
359
+
),
360
360
+
content: @Composable ColumnScope.() -> Unit = {},
361
361
+
) {
362
362
+
SearchBar(
363
363
+
inputField = inputField,
364
364
+
expanded = expanded,
365
365
+
onExpandedChange = onExpandedChange,
366
366
+
modifier = modifier,
367
367
+
shape = MaterialTheme.shapes.extraLarge,
368
368
+
colors = colors,
369
369
+
content = content,
370
370
+
)
371
371
+
}
372
372
+
373
373
+
@Composable
374
374
+
fun ExpressiveDropdownMenu(
375
375
+
expanded: Boolean,
376
376
+
onDismissRequest: () -> Unit,
377
377
+
modifier: Modifier = Modifier,
378
378
+
content: @Composable ColumnScope.() -> Unit,
379
379
+
) {
380
380
+
DropdownMenu(
381
381
+
expanded = expanded,
382
382
+
onDismissRequest = onDismissRequest,
383
383
+
modifier = modifier,
384
384
+
shape = MenuDefaults.shape,
385
385
+
containerColor = MaterialTheme.colorScheme.surfaceContainer,
386
386
+
tonalElevation = 0.dp,
387
387
+
shadowElevation = 0.dp,
388
388
+
content = content,
389
389
+
)
390
390
+
}
391
391
+
392
392
+
@OptIn(ExperimentalMaterial3Api::class)
393
393
+
@Composable
394
394
+
fun ExpressiveDropdownMenuItem(
395
395
+
selected: Boolean,
396
396
+
index: Int,
397
397
+
lastIndex: Int,
398
398
+
onClick: () -> Unit,
399
399
+
text: @Composable () -> Unit,
400
400
+
leadingIcon: @Composable (() -> Unit)? = null,
401
401
+
) {
402
402
+
DropdownMenuItem(
403
403
+
selected = selected,
404
404
+
text = text,
405
405
+
leadingIcon = leadingIcon,
406
406
+
shapes = MenuDefaults.itemShape(index, lastIndex),
407
407
+
colors = MenuDefaults.selectableItemVibrantColors(),
408
408
+
onClick = onClick,
409
409
+
)
410
410
+
}
411
411
+
412
412
+
@Composable
413
413
+
fun ExpressiveAlertDialog(
414
414
+
onDismissRequest: () -> Unit,
415
415
+
confirmButton: @Composable () -> Unit,
416
416
+
modifier: Modifier = Modifier,
417
417
+
dismissButton: @Composable (() -> Unit)? = null,
418
418
+
icon: @Composable (() -> Unit)? = null,
419
419
+
title: @Composable (() -> Unit)? = null,
420
420
+
text: @Composable (() -> Unit)? = null,
421
421
+
) {
422
422
+
AlertDialog(
423
423
+
onDismissRequest = onDismissRequest,
424
424
+
confirmButton = confirmButton,
425
425
+
modifier = modifier,
426
426
+
dismissButton = dismissButton,
427
427
+
icon = icon,
428
428
+
title = title,
429
429
+
text = text,
430
430
+
shape = MaterialTheme.shapes.extraLarge,
431
431
+
containerColor = Color.White,
432
432
+
tonalElevation = 0.dp,
433
433
+
)
434
434
+
}
435
435
+
436
436
+
@Composable
437
437
+
fun ExpressiveNavigationBar(
438
438
+
modifier: Modifier = Modifier,
439
439
+
containerColor: Color,
440
440
+
contentColor: Color,
441
441
+
tonalElevation: Dp = 10.dp,
442
442
+
content: @Composable RowScope.() -> Unit,
443
443
+
) {
444
444
+
NavigationBar(
445
445
+
modifier = modifier,
446
446
+
containerColor = containerColor,
447
447
+
contentColor = contentColor,
448
448
+
tonalElevation = tonalElevation,
449
449
+
content = content,
450
450
+
)
451
451
+
}
452
452
+
453
453
+
@Composable
454
454
+
fun expressiveNavigationBarItemColors(
455
455
+
contentColor: Color,
456
456
+
selectedContainerColor: Color,
457
457
+
): NavigationBarItemColors = NavigationBarItemDefaults.colors(
458
458
+
indicatorColor = selectedContainerColor,
459
459
+
selectedIconColor = contentColor,
460
460
+
selectedTextColor = contentColor,
461
461
+
unselectedIconColor = contentColor.copy(alpha = 0.6f),
462
462
+
unselectedTextColor = contentColor.copy(alpha = 0.6f),
463
463
+
)
464
464
+
465
465
+
@Composable
466
466
+
fun RowScope.ExpressiveNavigationBarItem(
467
467
+
selected: Boolean,
468
468
+
onClick: () -> Unit,
469
469
+
icon: @Composable () -> Unit,
470
470
+
modifier: Modifier = Modifier,
471
471
+
enabled: Boolean = true,
472
472
+
label: @Composable (() -> Unit)? = null,
473
473
+
alwaysShowLabel: Boolean = true,
474
474
+
colors: NavigationBarItemColors,
475
475
+
) {
476
476
+
NavigationBarItem(
477
477
+
selected = selected,
478
478
+
onClick = onClick,
479
479
+
icon = icon,
480
480
+
modifier = modifier,
481
481
+
enabled = enabled,
482
482
+
label = label,
483
483
+
alwaysShowLabel = alwaysShowLabel,
484
484
+
colors = colors,
485
485
+
)
486
486
+
}
487
487
+
488
488
+
@Composable
489
489
+
fun ExpressiveScaffold(
490
490
+
modifier: Modifier = Modifier,
491
491
+
topBar: @Composable () -> Unit = {},
492
492
+
bottomBar: @Composable () -> Unit = {},
493
493
+
snackbarHost: @Composable () -> Unit = {},
494
494
+
floatingActionButton: @Composable () -> Unit = {},
495
495
+
floatingActionButtonPosition: FabPosition = FabPosition.End,
496
496
+
containerColor: Color = MaterialTheme.colorScheme.background,
497
497
+
contentColor: Color = MaterialTheme.colorScheme.onBackground,
498
498
+
contentWindowInsets: WindowInsets = WindowInsets.safeDrawing,
499
499
+
content: @Composable (PaddingValues) -> Unit,
500
500
+
) {
501
501
+
Scaffold(
502
502
+
modifier = modifier,
503
503
+
topBar = topBar,
504
504
+
bottomBar = bottomBar,
505
505
+
snackbarHost = snackbarHost,
506
506
+
floatingActionButton = floatingActionButton,
507
507
+
floatingActionButtonPosition = floatingActionButtonPosition,
508
508
+
containerColor = containerColor,
509
509
+
contentColor = contentColor,
510
510
+
contentWindowInsets = contentWindowInsets,
511
511
+
content = content,
512
512
+
)
513
513
+
}
514
514
+
515
515
+
@Composable
516
516
+
fun ExpressiveBadge(
517
517
+
modifier: Modifier = Modifier,
518
518
+
containerColor: Color = MaterialTheme.colorScheme.error,
519
519
+
contentColor: Color = MaterialTheme.colorScheme.onError,
520
520
+
content: @Composable (RowScope.() -> Unit)? = null,
521
521
+
) {
522
522
+
Badge(
523
523
+
modifier = modifier,
524
524
+
containerColor = containerColor,
525
525
+
contentColor = contentColor,
526
526
+
content = content,
527
527
+
)
528
528
+
}
529
529
+
530
530
+
@Composable
531
531
+
fun ExpressiveBadgedBox(
532
532
+
badge: @Composable BoxScope.() -> Unit,
533
533
+
modifier: Modifier = Modifier,
534
534
+
content: @Composable BoxScope.() -> Unit,
535
535
+
) {
536
536
+
BadgedBox(
537
537
+
badge = badge,
538
538
+
modifier = modifier,
539
539
+
content = content,
540
540
+
)
541
541
+
}
542
542
+
543
543
+
@Composable
544
544
+
fun ExpressiveHorizontalDivider(
545
545
+
modifier: Modifier = Modifier,
546
546
+
color: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
547
547
+
thickness: Dp = 1.dp,
548
548
+
) {
549
549
+
HorizontalDivider(
550
550
+
modifier = modifier,
551
551
+
thickness = thickness,
552
552
+
color = color,
553
553
+
)
554
554
+
}
555
555
+
556
556
+
@OptIn(ExperimentalMaterial3Api::class)
557
557
+
@Composable
558
558
+
fun ExpressivePullToRefreshBox(
559
559
+
isRefreshing: Boolean,
560
560
+
onRefresh: () -> Unit,
561
561
+
modifier: Modifier = Modifier,
562
562
+
content: @Composable BoxScope.() -> Unit,
563
563
+
) {
564
564
+
PullToRefreshBox(
565
565
+
isRefreshing = isRefreshing,
566
566
+
onRefresh = onRefresh,
567
567
+
modifier = modifier,
568
568
+
content = content,
569
569
+
)
570
570
+
}
571
571
+
572
572
+
@Composable
573
573
+
fun ExpressiveFilledTonalIconButton(
574
574
+
onClick: () -> Unit,
575
575
+
modifier: Modifier = Modifier,
576
576
+
enabled: Boolean = true,
577
577
+
content: @Composable () -> Unit,
578
578
+
) {
579
579
+
val interactionSource = remember { MutableInteractionSource() }
580
580
+
FilledTonalIconButton(
581
581
+
onClick = onClick,
582
582
+
modifier = modifier.expressivePressScale(interactionSource),
583
583
+
enabled = enabled,
584
584
+
interactionSource = interactionSource,
585
585
+
content = content,
586
586
+
)
587
587
+
}
588
588
+
589
589
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
590
590
+
@Composable
591
591
+
fun ExpressiveToggleButton(
592
592
+
checked: Boolean,
593
593
+
onCheckedChange: (Boolean) -> Unit,
594
594
+
modifier: Modifier = Modifier,
595
595
+
enabled: Boolean = true,
596
596
+
shapes: ToggleButtonShapes,
597
597
+
content: @Composable RowScope.() -> Unit,
598
598
+
) {
599
599
+
ToggleButton(
600
600
+
checked = checked,
601
601
+
onCheckedChange = onCheckedChange,
602
602
+
modifier = modifier,
603
603
+
enabled = enabled,
604
604
+
shapes = shapes,
605
605
+
content = content,
606
606
+
)
607
607
+
}
608
608
+
609
609
+
@Composable
610
610
+
fun ExpressiveLoadingIndicator(
611
611
+
modifier: Modifier = Modifier,
612
612
+
color: Color = MaterialTheme.colorScheme.primary,
613
613
+
size: Dp = 48.dp,
614
614
+
) {
615
615
+
CircularProgressIndicator(
616
616
+
modifier = modifier.size(size),
617
617
+
color = color,
618
618
+
trackColor = color.copy(alpha = 0.18f),
619
619
+
strokeWidth = 5.dp,
620
620
+
gapSize = 3.dp,
621
621
+
)
622
622
+
}
623
623
+
624
624
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
625
625
+
@Composable
626
626
+
fun ExpressiveTopLoadingIndicator(
627
627
+
modifier: Modifier = Modifier,
628
628
+
color: Color = MaterialTheme.colorScheme.primary,
629
629
+
trackColor: Color = color.copy(alpha = 0.18f),
630
630
+
) {
631
631
+
LinearWavyProgressIndicator(
632
632
+
modifier = modifier.fillMaxWidth().height(18.dp),
633
633
+
color = color,
634
634
+
trackColor = trackColor,
635
635
+
stroke = WavyProgressIndicatorDefaults.linearIndicatorStroke,
636
636
+
trackStroke = WavyProgressIndicatorDefaults.linearTrackStroke,
637
637
+
wavelength = WavyProgressIndicatorDefaults.LinearIndeterminateWavelength,
638
638
+
waveSpeed = 12.dp,
639
639
+
)
640
640
+
}
641
641
+
642
642
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
643
643
+
@Composable
644
644
+
fun ExpressiveTopProgressIndicator(
645
645
+
progress: () -> Float,
646
646
+
modifier: Modifier = Modifier,
647
647
+
color: Color = MaterialTheme.colorScheme.primary,
648
648
+
trackColor: Color = color.copy(alpha = 0.18f),
649
649
+
) {
650
650
+
LinearWavyProgressIndicator(
651
651
+
progress = progress,
652
652
+
modifier = modifier.fillMaxWidth().height(18.dp),
653
653
+
color = color,
654
654
+
trackColor = trackColor,
655
655
+
stroke = WavyProgressIndicatorDefaults.linearIndicatorStroke,
656
656
+
trackStroke = WavyProgressIndicatorDefaults.linearTrackStroke,
657
657
+
wavelength = WavyProgressIndicatorDefaults.LinearDeterminateWavelength,
658
658
+
amplitude = WavyProgressIndicatorDefaults.indicatorAmplitude,
659
659
+
stopSize = WavyProgressIndicatorDefaults.LinearTrackStopIndicatorSize,
660
660
+
gapSize = WavyProgressIndicatorDefaults.LinearIndicatorTrackGapSize,
661
661
+
)
662
662
+
}
663
663
+
664
664
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
665
665
+
@Composable
666
666
+
fun ExpressiveLinearProgressIndicator(
667
667
+
progress: () -> Float,
668
668
+
modifier: Modifier = Modifier,
669
669
+
color: Color = MaterialTheme.colorScheme.primary,
670
670
+
trackColor: Color = color.copy(alpha = 0.18f),
671
671
+
) {
672
672
+
LinearWavyProgressIndicator(
673
673
+
progress = progress,
674
674
+
modifier = modifier,
675
675
+
color = color,
676
676
+
trackColor = trackColor,
677
677
+
stroke = WavyProgressIndicatorDefaults.linearIndicatorStroke,
678
678
+
trackStroke = WavyProgressIndicatorDefaults.linearTrackStroke,
679
679
+
wavelength = WavyProgressIndicatorDefaults.LinearDeterminateWavelength,
680
680
+
amplitude = { WavyProgressIndicatorDefaults.indicatorAmplitude(it) * 0.45f },
681
681
+
stopSize = WavyProgressIndicatorDefaults.LinearTrackStopIndicatorSize,
682
682
+
gapSize = WavyProgressIndicatorDefaults.LinearIndicatorTrackGapSize,
683
683
+
)
684
684
+
}
685
685
+
686
686
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
687
687
+
@Composable
688
688
+
fun ExpressiveLinearProgressIndicator(
689
689
+
modifier: Modifier = Modifier,
690
690
+
color: Color = MaterialTheme.colorScheme.primary,
691
691
+
trackColor: Color = color.copy(alpha = 0.18f),
692
692
+
) {
693
693
+
LinearWavyProgressIndicator(
694
694
+
modifier = modifier,
695
695
+
color = color,
696
696
+
trackColor = trackColor,
697
697
+
stroke = WavyProgressIndicatorDefaults.linearIndicatorStroke,
698
698
+
trackStroke = WavyProgressIndicatorDefaults.linearTrackStroke,
699
699
+
wavelength = WavyProgressIndicatorDefaults.LinearIndeterminateWavelength,
700
700
+
waveSpeed = 8.dp,
701
701
+
)
702
702
+
}
703
703
+
704
704
+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
705
705
+
@Composable
706
706
+
fun ExpressiveSlider(
707
707
+
value: Float,
708
708
+
onValueChange: (Float) -> Unit,
709
709
+
modifier: Modifier = Modifier,
710
710
+
enabled: Boolean = true,
711
711
+
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
712
712
+
steps: Int = 0,
713
713
+
) {
714
714
+
val interactionSource = remember { MutableInteractionSource() }
715
715
+
val colors = SliderDefaults.colors(
716
716
+
thumbColor = MaterialTheme.colorScheme.secondary,
717
717
+
activeTrackColor = MaterialTheme.colorScheme.secondary,
718
718
+
inactiveTrackColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.18f),
719
719
+
activeTickColor = MaterialTheme.colorScheme.onSecondary,
720
720
+
inactiveTickColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.38f),
721
721
+
)
722
722
+
Slider(
723
723
+
value = value,
724
724
+
onValueChange = onValueChange,
725
725
+
modifier = modifier,
726
726
+
enabled = enabled,
727
727
+
valueRange = valueRange,
728
728
+
steps = steps,
729
729
+
colors = colors,
730
730
+
interactionSource = interactionSource,
731
731
+
thumb = { sliderState ->
732
732
+
SliderDefaults.Thumb(
733
733
+
interactionSource = interactionSource,
734
734
+
sliderState = sliderState,
735
735
+
colors = colors,
736
736
+
enabled = enabled,
737
737
+
thumbSize = DpSize(6.dp, 34.dp),
738
738
+
)
739
739
+
},
740
740
+
track = { sliderState ->
741
741
+
val stopColor = MaterialTheme.colorScheme.secondary
742
742
+
SliderDefaults.Track(
743
743
+
sliderState = sliderState,
744
744
+
colors = colors,
745
745
+
enabled = enabled,
746
746
+
trackInsideCornerSize = 8.dp,
747
747
+
thumbTrackGapSize = 6.dp,
748
748
+
drawStopIndicator = { offset ->
749
749
+
drawCircle(
750
750
+
color = stopColor,
751
751
+
radius = 4.dp.toPx(),
752
752
+
center = offset,
753
753
+
)
754
754
+
},
755
755
+
)
756
756
+
},
757
757
+
)
758
758
+
}
···
1
1
+
package com.derakkuma.ui.components
2
2
+
3
3
+
import androidx.compose.foundation.BorderStroke
4
4
+
import androidx.compose.foundation.background
5
5
+
import androidx.compose.foundation.border
6
6
+
import androidx.compose.foundation.layout.Box
7
7
+
import androidx.compose.foundation.layout.BoxScope
8
8
+
import androidx.compose.foundation.layout.size
9
9
+
import androidx.compose.foundation.layout.padding
10
10
+
import androidx.compose.foundation.shape.RoundedCornerShape
11
11
+
import androidx.compose.material3.MaterialTheme
12
12
+
import androidx.compose.material3.Surface
13
13
+
import androidx.compose.runtime.Composable
14
14
+
import androidx.compose.ui.Modifier
15
15
+
import androidx.compose.ui.draw.clip
16
16
+
import androidx.compose.ui.graphics.Color
17
17
+
import androidx.compose.ui.graphics.Shape
18
18
+
import androidx.compose.ui.unit.Dp
19
19
+
import androidx.compose.ui.unit.dp
20
20
+
21
21
+
@Composable
22
22
+
fun Modifier.expressiveScreenBackground(): Modifier =
23
23
+
background(MaterialTheme.colorScheme.background)
24
24
+
25
25
+
@Composable
26
26
+
fun ExpressiveGlassCard(
27
27
+
modifier: Modifier = Modifier,
28
28
+
cornerRadius: Dp = 28.dp,
29
29
+
containerColor: Color = Color.White,
30
30
+
content: @Composable BoxScope.() -> Unit,
31
31
+
) {
32
32
+
val shape = RoundedCornerShape(cornerRadius)
33
33
+
Surface(
34
34
+
modifier = modifier,
35
35
+
shape = shape,
36
36
+
color = containerColor,
37
37
+
tonalElevation = 0.dp,
38
38
+
shadowElevation = 0.dp,
39
39
+
) {
40
40
+
Box(
41
41
+
modifier = Modifier
42
42
+
.clip(shape)
43
43
+
.background(containerColor)
44
44
+
.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)), shape),
45
45
+
content = content,
46
46
+
)
47
47
+
}
48
48
+
}
49
49
+
50
50
+
@Composable
51
51
+
fun ExpressiveIconSurface(
52
52
+
modifier: Modifier = Modifier,
53
53
+
size: Dp = 40.dp,
54
54
+
shape: Shape = MaterialTheme.shapes.extraLarge,
55
55
+
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
56
56
+
content: @Composable BoxScope.() -> Unit,
57
57
+
) {
58
58
+
Box(
59
59
+
modifier = modifier
60
60
+
.size(size)
61
61
+
.clip(shape)
62
62
+
.background(containerColor),
63
63
+
content = content,
64
64
+
)
65
65
+
}
66
66
+
67
67
+
@Composable
68
68
+
fun ExpressiveBadgeSurface(
69
69
+
modifier: Modifier = Modifier,
70
70
+
shape: Shape = MaterialTheme.shapes.extraLarge,
71
71
+
containerColor: Color,
72
72
+
content: @Composable BoxScope.() -> Unit,
73
73
+
) {
74
74
+
Box(
75
75
+
modifier = modifier
76
76
+
.clip(shape)
77
77
+
.background(containerColor),
78
78
+
content = content,
79
79
+
)
80
80
+
}
81
81
+
82
82
+
@Composable
83
83
+
fun ExpressiveDot(
84
84
+
modifier: Modifier = Modifier,
85
85
+
size: Dp = 10.dp,
86
86
+
color: Color,
87
87
+
) {
88
88
+
Box(
89
89
+
modifier = modifier
90
90
+
.size(size)
91
91
+
.clip(MaterialTheme.shapes.extraLarge)
92
92
+
.background(color)
93
93
+
.padding(0.dp),
94
94
+
)
95
95
+
}
···
40
40
@Composable
41
41
fun BigStat(value: String, label: String) {
42
42
Column(horizontalAlignment = Alignment.CenterHorizontally) {
43
43
-
Text(value, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = DarkText)
44
44
-
Text(label, fontSize = 12.sp, color = MutedText)
43
43
+
Text(value, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
44
44
+
Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
45
45
}
46
46
}
47
47
48
48
@Composable
49
49
fun LoadingState(modifier: Modifier = Modifier.fillMaxWidth().padding(top = 60.dp), color: Color = MaterialTheme.colorScheme.onBackground) {
50
50
Box(modifier, contentAlignment = Alignment.Center) {
51
51
-
CircularProgressIndicator(color = color)
51
51
+
ExpressiveLoadingIndicator(color = color)
52
52
}
53
53
}
54
54
···
61
61
) {
62
62
Text(error, fontSize = 14.sp, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Medium)
63
63
if (onRetry != null) {
64
64
-
TextButton(onClick = onRetry) {
65
65
-
Text("Retry", color = PrimaryBlue, fontWeight = FontWeight.SemiBold)
64
64
+
ExpressiveButton(
65
65
+
onClick = onRetry,
66
66
+
colors = ButtonDefaults.filledTonalButtonColors(
67
67
+
containerColor = MaterialTheme.colorScheme.errorContainer,
68
68
+
contentColor = MaterialTheme.colorScheme.onErrorContainer,
69
69
+
),
70
70
+
) {
71
71
+
Text("Retry", fontWeight = FontWeight.Bold)
66
72
}
67
73
}
68
74
}
···
71
77
@Composable
72
78
fun EmptyState(message: String, modifier: Modifier = Modifier.fillMaxWidth().padding(24.dp)) {
73
79
Box(modifier, contentAlignment = Alignment.Center) {
74
74
-
Text(message, color = MutedText, fontWeight = FontWeight.Medium)
80
80
+
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
75
81
}
76
82
}
77
83
···
84
90
85
91
@Composable
86
92
fun EmptyCard(text: String, modifier: Modifier = Modifier.fillMaxWidth()) {
87
87
-
InfoCard(text, MutedText, modifier)
93
93
+
InfoCard(text, MaterialTheme.colorScheme.onSurfaceVariant, modifier)
88
94
}
89
95
90
96
@Composable
91
97
fun SectionHeader(text: String, modifier: Modifier = Modifier.padding(top = 8.dp, start = 4.dp)) {
92
92
-
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = modifier)
98
98
+
Text(
99
99
+
text,
100
100
+
style = MaterialTheme.typography.titleSmall,
101
101
+
color = MaterialTheme.colorScheme.onSurface,
102
102
+
modifier = modifier,
103
103
+
)
93
104
}
94
105
95
106
@Composable
96
107
fun DerakkumaCard(
97
108
modifier: Modifier = Modifier.fillMaxWidth(),
98
98
-
containerColor: Color = CardWhite,
99
99
-
cornerRadius: Dp = 8.dp,
109
109
+
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
110
110
+
cornerRadius: Dp = 24.dp,
100
111
content: @Composable () -> Unit,
101
112
) {
102
102
-
Card(
113
113
+
ExpressiveGlassCard(
103
114
modifier = modifier,
104
104
-
shape = androidx.compose.foundation.shape.RoundedCornerShape(cornerRadius),
105
105
-
colors = CardDefaults.cardColors(containerColor = containerColor),
115
115
+
cornerRadius = cornerRadius,
116
116
+
containerColor = containerColor,
106
117
) {
107
118
content()
108
119
}
···
19
19
import androidx.compose.foundation.layout.width
20
20
import androidx.compose.foundation.layout.widthIn
21
21
import androidx.compose.foundation.shape.RoundedCornerShape
22
22
-
import androidx.compose.material3.Card
23
23
-
import androidx.compose.material3.CardDefaults
22
22
+
import androidx.compose.material3.MaterialTheme
24
23
import androidx.compose.material3.Text
25
24
import androidx.compose.runtime.Composable
26
25
import androidx.compose.ui.Alignment
···
43
42
import com.derakkuma.ui.images.ratingPlateImage
44
43
import com.derakkuma.ui.images.rememberBundledImage
45
44
import com.derakkuma.ui.images.trophyPlateImage
46
46
-
import com.derakkuma.ui.theme.CardWhite
47
47
-
import com.derakkuma.ui.theme.DarkText
48
48
-
import com.derakkuma.ui.theme.MutedText
49
49
-
import com.derakkuma.ui.theme.PrimaryBlue
50
45
51
46
@Composable
52
47
fun MaimaiProfile(
···
72
67
details: @Composable ColumnScope.() -> Unit = {},
73
68
actions: @Composable RowScope.() -> Unit = {},
74
69
) {
75
75
-
Card(
70
70
+
ExpressiveGlassCard(
76
71
modifier = modifier
77
72
.fillMaxWidth()
78
73
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
79
79
-
shape = RoundedCornerShape(8.dp),
80
80
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
74
74
+
cornerRadius = 24.dp,
81
75
) {
82
76
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
83
77
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
···
105
99
"×$stars",
106
100
fontSize = ratingTextSize,
107
101
fontWeight = if (ratingPlate != null) FontWeight.SemiBold else FontWeight.Normal,
108
108
-
color = if (ratingPlate != null) DarkText else MutedText,
102
102
+
color = if (ratingPlate != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
109
103
)
110
104
}
111
105
ratingTrailing()
···
118
112
}
119
113
120
114
if (showComment && comment.isNotBlank()) {
121
121
-
Text(comment, fontSize = 12.sp, color = MutedText)
115
115
+
Text(comment, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
122
116
}
123
117
124
118
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
···
135
129
fontSize: TextUnit,
136
130
) {
137
131
if (ratingPlate == null) {
138
138
-
Text("Rating $rating", fontSize = fontSize, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
132
132
+
Text("Rating $rating", fontSize = fontSize, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
139
133
return
140
134
}
141
135
···
150
144
)
151
145
}
152
146
Text(
153
153
-
"$rating ",
147
147
+
"$rating ",
154
148
fontSize = fontSize,
155
149
fontWeight = FontWeight.ExtraBold,
156
156
-
color = Color.White,
150
150
+
color = MaterialTheme.colorScheme.onPrimary,
157
151
)
158
152
}
159
153
}
···
177
171
) {
178
172
MaimaiRemoteImage(
179
173
url = iconUrl,
180
180
-
modifier = Modifier.size(iconSize).clip(RoundedCornerShape(6.dp)),
174
174
+
modifier = Modifier.size(iconSize).clip(RoundedCornerShape(2.dp)),
181
175
contentDescription = "Profile icon",
182
176
contentScale = ContentScale.Crop,
183
177
)
···
188
182
modifier = Modifier.weight(1f),
189
183
verticalArrangement = Arrangement.spacedBy(3.dp),
190
184
) {
191
191
-
Text(name, fontSize = nameFontSize, fontWeight = FontWeight.Bold, color = DarkText, maxLines = 1)
185
185
+
Text(name, fontSize = nameFontSize, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
192
186
if (title.isNotBlank()) {
193
187
MaimaiTrophyTitle(title = title, rarity = titleRarity)
194
188
}
···
249
243
textAlign = TextAlign.Center,
250
244
style = TextStyle(
251
245
fontSize = 12.sp,
252
252
-
color = Color.White,
246
246
+
color = MaterialTheme.colorScheme.onPrimary,
253
247
shadow = Shadow(
254
254
-
color = Color.Black,
248
248
+
color = MaterialTheme.colorScheme.scrim,
255
249
blurRadius = 4f,
256
250
),
257
251
),
···
1
1
package com.derakkuma.ui.components
2
2
3
3
+
import androidx.compose.material3.MaterialTheme
3
4
import androidx.compose.material3.Text
4
5
import androidx.compose.runtime.Composable
5
6
import androidx.compose.runtime.LaunchedEffect
···
10
11
import androidx.compose.ui.text.font.FontWeight
11
12
import androidx.compose.ui.unit.sp
12
13
import com.derakkuma.data.MaintenanceMode
13
13
-
import com.derakkuma.ui.theme.CardWhite
14
14
import kotlinx.coroutines.delay
15
15
16
16
@Composable
···
38
38
countdown,
39
39
fontSize = 20.sp,
40
40
fontWeight = FontWeight.ExtraBold,
41
41
-
color = CardWhite,
41
41
+
color = MaterialTheme.colorScheme.onError,
42
42
)
43
43
},
44
44
)
···
10
10
import androidx.compose.foundation.layout.padding
11
11
import androidx.compose.foundation.layout.size
12
12
import androidx.compose.foundation.layout.width
13
13
+
import androidx.compose.material3.MaterialTheme
13
14
import androidx.compose.material3.Text
14
15
import androidx.compose.runtime.Composable
15
16
import androidx.compose.runtime.LaunchedEffect
···
26
27
import androidx.compose.ui.unit.dp
27
28
import androidx.compose.ui.unit.sp
28
29
import com.derakkuma.ui.images.MaimaiAssets
29
29
-
import com.derakkuma.ui.theme.CardWhite
30
30
-
import com.derakkuma.ui.theme.DangerRed
31
30
32
31
@Composable
33
32
fun MessageBanner(
34
33
title: String,
35
34
message: String,
36
35
contentDescription: String,
37
37
-
backgroundColor: Color = DangerRed.copy(alpha = 0.9f),
36
36
+
backgroundColor: Color = MaterialTheme.colorScheme.error.copy(alpha = 0.9f),
38
37
modifier: Modifier = Modifier,
39
38
messageAlpha: Float = 0.75f,
40
39
fallbackIcon: @Composable (() -> Unit)? = null,
41
40
trailingContent: @Composable (() -> Unit)? = null,
42
41
) {
43
42
var charaBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
43
43
+
val contentColor = MaterialTheme.colorScheme.onError
44
44
LaunchedEffect(Unit) {
45
45
charaBitmap = MaimaiAssets.load("chara_01.png")
46
46
}
···
73
73
title,
74
74
fontSize = 14.sp,
75
75
fontWeight = FontWeight.Bold,
76
76
-
color = CardWhite,
76
76
+
color = contentColor,
77
77
)
78
78
Text(
79
79
message,
80
80
fontSize = 11.sp,
81
81
-
color = CardWhite.copy(alpha = messageAlpha),
81
81
+
color = contentColor.copy(alpha = messageAlpha),
82
82
)
83
83
}
84
84
···
5
5
import androidx.compose.animation.fadeOut
6
6
import androidx.compose.foundation.layout.*
7
7
import androidx.compose.foundation.shape.RoundedCornerShape
8
8
-
import androidx.compose.material3.Card
9
9
-
import androidx.compose.material3.CardDefaults
10
10
-
import androidx.compose.material3.Snackbar
8
8
+
import androidx.compose.material3.MaterialTheme
11
9
import androidx.compose.material3.Text
12
10
import androidx.compose.runtime.*
13
11
import androidx.compose.ui.Alignment
···
70
68
71
69
var toastText by remember { mutableStateOf<String?>(null) }
72
70
val scope = rememberCoroutineScope()
71
71
+
val inactiveHeatColor = MaterialTheme.colorScheme.surfaceVariant
72
72
+
val activeHeatColor = MaterialTheme.colorScheme.secondary
73
73
74
74
-
Card(
74
74
+
ExpressiveGlassCard(
75
75
modifier = modifier.fillMaxWidth(),
76
76
-
shape = RoundedCornerShape(8.dp),
77
77
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
76
76
+
cornerRadius = 24.dp,
78
77
) {
79
78
Column(modifier = Modifier.padding(12.dp)) {
80
80
-
Text("Play Activity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
79
79
+
Text("Play Activity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
81
80
Spacer(Modifier.height(6.dp))
82
81
83
82
HeatMap(
···
86
85
heatStyle = HeatStyle(
87
86
height = 14.dp,
88
87
heatColor = HeatColor(
89
89
-
inactiveColor = Color(0xFFE5E7EB),
90
90
-
activeLowestColor = Color(0xFFE5E7EB), // same as inactive so 0-play days look gray
91
91
-
activeHighestColor = Color(0xFFFF699D),
88
88
+
inactiveColor = inactiveHeatColor,
89
89
+
activeLowestColor = inactiveHeatColor, // same as inactive so 0-play days look gray
90
90
+
activeHighestColor = activeHeatColor,
92
91
),
93
92
heatShape = RoundedCornerShape(2.dp),
94
93
showHeatValue = false,
···
118
117
) {
119
118
Column {
120
119
Spacer(Modifier.height(6.dp))
121
121
-
Snackbar(
120
120
+
ExpressiveSnackbar(
122
121
modifier = Modifier.fillMaxWidth(),
123
123
-
shape = RoundedCornerShape(6.dp),
124
122
) {
125
123
Text(toastText ?: "", fontSize = 12.sp)
126
124
}
···
2
2
3
3
import androidx.compose.foundation.background
4
4
import androidx.compose.foundation.layout.*
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
5
5
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
6
6
+
import androidx.compose.material3.MaterialShapes
7
7
+
import androidx.compose.material3.MaterialTheme
6
8
import androidx.compose.material3.Text
9
9
+
import androidx.compose.material3.toShape
7
10
import androidx.compose.runtime.Composable
8
11
import androidx.compose.ui.Alignment
9
12
import androidx.compose.ui.Modifier
10
13
import androidx.compose.ui.draw.clip
14
14
+
import androidx.compose.ui.graphics.Color
11
15
import androidx.compose.ui.layout.ContentScale
12
16
import androidx.compose.ui.text.font.FontWeight
13
17
import androidx.compose.ui.text.style.TextOverflow
···
24
28
* Reusable song row with album art, difficulty badge, name, badges, and score.
25
29
* Used in both Record (playlog) and Rating tabs.
26
30
*/
31
31
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
27
32
@Composable
28
33
fun SongRow(
29
34
name: String,
···
43
48
datetime: String = "",
44
49
// Whether to use playlog-style rank images or music_icon style
45
50
usePlaylogRanks: Boolean = true,
51
51
+
containerColor: Color = Color.Transparent,
46
52
) {
47
53
Row(
48
48
-
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
54
54
+
modifier = Modifier
55
55
+
.fillMaxWidth()
56
56
+
.clip(MaterialTheme.shapes.extraLarge)
57
57
+
.background(containerColor)
58
58
+
.padding(horizontal = 12.dp, vertical = 8.dp),
49
59
verticalAlignment = Alignment.CenterVertically,
60
60
+
horizontalArrangement = Arrangement.spacedBy(12.dp),
50
61
) {
51
51
-
// Album art
52
52
-
Box(modifier = Modifier.size(artSize)) {
53
53
-
if (albumArtUrl != null) {
62
62
+
// Album art
63
63
+
Box(modifier = Modifier.size(artSize)) {
64
64
+
if (albumArtUrl != null) {
65
65
+
MaimaiRemoteImage(
66
66
+
url = albumArtUrl,
67
67
+
modifier = Modifier.fillMaxSize().clip(MaterialShapes.Square.toShape()),
68
68
+
contentDescription = name,
69
69
+
contentScale = ContentScale.Crop,
70
70
+
)
71
71
+
} else {
72
72
+
Box(
73
73
+
modifier = Modifier.fillMaxSize()
74
74
+
.clip(MaterialShapes.Square.toShape())
75
75
+
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)),
76
76
+
)
77
77
+
}
78
78
+
// Difficulty badge overlaid bottom-left
54
79
MaimaiRemoteImage(
55
55
-
url = albumArtUrl,
56
56
-
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)),
57
57
-
contentDescription = name,
58
58
-
contentScale = ContentScale.Crop,
59
59
-
)
60
60
-
} else {
61
61
-
Box(
62
62
-
modifier = Modifier.fillMaxSize()
63
63
-
.clip(RoundedCornerShape(4.dp))
64
64
-
.background(MutedText.copy(alpha = 0.1f)),
80
80
+
url = "$IMG_BASE/diff_$difficulty.png",
81
81
+
modifier = Modifier.height(14.dp).align(Alignment.BottomStart),
82
82
+
contentDescription = difficulty,
65
83
)
66
84
}
67
67
-
// Difficulty badge overlaid bottom-left
68
68
-
MaimaiRemoteImage(
69
69
-
url = "$IMG_BASE/diff_$difficulty.png",
70
70
-
modifier = Modifier.height(14.dp).align(Alignment.BottomStart),
71
71
-
contentDescription = difficulty,
72
72
-
)
73
73
-
}
74
85
75
75
-
Spacer(Modifier.width(8.dp))
76
76
-
77
77
-
// Song info
78
78
-
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
86
86
+
Column(
87
87
+
modifier = Modifier.weight(1f),
88
88
+
verticalArrangement = Arrangement.spacedBy(4.dp),
89
89
+
) {
79
90
// Title row
80
91
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
81
92
Text(
82
93
name,
83
94
fontSize = 13.sp,
84
95
fontWeight = FontWeight.SemiBold,
85
85
-
color = DarkText,
96
96
+
color = MaterialTheme.colorScheme.onSurface,
86
97
maxLines = 1,
87
98
overflow = TextOverflow.Ellipsis,
88
99
modifier = Modifier.weight(1f, fill = false),
···
128
139
if (placingImg != null) {
129
140
BundledIcon(image = placingImg, modifier = Modifier.height(14.dp), contentDescription = placing)
130
141
} else {
131
131
-
Text(placing, fontSize = 10.sp, color = MutedText)
142
142
+
Text(placing, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
132
143
}
133
144
}
134
145
}
135
146
}
136
147
137
137
-
// Score + rank column
138
138
-
Column(horizontalAlignment = Alignment.End) {
139
139
-
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
140
140
-
Text(
141
141
-
"${formatScore(score)}%",
142
142
-
fontSize = 13.sp,
143
143
-
fontWeight = FontWeight.Bold,
144
144
-
color = AccentPink,
145
145
-
)
146
146
-
val rankIcon = if (usePlaylogRanks) scoreRankImage(scoreRank) else musicIconImage(scoreRank)
147
147
-
if (rankIcon != null) {
148
148
-
BundledIcon(image = rankIcon, modifier = Modifier.height(16.dp), contentDescription = scoreRank)
148
148
+
// Score + rank column
149
149
+
Column(horizontalAlignment = Alignment.End) {
150
150
+
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
151
151
+
Text(
152
152
+
"${formatScore(score)}%",
153
153
+
fontSize = 13.sp,
154
154
+
fontWeight = FontWeight.Bold,
155
155
+
color = MaterialTheme.colorScheme.secondary,
156
156
+
)
157
157
+
val rankIcon = if (usePlaylogRanks) scoreRankImage(scoreRank) else musicIconImage(scoreRank)
158
158
+
if (rankIcon != null) {
159
159
+
BundledIcon(image = rankIcon, modifier = Modifier.height(16.dp), contentDescription = scoreRank)
160
160
+
}
149
161
}
150
150
-
}
151
151
-
if (dxScore.isNotBlank()) {
152
152
-
Text("DX $dxScore", fontSize = 10.sp, color = MutedText)
153
153
-
}
154
154
-
if (datetime.isNotBlank()) {
155
155
-
Text(datetime, fontSize = 10.sp, color = MutedText)
162
162
+
if (dxScore.isNotBlank()) {
163
163
+
Text("DX $dxScore", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
164
164
+
}
165
165
+
if (datetime.isNotBlank()) {
166
166
+
Text(datetime, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
167
167
+
}
156
168
}
157
157
-
}
158
169
}
159
170
}
···
1
1
package com.derakkuma.ui.components
2
2
3
3
-
import androidx.compose.foundation.background
4
4
-
import androidx.compose.foundation.clickable
5
5
-
import androidx.compose.foundation.layout.Box
6
3
import androidx.compose.foundation.layout.Column
7
4
import androidx.compose.foundation.layout.Row
8
8
-
import androidx.compose.foundation.layout.defaultMinSize
9
9
-
import androidx.compose.foundation.layout.fillMaxWidth
10
5
import androidx.compose.foundation.layout.padding
11
6
import androidx.compose.foundation.layout.size
12
12
-
import androidx.compose.foundation.shape.RoundedCornerShape
7
7
+
import androidx.compose.material3.ButtonGroupDefaults
8
8
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
13
9
import androidx.compose.material3.Icon
10
10
+
import androidx.compose.material3.MaterialTheme
14
11
import androidx.compose.material3.Text
15
12
import androidx.compose.runtime.Composable
16
13
import androidx.compose.ui.Alignment
17
14
import androidx.compose.ui.Modifier
18
18
-
import androidx.compose.ui.draw.clip
19
19
-
import androidx.compose.ui.graphics.Color
20
15
import androidx.compose.ui.graphics.vector.ImageVector
21
16
import androidx.compose.ui.text.font.FontWeight
22
22
-
import androidx.compose.ui.text.style.TextAlign
23
23
-
import androidx.compose.ui.unit.Dp
24
17
import androidx.compose.ui.unit.dp
25
18
import androidx.compose.ui.unit.sp
26
26
-
import com.derakkuma.ui.theme.CardWhite
27
27
-
import com.derakkuma.ui.theme.DarkText
28
28
-
import com.derakkuma.ui.theme.MutedText
29
29
-
import com.derakkuma.ui.theme.PrimaryBlue
30
19
20
20
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
31
21
@Composable
32
22
fun SplitButtonRow(
33
23
label: String,
···
38
28
description: String? = null,
39
29
disabled: Boolean = false,
40
30
icons: Map<Int, ImageVector>? = null,
41
41
-
minOptionWidth: Dp = 44.dp,
42
42
-
minOptionHeight: Dp = 38.dp,
43
43
-
unselectedContainerColor: Color = MutedText.copy(alpha = 0.1f),
44
31
) {
45
32
Row(
46
46
-
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
33
33
+
modifier = modifier.padding(vertical = 4.dp),
47
34
verticalAlignment = Alignment.CenterVertically,
48
35
) {
49
36
Column(modifier = Modifier.weight(1f)) {
50
50
-
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
37
37
+
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
51
38
if (!description.isNullOrBlank()) {
52
52
-
Text(description, fontSize = 11.sp, color = MutedText)
39
39
+
Text(description, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
53
40
}
54
41
}
55
42
56
56
-
Row(modifier = Modifier.clip(RoundedCornerShape(6.dp))) {
43
43
+
Row(
44
44
+
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(
45
45
+
ButtonGroupDefaults.ConnectedSpaceBetween,
46
46
+
),
47
47
+
) {
57
48
options.forEachIndexed { i, (value, optLabel) ->
58
49
val selectedOption = value == selected
59
59
-
val bgColor = if (selectedOption) PrimaryBlue else unselectedContainerColor
60
60
-
val textColor = if (selectedOption) CardWhite else DarkText
61
50
val icon = icons?.get(i)
62
62
-
63
63
-
Box(
64
64
-
modifier = Modifier
65
65
-
.defaultMinSize(minWidth = minOptionWidth, minHeight = minOptionHeight)
66
66
-
.background(if (disabled) bgColor.copy(alpha = 0.4f) else bgColor)
67
67
-
.clickable(enabled = !disabled) { onSelect(value) }
68
68
-
.padding(horizontal = 12.dp, vertical = 6.dp),
69
69
-
contentAlignment = Alignment.Center,
51
51
+
ExpressiveToggleButton(
52
52
+
checked = selectedOption,
53
53
+
onCheckedChange = { onSelect(value) },
54
54
+
enabled = !disabled,
55
55
+
shapes = when (i) {
56
56
+
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
57
57
+
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
58
58
+
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
59
59
+
},
70
60
) {
71
61
if (icon != null) {
72
72
-
Icon(icon, contentDescription = optLabel, modifier = Modifier.size(18.dp), tint = textColor)
62
62
+
Icon(icon, contentDescription = optLabel, modifier = Modifier.size(18.dp))
73
63
} else {
74
74
-
Text(
75
75
-
optLabel,
76
76
-
fontSize = 12.sp,
77
77
-
fontWeight = if (selectedOption) FontWeight.Bold else FontWeight.Medium,
78
78
-
color = textColor,
79
79
-
textAlign = TextAlign.Center,
80
80
-
)
64
64
+
Text(optLabel)
81
65
}
82
66
}
83
67
}
···
4
4
import androidx.compose.material.icons.Icons
5
5
import androidx.compose.material.icons.outlined.WifiOff
6
6
import androidx.compose.material3.Icon
7
7
+
import androidx.compose.material3.MaterialTheme
7
8
import androidx.compose.runtime.Composable
8
9
import androidx.compose.ui.Modifier
9
10
import androidx.compose.ui.unit.dp
10
10
-
import com.derakkuma.ui.theme.CardWhite
11
11
12
12
@Composable
13
13
fun VerizonNetworkBanner() {
···
21
21
imageVector = Icons.Outlined.WifiOff,
22
22
contentDescription = "Verizon network warning",
23
23
modifier = Modifier.size(40.dp),
24
24
-
tint = CardWhite,
24
24
+
tint = MaterialTheme.colorScheme.onError,
25
25
)
26
26
},
27
27
)
···
7
7
import androidx.compose.foundation.lazy.grid.GridCells
8
8
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
9
9
import androidx.compose.foundation.lazy.grid.items
10
10
-
import androidx.compose.foundation.shape.RoundedCornerShape
11
10
import androidx.compose.material3.*
12
11
import androidx.compose.runtime.*
13
12
import androidx.compose.ui.Alignment
···
31
30
import com.derakkuma.atproto.openOAuthUrl
32
31
import com.derakkuma.data.CacheStore
33
32
import com.derakkuma.donation.DonationUnlock
33
33
+
import com.derakkuma.ui.components.ExpressiveGlassCard
34
34
+
import com.derakkuma.ui.components.ExpressiveTextButton
35
35
+
import com.derakkuma.ui.components.ExpressiveAlertDialog
36
36
+
import com.derakkuma.ui.components.ExpressiveSnackbar
37
37
+
import com.derakkuma.ui.components.ExpressiveScaffold
34
38
import com.derakkuma.ui.components.SplitButtonRow
35
39
import com.derakkuma.ui.theme.*
36
40
import io.github.vinceglb.confettikit.compose.ConfettiKit
···
64
68
}
65
69
}
66
70
67
67
-
Scaffold(
71
71
+
ExpressiveScaffold(
68
72
contentWindowInsets = WindowInsets(0.dp),
69
73
) { padding ->
70
74
Column(
···
72
76
verticalArrangement = Arrangement.spacedBy(12.dp),
73
77
) {
74
78
Column(Modifier.padding(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
75
75
-
Text("App Icon", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
79
79
+
Text("App Icon", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
76
80
}
77
81
78
82
SplitButtonRow(
···
84
88
selected = previewMode,
85
89
onSelect = { previewMode = it },
86
90
modifier = Modifier.padding(horizontal = 12.dp),
87
87
-
minOptionWidth = 96.dp,
88
88
-
unselectedContainerColor = CardWhite,
89
91
)
90
92
91
93
LazyVerticalGrid(
···
114
116
}
115
117
116
118
pendingPartnerIcon?.let { option ->
117
117
-
AlertDialog(
119
119
+
ExpressiveAlertDialog(
118
120
onDismissRequest = { pendingPartnerIcon = null },
119
121
title = { Text("Support Derakkuma") },
120
122
text = {
···
124
126
}
125
127
},
126
128
confirmButton = {
127
127
-
TextButton(
129
129
+
ExpressiveTextButton(
128
130
onClick = {
129
131
scope.launch {
130
132
val nonce = DonationUnlock.friendCodeNonce(cacheStore)
···
137
139
},
138
140
dismissButton = {
139
141
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
140
140
-
TextButton(onClick = { pendingPartnerIcon = null }) {
142
142
+
ExpressiveTextButton(onClick = { pendingPartnerIcon = null }) {
141
143
Text("Not now")
142
144
}
143
143
-
TextButton(
145
145
+
ExpressiveTextButton(
144
146
onClick = {
145
147
scope.launch {
146
148
checkingRestore = true
···
167
169
168
170
if (snackMsg != null) {
169
171
Popup(alignment = Alignment.BottomCenter) {
170
170
-
Snackbar(
172
172
+
ExpressiveSnackbar(
171
173
modifier = Modifier.padding(12.dp),
172
172
-
shape = RoundedCornerShape(8.dp),
173
173
-
action = { TextButton(onClick = { snackMsg = null }) { Text("OK") } },
174
174
+
action = { ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") } },
174
175
) { Text(snackMsg!!) }
175
176
}
176
177
}
···
200
201
201
202
@Composable
202
203
private fun AppIconCard(option: AppIconOption, selected: Boolean, alternatePreview: Boolean, onClick: () -> Unit) {
203
203
-
Card(
204
204
+
ExpressiveGlassCard(
204
205
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
205
205
-
shape = RoundedCornerShape(26.dp),
206
206
-
colors = CardDefaults.cardColors(containerColor = if (selected) Color(0xFFFFCDDD) else CardWhite),
206
206
+
cornerRadius = 26.dp,
207
207
+
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
207
208
) {
208
209
Column(
209
210
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 8.dp),
···
211
212
verticalArrangement = Arrangement.spacedBy(6.dp),
212
213
) {
213
214
AppIconPreview(option = option, alternatePreview = alternatePreview)
214
214
-
Text(option.label, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = DarkText, maxLines = 1)
215
215
+
Text(
216
216
+
option.label,
217
217
+
fontSize = 11.sp,
218
218
+
fontWeight = FontWeight.SemiBold,
219
219
+
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
220
220
+
maxLines = 1,
221
221
+
)
215
222
}
216
223
}
217
224
}
···
235
242
value = if (usesIosPreview) null else loadAppIconPreviewAsset(fgName)
236
243
}
237
244
val (androidMonochromeBackground, androidMonochromeForeground) = platformMonochromeAppIconColors()
245
245
+
val defaultPreviewBackground = MaterialTheme.colorScheme.primaryContainer
238
246
239
239
-
val previewModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(18.dp)).let { modifier ->
240
240
-
if (usesIosPreview) modifier else modifier.background(if (alternatePreview) androidMonochromeBackground else Color(0xFFEAF7FF))
247
247
+
val previewModifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(MaterialTheme.shapes.extraLarge).let { modifier ->
248
248
+
if (usesIosPreview) modifier else modifier.background(if (alternatePreview) androidMonochromeBackground else defaultPreviewBackground)
241
249
}
242
250
243
251
Box(
···
2
2
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.rememberScrollState
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
6
5
import androidx.compose.foundation.verticalScroll
7
6
import androidx.compose.material.icons.Icons
8
7
import androidx.compose.material.icons.automirrored.filled.Logout
···
15
14
import androidx.compose.ui.unit.dp
16
15
import androidx.compose.ui.unit.sp
17
16
import com.derakkuma.data.CacheStore
17
17
+
import com.derakkuma.ui.components.ExpressiveButton
18
18
+
import com.derakkuma.ui.components.ExpressiveGlassCard
19
19
+
import com.derakkuma.ui.components.ExpressiveSwitch
20
20
+
import com.derakkuma.ui.components.ExpressiveOutlinedButton
21
21
+
import com.derakkuma.ui.components.ExpressiveTextButton
22
22
+
import com.derakkuma.ui.components.ExpressiveAlertDialog
23
23
+
import com.derakkuma.ui.components.ExpressiveSnackbar
24
24
+
import com.derakkuma.ui.components.ExpressiveScaffold
18
25
import com.derakkuma.ui.components.SplitButtonRow
19
26
import com.derakkuma.ui.theme.*
20
27
import io.github.samuolis.posthog.PostHog
···
31
38
var snackMsg by remember { mutableStateOf<String?>(null) }
32
39
var showLogoutConfirm by remember { mutableStateOf(false) }
33
40
34
34
-
Scaffold(
41
41
+
ExpressiveScaffold(
35
42
contentWindowInsets = WindowInsets(0.dp),
36
43
snackbarHost = {
37
44
if (snackMsg != null) {
38
38
-
Snackbar(
45
45
+
ExpressiveSnackbar(
39
46
modifier = Modifier.padding(12.dp),
40
40
-
shape = RoundedCornerShape(8.dp),
41
41
-
action = { TextButton(onClick = { snackMsg = null }) { Text("OK") } },
47
47
+
action = { ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") } },
42
48
) { Text(snackMsg!!) }
43
49
}
44
50
},
···
47
53
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, bottom = 88.dp),
48
54
verticalArrangement = Arrangement.spacedBy(8.dp),
49
55
) {
50
50
-
Text("App Settings", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(bottom = 4.dp))
56
56
+
Text("App Settings", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 4.dp))
51
57
// Account section
52
52
-
Card(
58
58
+
ExpressiveGlassCard(
53
59
modifier = Modifier.fillMaxWidth(),
54
54
-
shape = RoundedCornerShape(8.dp),
55
55
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
56
60
) {
57
61
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
58
58
-
Text("Account", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
59
59
-
Text("Sign out of your SEGA account. You'll need to log in again to access your data.", fontSize = 12.sp, color = MutedText)
60
60
-
OutlinedButton(
62
62
+
Text("Account", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
63
63
+
Text("Sign out of your SEGA account. You'll need to log in again to access your data.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
64
64
+
ExpressiveOutlinedButton(
61
65
onClick = { showLogoutConfirm = true },
62
66
modifier = Modifier.fillMaxWidth(),
63
63
-
shape = RoundedCornerShape(8.dp),
64
64
-
colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed),
65
67
) {
66
68
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp))
67
69
Spacer(Modifier.width(8.dp))
···
71
73
}
72
74
73
75
// Appearance section
74
74
-
Card(
76
76
+
ExpressiveGlassCard(
75
77
modifier = Modifier.fillMaxWidth(),
76
76
-
shape = RoundedCornerShape(8.dp),
77
77
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
78
78
) {
79
79
Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
80
80
Column {
81
81
-
Text("Appearance", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
82
82
-
Text("Customize Derakkuma's look and feel", fontSize = 12.sp, color = MutedText)
81
81
+
Text("Appearance", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
82
82
+
Text("Customize Derakkuma's look and feel", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
83
83
}
84
84
85
85
var selectedIconStyle by remember(iconStyle) { mutableStateOf(iconStyle.takeIf { it == "flat" } ?: "fun") }
···
101
101
verticalAlignment = Alignment.CenterVertically,
102
102
) {
103
103
Column(modifier = Modifier.weight(1f)) {
104
104
-
Text("Park animation", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
105
105
-
Text("Scrolling park animation above the navbar", fontSize = 11.sp, color = MutedText)
104
104
+
Text("Park animation", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
105
105
+
Text("Scrolling park animation above the navbar", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
106
106
}
107
107
var parkState by remember { mutableStateOf(cacheStore.getSetting("parkAnimation") != "false") }
108
108
-
Switch(
108
108
+
ExpressiveSwitch(
109
109
checked = parkState,
110
110
onCheckedChange = {
111
111
parkState = it
112
112
cacheStore.putSetting("parkAnimation", if (it) "true" else "false")
113
113
-
},
114
114
-
colors = SwitchDefaults.colors(checkedTrackColor = PrimaryBlue),
113
113
+
}
115
114
)
116
115
}
117
116
}
···
119
118
120
119
// Analytics section
121
120
if (Secrets.POSTHOG_API_KEY.isNotEmpty()) {
122
122
-
Card(
121
121
+
ExpressiveGlassCard(
123
122
modifier = Modifier.fillMaxWidth(),
124
124
-
shape = RoundedCornerShape(8.dp),
125
125
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
126
123
) {
127
124
Row(
128
125
modifier = Modifier.fillMaxWidth().padding(16.dp),
···
130
127
verticalAlignment = Alignment.CenterVertically,
131
128
) {
132
129
Column(modifier = Modifier.weight(1f)) {
133
133
-
Text("Analytics", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
134
134
-
Text("Fully anonymous and private", fontSize = 12.sp, color = MutedText)
130
130
+
Text("Analytics", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
131
131
+
Text("Fully anonymous and private", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
135
132
}
136
133
var analyticsEnabled by remember { mutableStateOf(cacheStore.getSetting("analyticsOptOut") != "true") }
137
137
-
Switch(
134
134
+
ExpressiveSwitch(
138
135
checked = analyticsEnabled,
139
136
onCheckedChange = {
140
137
analyticsEnabled = it
141
138
cacheStore.putSetting("analyticsOptOut", if (it) "false" else "true")
142
139
if (it) PostHog.optIn() else PostHog.optOut()
143
143
-
},
144
144
-
colors = SwitchDefaults.colors(checkedTrackColor = PrimaryBlue),
140
140
+
}
145
141
)
146
142
}
147
143
}
148
144
}
149
145
150
146
// Cache section
151
151
-
Card(
147
147
+
ExpressiveGlassCard(
152
148
modifier = Modifier.fillMaxWidth(),
153
153
-
shape = RoundedCornerShape(8.dp),
154
154
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
155
149
) {
156
150
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
157
157
-
Text("Cache", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
158
158
-
Text("Clear cached profile, play log, rating data, and app preferences. Useful if data appears wrong or corrupted.", fontSize = 12.sp, color = MutedText)
159
159
-
Button(
151
151
+
Text("Cache", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
152
152
+
Text("Clear cached profile, play log, rating data, and app preferences. Useful if data appears wrong or corrupted.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
153
153
+
ExpressiveButton(
160
154
onClick = {
161
155
cacheStore.clear()
162
156
snackMsg = "Cache cleared"
163
157
onCacheCleared()
164
158
},
165
159
modifier = Modifier.fillMaxWidth(),
166
166
-
shape = RoundedCornerShape(8.dp),
167
167
-
colors = ButtonDefaults.buttonColors(containerColor = DangerRed),
160
160
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
168
161
) {
169
162
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
170
163
Spacer(Modifier.width(8.dp))
···
174
167
}
175
168
176
169
// About section
177
177
-
Card(
170
170
+
ExpressiveGlassCard(
178
171
modifier = Modifier.fillMaxWidth(),
179
179
-
shape = RoundedCornerShape(8.dp),
180
180
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
181
172
) {
182
173
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
183
183
-
Text("About", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
174
174
+
Text("About", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
184
175
// Row(
185
176
// modifier = Modifier.fillMaxWidth(),
186
177
// horizontalArrangement = Arrangement.SpaceBetween,
···
195
186
// Text("Built with", fontSize = 13.sp, color = MutedText)
196
187
// Text("Compose Multiplatform", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
197
188
// }
198
198
-
Text("Derakkuma is an unofficial client for maimai DX NET. Not affiliated with SEGA.", fontSize = 11.sp, color = MutedText)
189
189
+
Text("Derakkuma is an unofficial client for maimai DX NET. Not affiliated with SEGA.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
199
190
}
200
191
}
201
192
}
···
203
194
204
195
// Logout confirmation dialog
205
196
if (showLogoutConfirm) {
206
206
-
AlertDialog(
197
197
+
ExpressiveAlertDialog(
207
198
onDismissRequest = { showLogoutConfirm = false },
208
199
title = { Text("Log Out?") },
209
200
text = { Text("You'll need to log in again to access your data.") },
210
201
confirmButton = {
211
211
-
TextButton(
202
202
+
ExpressiveTextButton(
212
203
onClick = {
213
204
showLogoutConfirm = false
214
205
onLogout()
215
206
},
216
216
-
colors = ButtonDefaults.textButtonColors(contentColor = DangerRed),
217
217
-
) { Text("Log Out", fontWeight = FontWeight.Bold) }
207
207
+
) { Text("Log Out", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) }
218
208
},
219
209
dismissButton = {
220
220
-
TextButton(onClick = { showLogoutConfirm = false }) { Text("Cancel") }
210
210
+
ExpressiveTextButton(onClick = { showLogoutConfirm = false }) { Text("Cancel") }
221
211
},
222
222
-
containerColor = CardWhite,
223
212
)
224
213
}
225
214
}
···
5
5
import androidx.compose.foundation.layout.padding
6
6
import androidx.compose.foundation.rememberScrollState
7
7
import androidx.compose.foundation.verticalScroll
8
8
+
import androidx.compose.material3.MaterialTheme
8
9
import androidx.compose.material3.Text
9
9
-
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
10
10
import androidx.compose.runtime.*
11
11
import androidx.compose.ui.Modifier
12
12
import androidx.compose.ui.text.font.FontWeight
···
17
17
import com.derakkuma.data.CacheStore
18
18
import com.derakkuma.data.MaimaiClient
19
19
import com.derakkuma.data.PlayStore
20
20
-
import com.derakkuma.ui.theme.DarkText
20
20
+
import com.derakkuma.ui.components.ExpressivePullToRefreshBox
21
21
22
22
@Composable
23
23
fun AtmosphereTab(
···
31
31
var refreshKey by remember { mutableStateOf(0) }
32
32
var refreshing by remember { mutableStateOf(false) }
33
33
34
34
-
PullToRefreshBox(
34
34
+
ExpressivePullToRefreshBox(
35
35
isRefreshing = refreshing,
36
36
onRefresh = {
37
37
refreshing = true
···
43
43
.verticalScroll(rememberScrollState())
44
44
.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 88.dp),
45
45
) {
46
46
-
Text("Atmosphere", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(bottom = 8.dp))
46
46
+
Text("Atmosphere", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp))
47
47
AtProtoSection(
48
48
client = atProtoClient,
49
49
cacheStore = cacheStore,
···
4
4
import androidx.compose.foundation.lazy.LazyColumn
5
5
import androidx.compose.foundation.lazy.items
6
6
import androidx.compose.foundation.shape.CircleShape
7
7
-
import androidx.compose.foundation.shape.RoundedCornerShape
7
7
+
import androidx.compose.material.icons.automirrored.outlined.BluetoothSearching
8
8
import androidx.compose.material.icons.Icons
9
9
-
import androidx.compose.material.icons.outlined.BluetoothSearching
10
9
import androidx.compose.material.icons.outlined.PersonAdd
11
10
import androidx.compose.material3.*
12
11
import androidx.compose.runtime.*
···
21
20
import com.derakkuma.data.CacheStore
22
21
import com.derakkuma.data.MaimaiClient
23
22
import com.derakkuma.data.Profile
23
23
+
import com.derakkuma.ui.components.ExpressiveButton
24
24
+
import com.derakkuma.ui.components.ExpressiveGlassCard
25
25
+
import com.derakkuma.ui.components.ExpressiveIconSurface
26
26
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
24
27
import com.derakkuma.ui.images.MaimaiRemoteImage
25
28
import com.derakkuma.ui.theme.*
26
29
import kotlinx.coroutines.launch
···
42
45
DisposableEffect(Unit) { onDispose { manager.stop() } }
43
46
44
47
Column(Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
45
45
-
Text("Bubble", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
46
46
-
Card(shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
48
48
+
Text("Bubble", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
49
49
+
ExpressiveGlassCard(cornerRadius = 28.dp) {
47
50
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
48
51
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
49
49
-
Surface(shape = CircleShape, color = PrimaryBlue.copy(alpha = 0.12f), modifier = Modifier.size(42.dp)) {
50
50
-
Box(contentAlignment = Alignment.Center) { Icon(Icons.Outlined.BluetoothSearching, null, tint = PrimaryBlue) }
52
52
+
ExpressiveIconSurface(size = 42.dp, containerColor = MaterialTheme.colorScheme.primaryContainer) {
53
53
+
Box(contentAlignment = Alignment.Center) { Icon(Icons.AutoMirrored.Outlined.BluetoothSearching, null, tint = MaterialTheme.colorScheme.primary) }
51
54
}
52
55
Column(Modifier.weight(1f)) {
53
53
-
Text("Nearby friend-code exchange", fontWeight = FontWeight.Bold, color = DarkText)
54
54
-
Text("Broadcast your maimai friend code and player name over BLE to discover nearby Maimai players on Derakkuma.", fontSize = 12.sp, color = MutedText, lineHeight = 16.sp)
56
56
+
Text("Nearby friend-code exchange", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
57
57
+
Text("Broadcast your maimai friend code and player name over BLE to discover nearby Maimai players on Derakkuma.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, lineHeight = 16.sp)
55
58
}
56
59
}
57
57
-
if (!manager.isSupported()) Text("BLE advertising/scanning is not available on this device.", color = DangerRed, fontSize = 12.sp)
58
58
-
if (!canBubble) Text("Open Home/Friends once first so Derakkuma can cache your player name and friend code.", color = MutedText, fontSize = 12.sp)
59
59
-
message?.let { Text(it, color = MutedText, fontSize = 12.sp) }
60
60
-
Button(
60
60
+
if (!manager.isSupported()) Text("BLE advertising/scanning is not available on this device.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp)
61
61
+
if (!canBubble) Text("Open Home/Friends once first so Derakkuma can cache your player name and friend code.", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
62
62
+
message?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp) }
63
63
+
ExpressiveButton(
61
64
enabled = manager.isSupported() && canBubble,
62
65
onClick = {
63
66
scope.launch {
···
70
73
}
71
74
}
72
75
},
73
73
-
colors = ButtonDefaults.buttonColors(containerColor = if (broadcasting) DangerRed else PrimaryBlue),
76
76
+
colors = ButtonDefaults.buttonColors(containerColor = if (broadcasting) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary),
74
77
modifier = Modifier.fillMaxWidth(),
75
78
) { Text(if (broadcasting) "Stop Bubble" else "Start Bubble") }
76
79
}
77
80
}
78
81
79
79
-
Text("Nearby", fontWeight = FontWeight.Bold, color = DarkText)
82
82
+
Text("Nearby", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
80
83
if (peers.isEmpty()) {
81
81
-
Text(if (broadcasting) "Looking for nearby players..." else "Start Bubble to discover nearby players.", color = MutedText, fontSize = 13.sp)
84
84
+
Text(if (broadcasting) "Looking for nearby players..." else "Start Bubble to discover nearby players.", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
82
85
}
83
86
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 96.dp)) {
84
87
items(peers, key = { it.id }) { peer ->
···
120
123
121
124
@Composable
122
125
private fun BubblePeerRow(peer: BubblePeer, busy: Boolean, onRequest: () -> Unit) {
123
123
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(12.dp)) {
126
126
+
ExpressiveGlassCard(cornerRadius = 24.dp) {
124
127
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
125
128
MaimaiRemoteImage(peer.iconUrl, modifier = Modifier.size(42.dp), contentDescription = null)
126
129
Column(Modifier.weight(1f)) {
127
127
-
Text(peer.playerName.ifBlank { "Nearby player" }, fontWeight = FontWeight.Bold, color = DarkText)
128
128
-
Text("${peer.friendCode} · ${signalLabel(peer.rssi)}", fontSize = 12.sp, color = MutedText)
129
129
-
if (peer.comment.isNotBlank()) Text(peer.comment, fontSize = 11.sp, color = MutedText, maxLines = 2)
130
130
+
Text(peer.playerName.ifBlank { "Nearby player" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
131
131
+
Text("${peer.friendCode} · ${signalLabel(peer.rssi)}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
132
132
+
if (peer.comment.isNotBlank()) Text(peer.comment, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2)
130
133
}
131
131
-
Button(onClick = onRequest, enabled = !busy, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) {
132
132
-
if (busy) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = CardWhite) else Icon(Icons.Outlined.PersonAdd, null, modifier = Modifier.size(18.dp))
134
134
+
ExpressiveButton(onClick = onRequest, enabled = !busy, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)) {
135
135
+
if (busy) ExpressiveLoadingIndicator(modifier = Modifier, color = MaterialTheme.colorScheme.onPrimary, size = 16.dp) else Icon(Icons.Outlined.PersonAdd, null, modifier = Modifier.size(18.dp))
133
136
Spacer(Modifier.width(6.dp))
134
137
Text("Request")
135
138
}
···
1
1
package com.derakkuma.ui.tabs
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
3
4
import androidx.compose.foundation.layout.*
4
5
import androidx.compose.foundation.lazy.LazyColumn
5
6
import androidx.compose.foundation.lazy.LazyRow
6
7
import androidx.compose.foundation.lazy.items
7
7
-
import androidx.compose.foundation.shape.RoundedCornerShape
8
8
import androidx.compose.material.icons.Icons
9
9
import androidx.compose.material.icons.outlined.*
10
10
import androidx.compose.material3.*
···
31
31
import com.derakkuma.data.TourMemberFormationPage
32
32
import com.derakkuma.data.TourMemberSelectionPage
33
33
import com.derakkuma.ui.components.EmptyState
34
34
+
import com.derakkuma.ui.components.ExpressiveButton
35
35
+
import com.derakkuma.ui.components.ExpressiveGlassCard
36
36
+
import com.derakkuma.ui.components.ExpressiveFilterChip
37
37
+
import com.derakkuma.ui.components.ExpressiveTextButton
34
38
import com.derakkuma.ui.components.LoadingState
35
39
import com.derakkuma.ui.images.BundledIcon
36
40
import com.derakkuma.ui.images.BundledImage
37
41
import com.derakkuma.ui.images.MaimaiRemoteImage
38
38
-
import com.derakkuma.ui.theme.CardWhite
39
39
-
import com.derakkuma.ui.theme.DarkText
40
40
-
import com.derakkuma.ui.theme.MutedText
41
41
-
import com.derakkuma.ui.theme.PrimaryBlue
42
42
import kotlinx.coroutines.launch
43
43
44
44
@Composable
···
92
92
}
93
93
94
94
Column(Modifier.fillMaxSize().padding(start = 12.dp, end = 12.dp, top = 12.dp)) {
95
95
-
Text("Collection", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(bottom = 8.dp))
95
95
+
Text("Collection", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp))
96
96
LazyRow(horizontalArrangement = Arrangement.spacedBy(2.dp), contentPadding = PaddingValues(0.dp), modifier = Modifier.fillMaxWidth()) {
97
97
items(CollectionKind.entries) { tab ->
98
98
-
TextButton(
98
98
+
ExpressiveTextButton(
99
99
onClick = {
100
100
kind = tab
101
101
genre = "0"
···
109
109
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp),
110
110
modifier = Modifier.height(32.dp),
111
111
) {
112
112
-
Text(tab.label, fontSize = 11.sp, fontWeight = if (kind == tab) FontWeight.Bold else FontWeight.Normal, color = Color.White, maxLines = 1)
112
112
+
Text(tab.label, fontSize = 11.sp, fontWeight = if (kind == tab) FontWeight.Bold else FontWeight.Normal, color = MaterialTheme.colorScheme.onPrimary, maxLines = 1)
113
113
}
114
114
}
115
115
}
···
118
118
return@Column
119
119
}
120
120
if (loading) {
121
121
-
LoadingState(Modifier.fillMaxSize(), color = PrimaryBlue)
121
121
+
LoadingState(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary)
122
122
return@Column
123
123
}
124
124
page?.let { data ->
···
155
155
}
156
156
}
157
157
if (kind != CollectionKind.TITLE && kind != CollectionKind.TOUR_MEMBER && data.genres.isNotEmpty()) item { GenreSelector(data.genres, genre) { genre = it } }
158
158
-
message?.let { item { Text(it, color = MutedText, fontSize = 12.sp) } }
158
158
+
message?.let { item { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp) } }
159
159
if (kind == CollectionKind.TOUR_MEMBER && showFormation) {
160
160
arrangement?.let { edited ->
161
161
item {
···
282
282
return@LazyColumn
283
283
}
284
284
visibleSections.forEach { section ->
285
285
-
item { Text(section.title, color = DarkText, fontWeight = FontWeight.Bold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) }
285
285
+
item { Text(section.title, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) }
286
286
items(section.items.size, key = { index -> "${kind.name}:${section.id}:$index:${section.items[index].name}" }) { index ->
287
287
val item = section.items[index]
288
288
CollectionItemCard(
···
310
310
}
311
311
if (!loading && visibleSections.isEmpty()) {
312
312
item {
313
313
-
Text("No ${kind.label.lowercase()} entries found.", color = MutedText, modifier = Modifier.padding(12.dp))
313
313
+
Text("No ${kind.label.lowercase()} entries found.", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(12.dp))
314
314
}
315
315
}
316
316
}
317
317
} ?: Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
318
318
-
if (loading) CircularProgressIndicator() else Text(message ?: "No collection data", color = MutedText)
318
318
+
if (loading) ExpressiveLoadingIndicator() else Text(message ?: "No collection data", color = MaterialTheme.colorScheme.onSurfaceVariant)
319
319
}
320
320
}
321
321
}
322
322
323
323
@Composable
324
324
private fun CurrentCollectionCard(item: CollectionItem?, page: CollectionPage) {
325
325
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(12.dp)) {
325
325
+
ExpressiveGlassCard(cornerRadius = 24.dp) {
326
326
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
327
327
if (page.kind == CollectionKind.NAMEPLATE || page.kind == CollectionKind.FRAME) {
328
328
MaimaiRemoteImage(item?.imageUrl, modifier = Modifier.width(116.dp).height(42.dp), contentDescription = null, contentScale = ContentScale.Fit)
···
330
330
MaimaiRemoteImage(item?.imageUrl, modifier = Modifier.size(58.dp), contentDescription = null)
331
331
}
332
332
Column(Modifier.weight(1f)) {
333
333
-
Text("Currently using", color = MutedText, fontSize = 11.sp)
334
334
-
Text(item?.name ?: "Unknown", color = DarkText, fontWeight = FontWeight.Bold)
335
335
-
item?.category?.takeIf { it.isNotBlank() }?.let { Text(it, color = MutedText, fontSize = 12.sp) }
336
336
-
Text("Favorite ${page.favoriteCount}/${page.favoriteTotal} · Total ${page.itemCount}", color = MutedText, fontSize = 12.sp)
333
333
+
Text("Currently using", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
334
334
+
Text(item?.name ?: "Unknown", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold)
335
335
+
item?.category?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp) }
336
336
+
Text("Favorite ${page.favoriteCount}/${page.favoriteTotal} · Total ${page.itemCount}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
337
337
}
338
338
}
339
339
}
···
342
342
@Composable
343
343
private fun GenreSelector(genres: List<CollectionGenre>, selected: String, onSelect: (String) -> Unit) {
344
344
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
345
345
-
items(genres) { g -> CollectionFilterChip(selected = selected == g.value, onClick = { onSelect(g.value) }) { Text(g.label, fontSize = 11.sp) } }
345
345
+
items(genres) { g -> CollectionExpressiveFilterChip(selected = selected == g.value, onClick = { onSelect(g.value) }) { Text(g.label, fontSize = 11.sp) } }
346
346
}
347
347
}
348
348
···
350
350
private fun RareSelector(selected: String, onSelect: (String) -> Unit) {
351
351
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
352
352
listOf("0" to "Normal", "1" to "Bronze", "2" to "Silver", "3" to "Gold", "4" to "Rainbow").forEach { (value, label) ->
353
353
-
CollectionFilterChip(selected = selected == value, onClick = { onSelect(value) }) { Text(label, fontSize = 11.sp) }
353
353
+
CollectionExpressiveFilterChip(selected = selected == value, onClick = { onSelect(value) }) { Text(label, fontSize = 11.sp) }
354
354
}
355
355
}
356
356
}
357
357
358
358
@Composable
359
359
-
private fun CollectionFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit) {
360
360
-
FilterChip(
359
359
+
private fun CollectionExpressiveFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit) {
360
360
+
ExpressiveFilterChip(
361
361
selected = selected,
362
362
onClick = onClick,
363
363
label = label,
364
364
-
colors = FilterChipDefaults.filterChipColors(
365
365
-
containerColor = Color.White,
366
366
-
labelColor = DarkText,
367
367
-
selectedContainerColor = PrimaryBlue,
368
368
-
selectedLabelColor = Color.White,
369
369
-
),
370
370
-
border = FilterChipDefaults.filterChipBorder(
371
371
-
enabled = true,
372
372
-
selected = selected,
373
373
-
borderColor = Color.Transparent,
374
374
-
selectedBorderColor = PrimaryBlue,
375
375
-
),
376
364
)
377
365
}
378
366
···
380
368
private fun TourMemberControls(page: CollectionPage, selected: String, showingFormation: Boolean, onSelect: (String) -> Unit, onFormation: () -> Unit) {
381
369
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
382
370
if (page.joinedMembers.isNotBlank() || page.starCounts.isNotEmpty()) {
383
383
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
371
371
+
ExpressiveGlassCard(cornerRadius = 22.dp) {
384
372
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
385
385
-
if (page.joinedMembers.isNotBlank()) Text("${page.joinedMembers.filter { it.isDigit() }} partners", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 13.sp)
373
373
+
if (page.joinedMembers.isNotBlank()) Text("${page.joinedMembers.filter { it.isDigit() }} partners", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 13.sp)
386
374
if (page.starCounts.isNotEmpty()) {
387
375
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
388
376
page.starCounts.forEach { (stars, count) ->
389
377
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
390
390
-
Text("$stars", color = MutedText, fontSize = 11.sp)
378
378
+
Text("$stars", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
391
379
BundledIcon(BundledImage.IconStar, modifier = Modifier.size(12.dp), contentDescription = "star")
392
392
-
Text("×$count", color = MutedText, fontSize = 11.sp)
380
380
+
Text("×$count", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
393
381
}
394
382
}
395
383
}
···
398
386
}
399
387
}
400
388
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
401
401
-
CollectionFilterChip(selected = selected != "event", onClick = { onSelect("0") }) { Text("Area") }
402
402
-
CollectionFilterChip(selected = selected == "event", onClick = { onSelect("event") }) { Text("Event Area") }
389
389
+
CollectionExpressiveFilterChip(selected = selected != "event", onClick = { onSelect("0") }) { Text("Area") }
390
390
+
CollectionExpressiveFilterChip(selected = selected == "event", onClick = { onSelect("event") }) { Text("Event Area") }
403
391
}
404
404
-
Button(
392
392
+
ExpressiveButton(
405
393
onClick = onFormation,
406
406
-
shape = RoundedCornerShape(999.dp),
407
407
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue, contentColor = Color.White),
394
394
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary),
408
395
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp),
409
396
) {
410
397
Icon(Icons.Outlined.Groups, null, modifier = Modifier.size(18.dp))
···
438
425
SelectionEditor(selection = sel, onSelectCandidate = onSelectCandidate)
439
426
return@Column
440
427
}
441
441
-
Text("Tour Members Formation", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 15.sp)
428
428
+
Text("Tour Members Formation", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 15.sp)
442
429
page.slots.groupBy { it.preset }.forEach { (preset, slots) ->
443
443
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
430
430
+
ExpressiveGlassCard(cornerRadius = 24.dp) {
444
431
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
445
432
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
446
446
-
Text(if (preset == 0) "Current Formation" else "Preset $preset", color = DarkText, fontWeight = FontWeight.Bold)
433
433
+
Text(if (preset == 0) "Current Formation" else "Preset $preset", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold)
447
434
if (preset > 0) {
448
435
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
449
449
-
TextButton(onClick = { onArrangePreset(preset) }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text("Arrange", fontSize = 11.sp) }
450
450
-
Button(onClick = { onSetPreset(preset) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Use", fontSize = 11.sp) }
436
436
+
ExpressiveTextButton(onClick = { onArrangePreset(preset) }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text("Arrange", fontSize = 11.sp) }
437
437
+
ExpressiveButton(onClick = { onSetPreset(preset) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Use", fontSize = 11.sp) }
451
438
}
452
439
}
453
440
}
···
456
443
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
457
444
MaimaiRemoteImage(slot.imageUrl, modifier = Modifier.size(48.dp), contentDescription = null)
458
445
TourMemberMeta(level = slot.level, awakening = slot.awakening, fontSize = 10)
459
459
-
if (slot.locked) Text("Lock", color = MutedText, fontSize = 9.sp)
460
460
-
TextButton(onClick = { onChangeSlot(preset, slot.position) }, contentPadding = PaddingValues(0.dp)) { Text("Change", fontSize = 10.sp) }
446
446
+
if (slot.locked) Text("Lock", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 9.sp)
447
447
+
ExpressiveTextButton(onClick = { onChangeSlot(preset, slot.position) }, contentPadding = PaddingValues(0.dp)) { Text("Change", fontSize = 10.sp) }
461
448
}
462
449
}
463
450
}
···
469
456
470
457
@Composable
471
458
private fun SelectionEditor(selection: TourMemberSelectionPage, onSelectCandidate: (TourMemberCandidate) -> Unit) {
472
472
-
Text("Choose member for Preset ${selection.preset} Slot ${selection.position}", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 14.sp)
459
459
+
Text("Choose member for Preset ${selection.preset} Slot ${selection.position}", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp)
473
460
selection.candidates.forEach { candidate ->
474
474
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
461
461
+
ExpressiveGlassCard(cornerRadius = 22.dp) {
475
462
Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
476
463
MaimaiRemoteImage(candidate.imageUrl, modifier = Modifier.size(44.dp), contentDescription = null)
477
464
Column(Modifier.weight(1f)) {
478
478
-
Text(candidate.name, color = DarkText, fontWeight = FontWeight.Bold, fontSize = 13.sp)
465
465
+
Text(candidate.name, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 13.sp)
479
466
TourMemberMeta(level = candidate.level, awakening = candidate.awakening, fontSize = 11)
480
467
}
481
481
-
Button(onClick = { onSelectCandidate(candidate) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
468
468
+
ExpressiveButton(onClick = { onSelectCandidate(candidate) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
482
469
}
483
470
}
484
471
}
···
486
473
487
474
@Composable
488
475
private fun ArrangementEditor(page: TourMemberArrangementPage, onChange: (TourMemberArrangementPage) -> Unit, onSave: () -> Unit) {
489
489
-
Text("Arrange Preset ${page.preset}", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 15.sp)
490
490
-
Text("Use ↑/↓ to reorder members, then save.", color = MutedText, fontSize = 12.sp)
491
491
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
476
476
+
Text("Arrange Preset ${page.preset}", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 15.sp)
477
477
+
Text("Use ↑/↓ to reorder members, then save.", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
478
478
+
ExpressiveGlassCard(cornerRadius = 24.dp) {
492
479
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
493
480
page.slots.sortedBy { it.position }.forEach { slot ->
494
481
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
495
482
MaimaiRemoteImage(slot.imageUrl, modifier = Modifier.size(42.dp), contentDescription = null)
496
483
Column(Modifier.weight(1f)) {
497
497
-
Text("Slot ${slot.position}", color = DarkText, fontWeight = FontWeight.Bold, fontSize = 12.sp)
484
484
+
Text("Slot ${slot.position}", color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 12.sp)
498
485
TourMemberMeta(level = slot.level, awakening = slot.awakening, fontSize = 10)
499
486
}
500
500
-
TextButton(enabled = slot.position > 1, onClick = { onChange(page.copy(slots = page.slots.swapPositions(slot.position, slot.position - 1))) }) { Text("↑", fontSize = 16.sp) }
501
501
-
TextButton(enabled = slot.position < 5, onClick = { onChange(page.copy(slots = page.slots.swapPositions(slot.position, slot.position + 1))) }) { Text("↓", fontSize = 16.sp) }
502
502
-
FilterChip(selected = slot.lock == "1", onClick = { onChange(page.copy(slots = page.slots.map { if (it.position == slot.position) it.copy(lock = if (it.lock == "1") "0" else "1") else it })) }, label = { Text("Lock", fontSize = 10.sp) })
487
487
+
ExpressiveTextButton(enabled = slot.position > 1, onClick = { onChange(page.copy(slots = page.slots.swapPositions(slot.position, slot.position - 1))) }) { Text("↑", fontSize = 16.sp) }
488
488
+
ExpressiveTextButton(enabled = slot.position < 5, onClick = { onChange(page.copy(slots = page.slots.swapPositions(slot.position, slot.position + 1))) }) { Text("↓", fontSize = 16.sp) }
489
489
+
CollectionExpressiveFilterChip(selected = slot.lock == "1", onClick = { onChange(page.copy(slots = page.slots.map { if (it.position == slot.position) it.copy(lock = if (it.lock == "1") "0" else "1") else it })) }) { Text("Lock", fontSize = 10.sp) }
503
490
}
504
491
}
505
505
-
Button(onClick = onSave, modifier = Modifier.align(Alignment.End)) { Text("Save arrangement") }
492
492
+
ExpressiveButton(onClick = onSave, modifier = Modifier.align(Alignment.End)) { Text("Save arrangement") }
506
493
}
507
494
}
508
495
}
···
510
497
@Composable
511
498
private fun TourMemberMeta(level: String, awakening: String, fontSize: Int) {
512
499
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
513
513
-
if (level.isNotBlank()) Text(level, color = MutedText, fontSize = fontSize.sp)
500
500
+
if (level.isNotBlank()) Text(level, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = fontSize.sp)
514
501
if (awakening.isNotBlank()) {
515
502
BundledIcon(BundledImage.IconStar, modifier = Modifier.size((fontSize + 2).dp), contentDescription = "star")
516
516
-
Text(awakening, color = MutedText, fontSize = fontSize.sp)
503
503
+
Text(awakening, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = fontSize.sp)
517
504
}
518
505
}
519
506
}
···
536
523
TitleCollectionItemCard(item = item, onSet = onSet, onFavorite = onFavorite)
537
524
return
538
525
}
539
539
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
526
526
+
ExpressiveGlassCard(cornerRadius = 22.dp) {
540
527
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
541
528
MaimaiRemoteImage(item.imageUrl, modifier = Modifier.size(if (kind == CollectionKind.FRAME) 72.dp else 54.dp), contentDescription = null)
542
529
Column(Modifier.weight(1f)) {
543
543
-
Text(item.name, color = if (item.isLocked) MutedText else DarkText, fontWeight = FontWeight.Bold, fontSize = 14.sp)
530
530
+
Text(item.name, color = if (item.isLocked) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp)
544
531
val meta = listOf(item.category, item.level).filter { it.isNotBlank() }.joinToString(" · ")
545
545
-
if (meta.isNotBlank()) Text(meta, color = MutedText, fontSize = 11.sp)
532
532
+
if (meta.isNotBlank()) Text(meta, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
546
533
if (item.awakening.isNotBlank()) TourMemberMeta(level = "", awakening = item.awakening, fontSize = 11)
547
547
-
if (item.condition.isNotBlank()) Text(item.condition, color = MutedText, fontSize = 11.sp, maxLines = 2)
534
534
+
if (item.condition.isNotBlank()) Text(item.condition, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, maxLines = 2)
548
535
}
549
536
Column(verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.End) {
550
550
-
if (item.favoriteAction.isNotBlank()) TextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
551
551
-
if (item.setAction.isNotBlank()) Button(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
537
537
+
if (item.favoriteAction.isNotBlank()) ExpressiveTextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
538
538
+
if (item.setAction.isNotBlank()) ExpressiveButton(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
552
539
}
553
540
}
554
541
}
···
556
543
557
544
@Composable
558
545
private fun WideCollectionItemCard(item: CollectionItem, onSet: () -> Unit, onFavorite: () -> Unit) {
559
559
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
546
546
+
ExpressiveGlassCard(cornerRadius = 24.dp) {
560
547
Column(Modifier.fillMaxWidth().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
561
548
MaimaiRemoteImage(item.imageUrl, modifier = Modifier.fillMaxWidth().height(86.dp), contentDescription = null, contentScale = ContentScale.Fit)
562
549
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
563
550
Column(Modifier.weight(1f)) {
564
564
-
Text(item.name, color = if (item.isLocked) MutedText else DarkText, fontWeight = FontWeight.Bold, fontSize = 14.sp)
565
565
-
if (item.category.isNotBlank()) Text(item.category, color = MutedText, fontSize = 11.sp)
566
566
-
if (item.condition.isNotBlank()) Text(item.condition, color = MutedText, fontSize = 11.sp, maxLines = 2)
551
551
+
Text(item.name, color = if (item.isLocked) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp)
552
552
+
if (item.category.isNotBlank()) Text(item.category, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
553
553
+
if (item.condition.isNotBlank()) Text(item.condition, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, maxLines = 2)
567
554
}
568
555
Column(verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.End) {
569
569
-
if (item.favoriteAction.isNotBlank()) TextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
570
570
-
if (item.setAction.isNotBlank()) Button(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
556
556
+
if (item.favoriteAction.isNotBlank()) ExpressiveTextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
557
557
+
if (item.setAction.isNotBlank()) ExpressiveButton(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
571
558
}
572
559
}
573
560
}
···
576
563
577
564
@Composable
578
565
private fun TitleCollectionItemCard(item: CollectionItem, onSet: () -> Unit, onFavorite: () -> Unit) {
579
579
-
Card(colors = CardDefaults.cardColors(containerColor = CardWhite), shape = RoundedCornerShape(10.dp)) {
566
566
+
ExpressiveGlassCard(cornerRadius = 18.dp) {
580
567
Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
581
568
Column(Modifier.weight(1f)) {
582
582
-
Text(item.name, color = if (item.isLocked) MutedText else DarkText, fontWeight = FontWeight.Bold, fontSize = 13.sp, maxLines = 2)
583
583
-
if (item.category.isNotBlank()) Text(item.category, color = MutedText, fontSize = 10.sp, maxLines = 1)
569
569
+
Text(item.name, color = if (item.isLocked) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 13.sp, maxLines = 2)
570
570
+
if (item.category.isNotBlank()) Text(item.category, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.sp, maxLines = 1)
584
571
}
585
572
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
586
586
-
if (item.favoriteAction.isNotBlank()) TextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
587
587
-
if (item.setAction.isNotBlank()) Button(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
573
573
+
if (item.favoriteAction.isNotBlank()) ExpressiveTextButton(onClick = onFavorite) { Text(if (item.favoriteAction.contains("Off")) "Unfav" else "Fav", fontSize = 11.sp) }
574
574
+
if (item.setAction.isNotBlank()) ExpressiveButton(onClick = onSet, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { Text("Set", fontSize = 11.sp) }
588
575
}
589
576
}
590
577
}
···
4
4
import androidx.compose.foundation.lazy.LazyColumn
5
5
import androidx.compose.foundation.lazy.items
6
6
import androidx.compose.foundation.rememberScrollState
7
7
-
import androidx.compose.foundation.shape.RoundedCornerShape
8
7
import androidx.compose.foundation.verticalScroll
9
8
import androidx.compose.material.icons.Icons
10
9
import androidx.compose.material.icons.filled.Edit
11
10
import androidx.compose.material.icons.filled.Search
11
11
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
12
12
+
import androidx.compose.material3.MaterialShapes
12
13
import androidx.compose.material3.*
14
14
+
import androidx.compose.material3.toShape
13
15
import androidx.compose.runtime.*
14
16
import androidx.compose.ui.Alignment
15
17
import androidx.compose.ui.Modifier
···
22
24
import com.derakkuma.atproto.syncFavoriteListToAtProto
23
25
import com.derakkuma.data.*
24
26
import com.derakkuma.ui.components.EmptyState
27
27
+
import com.derakkuma.ui.components.ExpressiveGlassCard
28
28
+
import com.derakkuma.ui.components.ExpressiveCheckbox
29
29
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
30
30
+
import com.derakkuma.ui.components.ExpressiveScaffold
31
31
+
import com.derakkuma.ui.components.ExpressiveSearchBar
32
32
+
import com.derakkuma.ui.components.ExpressiveTextButton
33
33
+
import com.derakkuma.ui.components.ExpressiveSnackbar
34
34
+
import com.derakkuma.ui.components.ExpressiveFloatingActionButton
25
35
import com.derakkuma.ui.components.LoadingState
26
36
import com.derakkuma.ui.images.*
27
37
import com.derakkuma.ui.theme.*
···
29
39
import kotlinx.coroutines.launch
30
40
import kotlinx.coroutines.withContext
31
41
42
42
+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
32
43
@Composable
33
44
fun FavoritesTab(client: MaimaiClient, cookie: String, maintenanceMode: Boolean = false, cacheStore: CacheStore = CacheStore(), atProtoClient: AtProtoClient? = null) {
34
45
var favorites by remember { mutableStateOf<FavoriteList?>(null) }
···
60
71
loading = false
61
72
}
62
73
63
63
-
Scaffold(
74
74
+
ExpressiveScaffold(
64
75
contentWindowInsets = WindowInsets(0.dp),
65
76
snackbarHost = {
66
77
if (snackMsg != null) {
67
67
-
Snackbar(
78
78
+
ExpressiveSnackbar(
68
79
modifier = Modifier.padding(12.dp),
69
69
-
shape = RoundedCornerShape(8.dp),
70
70
-
action = { TextButton(onClick = { snackMsg = null }) { Text("OK") } },
80
80
+
action = { ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") } },
71
81
) { Text(snackMsg!!) }
72
82
}
73
83
},
74
84
floatingActionButton = {
75
85
if (favorites != null && !loading && !maintenanceMode) {
76
76
-
FloatingActionButton(
86
86
+
ExpressiveFloatingActionButton(
77
87
onClick = {
78
78
-
if (loadingEdit) return@FloatingActionButton
88
88
+
if (loadingEdit) return@ExpressiveFloatingActionButton
79
89
if (editing) {
80
90
// Save
81
91
val selectedIds = checkedSongs.filter { it.value }.keys.toList()
···
118
128
}
119
129
}
120
130
},
121
121
-
containerColor = PrimaryBlue,
131
131
+
containerColor = MaterialTheme.colorScheme.primary,
122
132
) {
123
133
if (saving || loadingEdit) {
124
124
-
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = CardWhite, strokeWidth = 2.dp)
134
134
+
ExpressiveLoadingIndicator(modifier = Modifier, color = MaterialTheme.colorScheme.onPrimary, size = 24.dp)
125
135
} else {
126
136
Icon(
127
137
if (editing) Icons.Default.Edit else Icons.Default.Edit,
128
138
contentDescription = if (editing) "Save" else "Edit",
129
129
-
tint = CardWhite,
139
139
+
tint = MaterialTheme.colorScheme.onPrimary,
130
140
)
131
141
}
132
142
}
···
140
150
Column(Modifier.fillMaxSize().padding(padding)) {
141
151
// Header + search
142
152
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
143
143
-
Text("Edit Favorites", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
153
153
+
Text("Edit Favorites", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
144
154
Spacer(Modifier.height(4.dp))
145
145
-
Text("Select songs to favorite", fontSize = 12.sp, color = MutedText)
155
155
+
Text("Select songs to favorite", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
146
156
Spacer(Modifier.height(8.dp))
147
147
-
OutlinedTextField(
148
148
-
value = searchQuery,
149
149
-
onValueChange = { searchQuery = it },
157
157
+
var searchActive by remember { mutableStateOf(false) }
158
158
+
ExpressiveSearchBar(
159
159
+
inputField = {
160
160
+
SearchBarDefaults.InputField(
161
161
+
query = searchQuery,
162
162
+
onQueryChange = { searchQuery = it },
163
163
+
onSearch = { searchActive = false },
164
164
+
expanded = searchActive,
165
165
+
onExpandedChange = { searchActive = it },
166
166
+
placeholder = { Text("Search songs...") },
167
167
+
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
168
168
+
)
169
169
+
},
170
170
+
expanded = searchActive,
171
171
+
onExpandedChange = { searchActive = it },
150
172
modifier = Modifier.fillMaxWidth(),
151
151
-
placeholder = { Text("Search songs...") },
152
152
-
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
153
153
-
singleLine = true,
154
154
-
shape = RoundedCornerShape(8.dp),
155
155
-
colors = OutlinedTextFieldDefaults.colors(
156
156
-
unfocusedContainerColor = CardWhite,
157
157
-
focusedContainerColor = CardWhite,
158
158
-
),
159
159
-
)
173
173
+
) {}
160
174
Spacer(Modifier.height(8.dp))
161
175
}
162
176
···
170
184
}
171
185
}
172
186
173
173
-
Card(
187
187
+
ExpressiveGlassCard(
174
188
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).weight(1f),
175
175
-
shape = RoundedCornerShape(8.dp),
176
176
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
189
189
+
cornerRadius = 24.dp,
177
190
) {
178
191
LazyColumn(
179
192
modifier = Modifier.fillMaxSize(),
···
185
198
verticalAlignment = Alignment.CenterVertically,
186
199
horizontalArrangement = Arrangement.spacedBy(8.dp),
187
200
) {
188
188
-
Checkbox(
201
201
+
ExpressiveCheckbox(
189
202
checked = checkedSongs[song.value] ?: false,
190
203
onCheckedChange = { checked ->
191
204
checkedSongs = checkedSongs.toMutableMap().apply { this[song.value] = checked }
192
205
},
193
193
-
colors = CheckboxDefaults.colors(checkedColor = PrimaryBlue),
194
206
)
195
207
if (song.jacketUrl.isNotBlank()) {
196
208
MaimaiRemoteImage(
197
209
url = song.jacketUrl,
198
198
-
modifier = Modifier.size(36.dp).clip(RoundedCornerShape(4.dp)),
210
210
+
modifier = Modifier.size(36.dp).clip(MaterialShapes.Square.toShape()),
199
211
contentDescription = song.name,
200
212
contentScale = ContentScale.Crop,
201
213
)
202
214
}
203
203
-
Text(song.name, fontSize = 13.sp, color = DarkText, modifier = Modifier.weight(1f))
215
215
+
Text(song.name, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
204
216
if (song.genre.isNotBlank()) {
205
205
-
Text(song.genre, fontSize = 10.sp, color = MutedText)
217
217
+
Text(song.genre, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
206
218
}
207
219
}
208
220
}
···
210
222
if (filteredSongs.isEmpty()) {
211
223
item {
212
224
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
213
213
-
Text("No songs match search", fontSize = 14.sp, color = MutedText)
225
225
+
Text("No songs match search", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
214
226
}
215
227
}
216
228
}
···
226
238
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, bottom = 88.dp),
227
239
) {
228
240
// Header
229
229
-
Card(
241
241
+
ExpressiveGlassCard(
230
242
modifier = Modifier.fillMaxWidth(),
231
231
-
shape = RoundedCornerShape(8.dp),
232
232
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
243
243
+
cornerRadius = 24.dp,
233
244
) {
234
245
Row(
235
246
modifier = Modifier.padding(16.dp).fillMaxWidth(),
236
247
horizontalArrangement = Arrangement.SpaceBetween,
237
248
verticalAlignment = Alignment.CenterVertically,
238
249
) {
239
239
-
Text("Favorites", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
240
240
-
Text("${fav.current} / ${fav.max}", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
250
250
+
Text("Favorites", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
251
251
+
Text("${fav.current} / ${fav.max}", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
241
252
}
242
253
}
243
254
···
247
258
val songsByGenre = fav.songs.groupBy { it.genre }
248
259
songsByGenre.forEach { (genre, songs) ->
249
260
if (genre.isNotBlank()) {
250
250
-
Text(genre, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue, modifier = Modifier.padding(start = 4.dp, top = 8.dp))
261
261
+
Text(genre, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 4.dp, top = 8.dp))
251
262
}
252
263
songs.forEach { song ->
253
253
-
Card(
264
264
+
ExpressiveGlassCard(
254
265
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
255
255
-
shape = RoundedCornerShape(6.dp),
256
256
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
266
266
+
cornerRadius = 16.dp,
257
267
) {
258
268
Row(
259
269
modifier = Modifier.padding(8.dp).fillMaxWidth(),
···
263
273
if (song.jacketUrl.isNotBlank()) {
264
274
MaimaiRemoteImage(
265
275
url = song.jacketUrl,
266
266
-
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)),
276
276
+
modifier = Modifier.size(44.dp).clip(MaterialShapes.Square.toShape()),
267
277
contentDescription = song.name,
268
278
contentScale = ContentScale.Crop,
269
279
)
270
280
}
271
281
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
272
272
-
Text(song.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = DarkText, maxLines = 1)
282
282
+
Text(song.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
273
283
if (song.artist.isNotBlank()) {
274
274
-
Text(song.artist, fontSize = 11.sp, color = MutedText, maxLines = 1)
284
284
+
Text(song.artist, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
275
285
}
276
286
}
277
287
if (song.isDx) {
···
283
293
}
284
294
285
295
if (fav.songs.isEmpty()) {
286
286
-
Card(
296
296
+
ExpressiveGlassCard(
287
297
modifier = Modifier.fillMaxWidth(),
288
288
-
shape = RoundedCornerShape(8.dp),
289
289
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
298
298
+
cornerRadius = 24.dp,
290
299
) {
291
300
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
292
292
-
Text("No favorites yet", fontSize = 14.sp, color = MutedText)
301
301
+
Text("No favorites yet", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
293
302
}
294
303
}
295
304
}
···
8
8
import androidx.compose.foundation.lazy.LazyColumn
9
9
import androidx.compose.foundation.lazy.items
10
10
import androidx.compose.foundation.rememberScrollState
11
11
-
import androidx.compose.foundation.shape.RoundedCornerShape
12
11
import androidx.compose.material.icons.Icons
13
12
import androidx.compose.material.icons.automirrored.filled.ArrowBack
14
13
import androidx.compose.material.icons.filled.QrCode
···
16
15
import androidx.compose.material.icons.filled.Search
17
16
import androidx.compose.material.icons.filled.Star
18
17
import androidx.compose.material3.*
19
19
-
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20
18
import androidx.compose.runtime.*
21
19
import androidx.compose.ui.Alignment
22
20
import androidx.compose.ui.Modifier
···
42
40
import com.derakkuma.friends.friendScanCode
43
41
import com.derakkuma.friends.friendScanUrl
44
42
import com.derakkuma.ui.components.EmptyCard
43
43
+
import com.derakkuma.ui.components.ExpressiveAssistChip
44
44
+
import com.derakkuma.ui.components.ExpressiveButton
45
45
+
import com.derakkuma.ui.components.ExpressiveFloatingActionButton
46
46
+
import com.derakkuma.ui.components.ExpressiveGlassCard
47
47
+
import com.derakkuma.ui.components.ExpressivePullToRefreshBox
48
48
+
import com.derakkuma.ui.components.ExpressiveFilterChip
49
49
+
import com.derakkuma.ui.components.ExpressiveOutlinedButton
50
50
+
import com.derakkuma.ui.components.ExpressiveTextButton
51
51
+
import com.derakkuma.ui.components.ExpressiveAlertDialog
52
52
+
import com.derakkuma.ui.components.ExpressiveTextField
45
53
import com.derakkuma.ui.components.InfoCard
46
54
import com.derakkuma.ui.components.MaimaiProfile
47
55
import com.derakkuma.ui.components.SectionHeader
···
405
413
}
406
414
407
415
Box(Modifier.fillMaxSize()) {
408
408
-
PullToRefreshBox(
416
416
+
ExpressivePullToRefreshBox(
409
417
isRefreshing = loading,
410
418
onRefresh = { reload() },
411
419
modifier = Modifier.fillMaxSize(),
···
447
455
}
448
456
}
449
457
450
450
-
if (message != null) item { InfoCard(message!!, PrimaryBlue) }
458
458
+
if (message != null) item { InfoCard(message!!, MaterialTheme.colorScheme.primary) }
451
459
if (error != null) item { InfoCard(error!!, MaterialTheme.colorScheme.error) }
452
460
453
461
when (section) {
···
605
613
circleRewards?.let { page ->
606
614
if (page.rewards.isNotEmpty()) {
607
615
item { SectionHeader("Point Rewards") }
608
608
-
if (page.monthlyIconText.isNotBlank()) item { InfoCard(page.monthlyIconText, PrimaryBlue) }
616
616
+
if (page.monthlyIconText.isNotBlank()) item { InfoCard(page.monthlyIconText, MaterialTheme.colorScheme.primary) }
609
617
items(page.rewards, key = { it.points }) { reward -> RewardRow(reward) }
610
618
}
611
619
}
612
620
}
613
621
614
622
FriendsSection.CircleRanking -> circleRanking?.let { page ->
615
615
-
item { InfoCard("Your circle total: ${page.totalPoints} PT", PrimaryBlue) }
623
623
+
item { InfoCard("Your circle total: ${page.totalPoints} PT", MaterialTheme.colorScheme.primary) }
616
624
items(page.entries, key = { "circle-rank-${it.rank}-${it.name}-${it.points}" }) { entry -> CircleRankingRow(entry) }
617
625
}
618
626
···
676
684
}
677
685
}
678
686
679
679
-
FloatingActionButton(
687
687
+
ExpressiveFloatingActionButton(
680
688
onClick = {
681
689
if (mode == FriendsMode.Circles) {
682
690
showCircleSearch = true
···
696
704
}
697
705
},
698
706
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 40.dp),
699
699
-
containerColor = PrimaryBlue,
700
700
-
contentColor = Color.White,
707
707
+
containerColor = MaterialTheme.colorScheme.primary,
708
708
+
contentColor = MaterialTheme.colorScheme.onPrimary,
701
709
) {
702
710
Icon(Icons.Default.Search, contentDescription = "Search")
703
711
}
···
706
714
707
715
@Composable
708
716
private fun PrimaryTabs(selected: FriendsMode, onSelect: (FriendsMode) -> Unit) {
709
709
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
717
717
+
ExpressiveGlassCard(Modifier.fillMaxWidth()) {
710
718
Row(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
711
719
FriendsMode.entries.forEach { mode ->
712
712
-
Button(
720
720
+
ExpressiveButton(
713
721
onClick = { onSelect(mode) },
714
722
modifier = Modifier.weight(1f),
715
723
colors = ButtonDefaults.buttonColors(
716
716
-
containerColor = if (selected == mode) PrimaryBlue else Color(0xFFE5E7EB),
717
717
-
contentColor = if (selected == mode) Color.White else DarkText,
724
724
+
containerColor = if (selected == mode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh,
725
725
+
contentColor = if (selected == mode) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
718
726
),
719
727
) { Text(mode.label) }
720
728
}
···
731
739
}
732
740
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
733
741
sections.forEach { section ->
734
734
-
FilterChip(
742
742
+
ExpressiveFilterChip(
735
743
selected = selected == section,
736
744
onClick = { onSelect(section) },
737
745
label = {
···
740
748
fontSize = 11.sp,
741
749
)
742
750
},
743
743
-
colors = FilterChipDefaults.filterChipColors(
744
744
-
containerColor = Color.White.copy(alpha = 0.5f),
745
745
-
selectedContainerColor = Color.White,
746
746
-
),
747
751
)
748
752
}
749
753
}
···
755
759
onAccept: () -> Unit,
756
760
onReject: () -> Unit,
757
761
) {
758
758
-
Card(
762
762
+
ExpressiveGlassCard(
759
763
Modifier.fillMaxWidth(),
760
760
-
shape = RoundedCornerShape(8.dp),
761
761
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
762
764
) {
763
765
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
764
766
UserCard(user = user, expanded = true)
765
767
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
766
766
-
Button(onClick = onAccept, enabled = user.idx.isNotBlank() && user.token.isNotBlank()) { Text("Accept") }
767
767
-
OutlinedButton(onClick = onReject, enabled = user.idx.isNotBlank() && user.token.isNotBlank()) { Text("Reject") }
768
768
+
ExpressiveButton(onClick = onAccept, enabled = user.idx.isNotBlank() && user.token.isNotBlank()) { Text("Accept") }
769
769
+
ExpressiveOutlinedButton(onClick = onReject, enabled = user.idx.isNotBlank() && user.token.isNotBlank()) { Text("Reject") }
768
770
}
769
771
if (user.idx.isBlank() || user.token.isBlank()) {
770
770
-
Text("Can't act on this request yet — missing maimai form token.", fontSize = 11.sp, color = MutedText)
772
772
+
Text("Can't act on this request yet — missing maimai form token.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
771
773
}
772
774
}
773
775
}
···
803
805
ratingTextSize = 12.sp,
804
806
showComment = expanded,
805
807
trailingBadges = {
806
806
-
if (user.isFavorite) AssistChip(onClick = {}, label = { Text("Favorite", fontSize = 10.sp) })
807
807
-
if (user.isRival) AssistChip(onClick = {}, label = { Text("Rival", fontSize = 10.sp) })
808
808
+
if (user.isFavorite) {
809
809
+
ExpressiveAssistChip(
810
810
+
onClick = {},
811
811
+
label = { Text("Favorite", fontSize = 10.sp) },
812
812
+
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
813
813
+
labelColor = MaterialTheme.colorScheme.onTertiaryContainer,
814
814
+
)
815
815
+
}
816
816
+
if (user.isRival) {
817
817
+
ExpressiveAssistChip(
818
818
+
onClick = {},
819
819
+
label = { Text("Rival", fontSize = 10.sp) },
820
820
+
containerColor = MaterialTheme.colorScheme.secondaryContainer,
821
821
+
labelColor = MaterialTheme.colorScheme.onSecondaryContainer,
822
822
+
)
823
823
+
}
808
824
},
809
825
details = {
810
810
-
if (user.date.isNotBlank()) Text("Date: ${user.date}", fontSize = 11.sp, color = MutedText)
811
811
-
if (user.status.isNotBlank()) Text(user.status, fontSize = 11.sp, color = MutedText)
826
826
+
if (user.date.isNotBlank()) Text("Date: ${user.date}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
827
827
+
if (user.status.isNotBlank()) Text(user.status, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
812
828
},
813
829
actions = {
814
814
-
onVs?.let { ActionButton("Friend VS", Color(0xFF51BCF3), it) }
815
815
-
onFavorite?.let { ActionButton(if (user.isFavorite) "Unfavorite" else "Favorite", Color(0xFFFA8E53), it) }
816
816
-
onRival?.let { ActionButton(if (user.isRival) "Deregister Rival" else "Register Rival", Color(0xFFFB4B78), it) }
817
817
-
if (primaryActionLabel != null && primaryAction != null) Button(onClick = primaryAction) { Text(primaryActionLabel) }
818
818
-
if (secondaryActionLabel != null && secondaryAction != null) OutlinedButton(onClick = secondaryAction) { Text(secondaryActionLabel) }
830
830
+
onVs?.let { ActionButton("Friend VS", Color(0xFF51BCF3), Color.White, it) }
831
831
+
onFavorite?.let {
832
832
+
ActionButton(
833
833
+
if (user.isFavorite) "Unfavorite" else "Favorite",
834
834
+
if (user.isFavorite) Color(0xFFFA8E53) else Color(0xFFFFD7C4),
835
835
+
if (user.isFavorite) Color.White else Color(0xFF8A3A12),
836
836
+
it,
837
837
+
)
838
838
+
}
839
839
+
onRival?.let {
840
840
+
ActionButton(
841
841
+
if (user.isRival) "Deregister Rival" else "Register Rival",
842
842
+
if (user.isRival) Color(0xFFFB4B78) else Color(0xFFFFD0DC),
843
843
+
if (user.isRival) Color.White else Color(0xFF8E1234),
844
844
+
it,
845
845
+
)
846
846
+
}
847
847
+
if (primaryActionLabel != null && primaryAction != null) ExpressiveButton(onClick = primaryAction) { Text(primaryActionLabel) }
848
848
+
if (secondaryActionLabel != null && secondaryAction != null) ExpressiveOutlinedButton(onClick = secondaryAction) { Text(secondaryActionLabel) }
819
849
},
820
850
)
821
851
}
822
852
823
823
-
@Composable private fun FriendCounts(page: FriendsPage) = InfoCard("Friends ${page.friendCount}/${page.friendMax} \u00B7 Favorites ${page.favoriteCount}/${page.favoriteMax} \u00B7 Rivals ${page.rivalCount}/${page.rivalMax}", PrimaryBlue)
853
853
+
@Composable private fun FriendCounts(page: FriendsPage) = InfoCard("Friends ${page.friendCount}/${page.friendMax} \u00B7 Favorites ${page.favoriteCount}/${page.favoriteMax} \u00B7 Rivals ${page.rivalCount}/${page.rivalMax}", MaterialTheme.colorScheme.primary)
824
854
825
855
@Composable
826
856
private fun FriendQrActions(onScan: () -> Unit, onMyQr: () -> Unit, scanEnabled: Boolean = true) {
827
857
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
828
828
-
Button(
858
858
+
ExpressiveButton(
829
859
onClick = onScan,
830
860
enabled = scanEnabled,
831
861
modifier = Modifier.weight(1f),
832
832
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue, contentColor = Color.White),
862
862
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary),
833
863
) {
834
864
Icon(Icons.Default.QrCodeScanner, contentDescription = null, modifier = Modifier.size(18.dp))
835
865
Spacer(Modifier.width(6.dp))
836
866
Text("Scan to Add")
837
867
}
838
838
-
Button(
868
868
+
ExpressiveButton(
839
869
onClick = onMyQr,
840
870
modifier = Modifier.weight(1f),
841
841
-
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = PrimaryBlue),
871
871
+
colors = ButtonDefaults.buttonColors(
872
872
+
containerColor = MaterialTheme.colorScheme.secondaryContainer,
873
873
+
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
874
874
+
),
842
875
) {
843
843
-
Icon(Icons.Default.QrCode, contentDescription = null, modifier = Modifier.size(18.dp), tint = PrimaryBlue)
876
876
+
Icon(Icons.Default.QrCode, contentDescription = null, modifier = Modifier.size(18.dp))
844
877
Spacer(Modifier.width(6.dp))
845
878
Text("My Friend QR")
846
879
}
···
848
881
}
849
882
850
883
@Composable
851
851
-
private fun ActionButton(text: String, color: Color, onClick: () -> Unit) {
852
852
-
Button(
884
884
+
private fun ActionButton(text: String, color: Color, contentColor: Color, onClick: () -> Unit) {
885
885
+
ExpressiveButton(
853
886
onClick = onClick,
854
854
-
colors = ButtonDefaults.buttonColors(containerColor = color, contentColor = Color.White),
887
887
+
colors = ButtonDefaults.buttonColors(containerColor = color, contentColor = contentColor),
855
888
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
856
889
) {
857
890
Text(text, fontSize = 11.sp)
···
860
893
861
894
@Composable
862
895
private fun FriendCodeCard(friendCode: FriendCode) {
863
863
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
896
896
+
ExpressiveGlassCard(Modifier.fillMaxWidth()) {
864
897
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
865
865
-
Text("My Friend Code", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = DarkText)
898
898
+
Text("My Friend Code", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
866
899
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
867
867
-
Text(friendCode.code, fontSize = 22.sp, fontWeight = FontWeight.ExtraBold, color = PrimaryBlue)
900
900
+
Text(friendCode.code, fontSize = 22.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.primary)
868
901
CopyButton(friendCode.code)
869
902
}
870
870
-
if (friendCode.name.isNotBlank()) Text(friendCode.name, fontSize = 13.sp, color = MutedText)
903
903
+
if (friendCode.name.isNotBlank()) Text(friendCode.name, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
871
904
}
872
905
}
873
906
}
874
907
875
908
@Composable
876
909
private fun CompactFriendCodeCard(friendCode: FriendCode) {
877
877
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
910
910
+
ExpressiveGlassCard(Modifier.fillMaxWidth()) {
878
911
Row(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
879
912
Column {
880
880
-
Text("My Friend Code", fontSize = 11.sp, color = MutedText)
881
881
-
Text(friendCode.code, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue)
913
913
+
Text("My Friend Code", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
914
914
+
Text(friendCode.code, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
882
915
}
883
916
CopyButton(friendCode.code)
884
917
}
···
887
920
888
921
@Composable
889
922
private fun SearchFriendCard(code: String, onCode: (String) -> Unit, onSearch: () -> Unit) {
890
890
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
923
923
+
ExpressiveGlassCard(Modifier.fillMaxWidth()) {
891
924
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
892
892
-
OutlinedTextField(value = code, onValueChange = onCode, label = { Text("Friend Code") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
893
893
-
Button(onClick = onSearch, enabled = code.isNotBlank()) { Text("Search") }
925
925
+
ExpressiveTextField(
926
926
+
value = code,
927
927
+
onValueChange = onCode,
928
928
+
label = { Text("Friend Code") },
929
929
+
modifier = Modifier.fillMaxWidth(),
930
930
+
singleLine = true,
931
931
+
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
932
932
+
)
933
933
+
ExpressiveButton(onClick = onSearch, enabled = code.isNotBlank()) { Text("Search") }
894
934
}
895
935
}
896
936
}
···
898
938
@Composable
899
939
private fun FriendQrScannerDialog(onDismiss: () -> Unit, onScanned: (String) -> Unit, onFailure: (String) -> Unit) {
900
940
Dialog(onDismissRequest = onDismiss) {
901
901
-
Card(Modifier.fillMaxWidth().height(460.dp), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
941
941
+
ExpressiveGlassCard(Modifier.fillMaxWidth().height(460.dp), cornerRadius = 32.dp) {
902
942
Box(Modifier.fillMaxSize()) {
903
943
ScannerWithPermissions(
904
944
modifier = Modifier.fillMaxSize(),
···
910
950
cameraPosition = CameraPosition.BACK,
911
951
enableTorch = false,
912
952
)
913
913
-
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) {
914
914
-
Text("Close", color = Color.White)
953
953
+
ExpressiveTextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) {
954
954
+
Text("Close", color = MaterialTheme.colorScheme.onPrimary)
915
955
}
916
956
}
917
957
}
···
945
985
}
946
986
}
947
987
}
948
948
-
AlertDialog(
988
988
+
ExpressiveAlertDialog(
949
989
onDismissRequest = onDismiss,
950
990
title = { Text("My Friend QR") },
951
991
text = {
952
992
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
953
953
-
Card(shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = Color.White)) {
993
993
+
ExpressiveGlassCard(cornerRadius = 28.dp, containerColor = Color.White) {
954
994
Box(Modifier.padding(14.dp), contentAlignment = Alignment.Center) {
955
995
Image(
956
996
painter = painter,
···
959
999
)
960
1000
}
961
1001
}
962
962
-
Text(friendCode.code, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = PrimaryBlue)
963
963
-
friendCode.name.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 13.sp, color = MutedText) }
1002
1002
+
Text(friendCode.code, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.primary)
1003
1003
+
friendCode.name.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) }
964
1004
}
965
1005
},
966
966
-
confirmButton = { TextButton(onClick = onDismiss) { Text("Done", color = PrimaryBlue) } },
1006
1006
+
confirmButton = { ExpressiveTextButton(onClick = onDismiss) { Text("Done", color = MaterialTheme.colorScheme.primary) } },
967
1007
)
968
1008
}
969
1009
···
976
1016
onDismiss: () -> Unit,
977
1017
onFetchToken: () -> Unit,
978
1018
) {
979
979
-
AlertDialog(
1019
1019
+
ExpressiveAlertDialog(
980
1020
onDismissRequest = onDismiss,
981
1021
title = { Text("Search Circles") },
982
1022
text = {
983
1023
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
984
984
-
OutlinedTextField(
1024
1024
+
ExpressiveTextField(
985
1025
value = code,
986
1026
onValueChange = onCode,
987
1027
modifier = Modifier.fillMaxWidth(),
988
1028
label = { Text("Circle code") },
989
1029
singleLine = true,
990
990
-
)
991
991
-
page?.message?.takeIf { it.isNotBlank() }?.let { Text(it, color = MutedText, fontSize = 13.sp) }
1030
1030
+
)
1031
1031
+
page?.message?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp) }
992
1032
}
993
1033
},
994
1034
confirmButton = {
995
995
-
TextButton(
1035
1035
+
ExpressiveTextButton(
996
1036
onClick = onSearch,
997
1037
enabled = code.isNotBlank() && page?.token?.isNotBlank() == true,
998
998
-
) { Text("Search", color = PrimaryBlue) }
1038
1038
+
) { Text("Search", color = MaterialTheme.colorScheme.primary) }
999
1039
},
1000
1040
dismissButton = {
1001
1001
-
TextButton(onClick = onDismiss) { Text("Cancel") }
1041
1041
+
ExpressiveTextButton(onClick = onDismiss) { Text("Cancel") }
1002
1042
},
1003
1043
)
1004
1044
}
1005
1045
1006
1046
@Composable
1007
1047
private fun CircleHomeCard(home: CircleHome, onNavigate: (FriendsSection) -> Unit = {}) {
1008
1008
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
1048
1048
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
1009
1049
Box {
1010
1050
MaimaiRemoteImage(home.backgroundImageUrl, modifier = Modifier.fillMaxWidth().height(170.dp), contentDescription = null, contentScale = ContentScale.Crop)
1011
1011
-
Box(Modifier.matchParentSize().background(Color.White.copy(alpha = 0.74f)))
1051
1051
+
Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.surface.copy(alpha = 0.74f)))
1012
1052
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
1013
1053
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
1014
1014
-
MaimaiRemoteImage(home.characterImageUrl ?: home.backgroundImageUrl, modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)), contentDescription = home.name, contentScale = ContentScale.Crop)
1054
1054
+
MaimaiRemoteImage(home.characterImageUrl ?: home.backgroundImageUrl, modifier = Modifier.size(64.dp).clip(MaterialTheme.shapes.large), contentDescription = home.name, contentScale = ContentScale.Crop)
1015
1055
Column(Modifier.weight(1f)) {
1016
1016
-
Text(home.name.ifBlank { "Circle" }, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
1017
1017
-
if (home.ownerName.isNotBlank()) Text(home.ownerName, color = MutedText, fontSize = 12.sp)
1018
1018
-
if (home.comment.isNotBlank()) Text(home.comment, color = MutedText, fontSize = 12.sp)
1056
1056
+
Text(home.name.ifBlank { "Circle" }, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
1057
1057
+
if (home.ownerName.isNotBlank()) Text(home.ownerName, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1058
1058
+
if (home.comment.isNotBlank()) Text(home.comment, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1019
1059
}
1020
1060
}
1021
1061
if (home.code.isNotBlank()) {
1022
1062
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
1023
1023
-
Text("Circle Code: ${home.code}", color = PrimaryBlue, fontWeight = FontWeight.SemiBold)
1063
1063
+
Text("Circle Code: ${home.code}", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
1024
1064
CopyButton(home.code)
1025
1065
}
1026
1066
}
1027
1067
if (home.tags.isNotEmpty()) {
1028
1028
-
Text(home.tags.joinToString(" \u00B7 "), color = MutedText, fontSize = 11.sp)
1068
1068
+
Text(home.tags.joinToString(" \u00B7 "), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
1029
1069
}
1030
1030
-
Text("${home.month}: ${home.totalPoints} PT", color = PrimaryBlue, fontWeight = FontWeight.SemiBold)
1031
1031
-
Text("Rank ${home.rank} \u00B7 ${home.daysUntilReset} days until reset", color = MutedText, fontSize = 12.sp)
1032
1032
-
if (home.updatedAt.isNotBlank()) Text("Updated: ${home.updatedAt}", color = MutedText, fontSize = 12.sp)
1033
1033
-
if (home.nextRewardPoints > 0) Text("Next reward: ${home.nextRewardPoints} PT", color = DarkText)
1034
1034
-
if (home.festaMessage.isNotBlank()) Text(home.festaMessage, color = MutedText, fontSize = 12.sp)
1070
1070
+
Text("${home.month}: ${home.totalPoints} PT", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
1071
1071
+
Text("Rank ${home.rank} \u00B7 ${home.daysUntilReset} days until reset", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1072
1072
+
if (home.updatedAt.isNotBlank()) Text("Updated: ${home.updatedAt}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1073
1073
+
if (home.nextRewardPoints > 0) Text("Next reward: ${home.nextRewardPoints} PT", color = MaterialTheme.colorScheme.onSurface)
1074
1074
+
if (home.festaMessage.isNotBlank()) Text(home.festaMessage, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1035
1075
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
1036
1036
-
TextButton(onClick = { onNavigate(FriendsSection.CircleRanking) }) { Text("Ranking") }
1037
1037
-
TextButton(onClick = { onNavigate(FriendsSection.CircleAccept) }) { Text("Invites") }
1038
1038
-
if (home.festaMessage.isNotBlank()) TextButton(onClick = { onNavigate(FriendsSection.Festa) }) { Text("FESTA") }
1076
1076
+
ExpressiveTextButton(onClick = { onNavigate(FriendsSection.CircleRanking) }) { Text("Ranking") }
1077
1077
+
ExpressiveTextButton(onClick = { onNavigate(FriendsSection.CircleAccept) }) { Text("Invites") }
1078
1078
+
if (home.festaMessage.isNotBlank()) ExpressiveTextButton(onClick = { onNavigate(FriendsSection.Festa) }) { Text("FESTA") }
1039
1079
}
1040
1080
}
1041
1081
}
1042
1082
}
1043
1083
}
1044
1084
1045
1045
-
@Composable private fun RewardRow(reward: CircleReward) = InfoCard("${reward.points} PT \u2014 ${reward.reward}", DarkText)
1085
1085
+
@Composable private fun RewardRow(reward: CircleReward) = InfoCard("${reward.points} PT \u2014 ${reward.reward}", MaterialTheme.colorScheme.onSurface)
1046
1086
1047
1047
-
@Composable private fun CircleRankingRow(entry: CircleRankingEntry) = InfoCard("#${entry.rank} ${entry.name} \u2014 ${entry.points} PT", DarkText)
1087
1087
+
@Composable private fun CircleRankingRow(entry: CircleRankingEntry) = InfoCard("#${entry.rank} ${entry.name} \u2014 ${entry.points} PT", MaterialTheme.colorScheme.onSurface)
1048
1088
1049
1049
-
@Composable private fun CircleMemberRow(member: CircleMember) = InfoCard("${member.name} \u00B7 Rating ${member.rating} \u00B7 ${member.points} PT${if (member.status.isNotBlank()) " \u00B7 ${member.status}" else ""}", DarkText)
1089
1089
+
@Composable private fun CircleMemberRow(member: CircleMember) = InfoCard("${member.name} \u00B7 Rating ${member.rating} \u00B7 ${member.points} PT${if (member.status.isNotBlank()) " \u00B7 ${member.status}" else ""}", MaterialTheme.colorScheme.onSurface)
1050
1090
1051
1091
@Composable
1052
1092
private fun FestaNav(selected: FestaSection, onSelect: (FestaSection) -> Unit) {
1053
1093
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
1054
1094
FestaSection.entries.forEach { section ->
1055
1055
-
FilterChip(
1095
1095
+
ExpressiveFilterChip(
1056
1096
selected = selected == section,
1057
1097
onClick = { onSelect(section) },
1058
1098
label = { Text(section.label) },
1059
1059
-
colors = FilterChipDefaults.filterChipColors(
1060
1060
-
containerColor = Color.White.copy(alpha = 0.5f),
1061
1061
-
selectedContainerColor = Color.White,
1062
1062
-
),
1063
1099
)
1064
1100
}
1065
1101
}
···
1067
1103
1068
1104
@Composable
1069
1105
private fun FriendVsCard(vs: FriendVsPage) {
1070
1070
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
1106
1106
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
1071
1107
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
1072
1072
-
Text("Friend VS", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = DarkText)
1108
1108
+
Text("Friend VS", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
1073
1109
if (vs.playerName.isNotBlank() || vs.friendName.isNotBlank()) {
1074
1110
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
1075
1111
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
1076
1076
-
MaimaiRemoteImage(vs.playerIconUrl, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), contentDescription = vs.playerName, contentScale = ContentScale.Crop)
1112
1112
+
MaimaiRemoteImage(vs.playerIconUrl, modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.large), contentDescription = vs.playerName, contentScale = ContentScale.Crop)
1077
1113
Spacer(Modifier.height(4.dp))
1078
1078
-
Text(vs.playerName.ifBlank { "You" }, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = DarkText, maxLines = 1)
1079
1079
-
if (vs.playerRating > 0) Text("${vs.playerRating}", fontSize = 15.sp, fontWeight = FontWeight.ExtraBold, color = PrimaryBlue)
1114
1114
+
Text(vs.playerName.ifBlank { "You" }, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
1115
1115
+
if (vs.playerRating > 0) Text("${vs.playerRating}", fontSize = 15.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.primary)
1080
1116
}
1081
1081
-
Text("VS", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MutedText)
1117
1117
+
Text("VS", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)
1082
1118
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
1083
1083
-
MaimaiRemoteImage(vs.friendIconUrl, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), contentDescription = vs.friendName, contentScale = ContentScale.Crop)
1119
1119
+
MaimaiRemoteImage(vs.friendIconUrl, modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.large), contentDescription = vs.friendName, contentScale = ContentScale.Crop)
1084
1120
Spacer(Modifier.height(4.dp))
1085
1085
-
Text(vs.friendName.ifBlank { "Friend" }, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = DarkText, maxLines = 1)
1086
1086
-
if (vs.friendRating > 0) Text("${vs.friendRating}", fontSize = 15.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFFFB4B78))
1121
1121
+
Text(vs.friendName.ifBlank { "Friend" }, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 1)
1122
1122
+
if (vs.friendRating > 0) Text("${vs.friendRating}", fontSize = 15.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.secondary)
1087
1123
}
1088
1124
}
1089
1125
}
1090
1090
-
if (vs.message.isNotBlank()) Text(vs.message, color = MutedText, fontSize = 12.sp)
1091
1091
-
if (vs.genres.isNotEmpty()) Text("Genres: ${vs.genres.joinToString()}", color = MutedText, fontSize = 11.sp)
1092
1092
-
if (vs.difficulties.isNotEmpty()) Text("Difficulties: ${vs.difficulties.joinToString()}", color = MutedText, fontSize = 11.sp)
1093
1093
-
if (vs.scoreTypes.isNotEmpty()) Text("Score types: ${vs.scoreTypes.joinToString()}", color = MutedText, fontSize = 11.sp)
1126
1126
+
if (vs.message.isNotBlank()) Text(vs.message, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp)
1127
1127
+
if (vs.genres.isNotEmpty()) Text("Genres: ${vs.genres.joinToString()}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
1128
1128
+
if (vs.difficulties.isNotEmpty()) Text("Difficulties: ${vs.difficulties.joinToString()}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
1129
1129
+
if (vs.scoreTypes.isNotEmpty()) Text("Score types: ${vs.scoreTypes.joinToString()}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp)
1094
1130
}
1095
1131
}
1096
1132
}
1097
1133
1098
1134
@Composable
1099
1135
private fun FestaHistoryRow(history: CircleFestaHistory) {
1100
1100
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
1136
1136
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 24.dp) {
1101
1137
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
1102
1102
-
MaimaiRemoteImage(history.imageUrl, modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)), contentDescription = history.season, contentScale = ContentScale.Crop)
1138
1138
+
MaimaiRemoteImage(history.imageUrl, modifier = Modifier.size(64.dp).clip(MaterialTheme.shapes.large), contentDescription = history.season, contentScale = ContentScale.Crop)
1103
1139
Column(Modifier.weight(1f)) {
1104
1104
-
Text(history.season.ifBlank { "Past FESTA" }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = DarkText)
1105
1105
-
if (history.period.isNotBlank()) Text(history.period, fontSize = 12.sp, color = MutedText)
1106
1106
-
if (history.team.isNotBlank()) Text(history.team, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color(0xFFFB4B78))
1140
1140
+
Text(history.season.ifBlank { "Past FESTA" }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
1141
1141
+
if (history.period.isNotBlank()) Text(history.period, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
1142
1142
+
if (history.team.isNotBlank()) Text(history.team, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.secondary)
1107
1143
}
1108
1144
}
1109
1145
}
1110
1146
}
1111
1147
1112
1112
-
@Composable private fun BackButton(text: String, onClick: () -> Unit) = TextButton(onClick = onClick) {
1148
1148
+
@Composable private fun BackButton(text: String, onClick: () -> Unit) = ExpressiveTextButton(onClick = onClick) {
1113
1149
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
1114
1150
Text(text)
1115
1151
}
···
3
3
import androidx.compose.foundation.clickable
4
4
import androidx.compose.foundation.layout.*
5
5
import androidx.compose.foundation.rememberScrollState
6
6
-
import androidx.compose.foundation.shape.RoundedCornerShape
7
6
import androidx.compose.foundation.verticalScroll
8
7
import androidx.compose.material.icons.Icons
9
8
import androidx.compose.material.icons.filled.Visibility
10
9
import androidx.compose.material.icons.filled.VisibilityOff
11
10
import androidx.compose.material3.*
12
12
-
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
13
11
import androidx.compose.runtime.*
14
12
import androidx.compose.ui.Alignment
15
13
import androidx.compose.ui.Modifier
···
25
23
import com.derakkuma.data.RatingData
26
24
import com.derakkuma.ui.components.BigStat
27
25
import com.derakkuma.ui.components.ErrorState
26
26
+
import com.derakkuma.ui.components.ExpressiveGlassCard
27
27
+
import com.derakkuma.ui.components.ExpressivePullToRefreshBox
28
28
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
29
29
+
import com.derakkuma.ui.components.ExpressiveTextButton
28
30
import com.derakkuma.ui.components.LoadingState
29
31
import com.derakkuma.ui.components.MaimaiProfile
30
32
import com.derakkuma.ui.components.PlayHeatmap
···
134
136
}
135
137
}
136
138
137
137
-
PullToRefreshBox(
139
139
+
ExpressivePullToRefreshBox(
138
140
isRefreshing = isRefreshing,
139
141
onRefresh = {
140
140
-
if (maintenanceMode) return@PullToRefreshBox
142
142
+
if (maintenanceMode) return@ExpressivePullToRefreshBox
141
143
isRefreshing = true
142
144
refreshKey += 1
143
145
},
···
147
149
) {
148
150
// Thin loading bar at top when refreshing
149
151
if (loading && data != null) {
150
150
-
LinearProgressIndicator(
152
152
+
ExpressiveLinearProgressIndicator(
151
153
modifier = Modifier.fillMaxWidth().height(2.dp),
152
152
-
color = PrimaryBlue,
154
154
+
color = MaterialTheme.colorScheme.primary,
153
155
trackColor = Color.Transparent,
154
156
)
155
157
}
···
191
193
// Missions
192
194
if (pd.missions.tasks.isNotEmpty() || pd.missions.deadline.isNotBlank()) {
193
195
Spacer(Modifier.height(8.dp))
194
194
-
Card(
196
196
+
ExpressiveGlassCard(
195
197
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
196
196
-
shape = RoundedCornerShape(8.dp),
197
197
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
198
198
+
cornerRadius = 28.dp,
198
199
) {
199
200
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
200
200
-
Text("Missions", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
201
201
+
Text("Missions", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
201
202
if (pd.missions.deadline.isNotBlank()) {
202
202
-
Text("Until ${pd.missions.deadline}", fontSize = 12.sp, color = MutedText)
203
203
+
Text("Until ${pd.missions.deadline}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
203
204
}
204
204
-
Text("Progress: ${pd.missions.progress}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
205
205
+
Text("Progress: ${pd.missions.progress}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
205
206
pd.missions.tasks.forEach { task ->
206
207
Row(
207
208
modifier = Modifier.fillMaxWidth(),
208
209
horizontalArrangement = Arrangement.SpaceBetween,
209
210
) {
210
210
-
Text(task.description, fontSize = 13.sp, color = DarkText, modifier = Modifier.weight(1f))
211
211
-
Text("+${task.reward}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = AccentPink)
211
211
+
Text(task.description, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
212
212
+
Text("+${task.reward}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
212
213
}
213
214
}
214
215
}
···
236
237
237
238
@Composable
238
239
private fun LastPlayedCard(play: RecentPlay, onClick: (() -> Unit)? = null) {
239
239
-
Card(
240
240
+
ExpressiveGlassCard(
240
241
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)
241
242
.clickable(enabled = onClick != null) { onClick?.invoke() },
242
242
-
shape = RoundedCornerShape(8.dp),
243
243
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
243
243
+
cornerRadius = 24.dp,
244
244
) {
245
245
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
246
246
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
247
247
-
Text("Last played", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
248
248
-
if (onClick != null) Text("Tap for details", fontSize = 11.sp, color = PrimaryBlue, fontWeight = FontWeight.SemiBold)
247
247
+
Text("Last played", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
248
248
+
if (onClick != null) Text("Tap for details", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
249
249
}
250
250
SongRow(
251
251
name = play.name,
···
269
269
270
270
@Composable
271
271
private fun StatusChip(label: String) {
272
272
-
Card(
273
273
-
shape = RoundedCornerShape(8.dp),
274
274
-
colors = CardDefaults.cardColors(containerColor = PrimaryBlue.copy(alpha = 0.1f)),
272
272
+
ExpressiveGlassCard(
273
273
+
cornerRadius = 18.dp,
274
274
+
containerColor = MaterialTheme.colorScheme.primaryContainer,
275
275
) {
276
276
Row(
277
277
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
278
278
horizontalArrangement = Arrangement.spacedBy(6.dp),
279
279
verticalAlignment = Alignment.CenterVertically,
280
280
) {
281
281
-
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
282
282
-
Text("!", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = AccentPink)
281
281
+
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer)
282
282
+
Text("!", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
283
283
}
284
284
}
285
285
}
···
307
307
ratingTrailing = {
308
308
Spacer(Modifier.weight(1f))
309
309
if (friendCode != null) {
310
310
-
TextButton(onClick = onToggleFriendCode) {
310
310
+
ExpressiveTextButton(onClick = onToggleFriendCode) {
311
311
Icon(
312
312
imageVector = if (showFriendCode) Icons.Default.Visibility else Icons.Default.VisibilityOff,
313
313
contentDescription = if (showFriendCode) "Hide friend code" else "Show friend code",
314
314
-
tint = PrimaryBlue,
314
314
+
tint = MaterialTheme.colorScheme.primary,
315
315
modifier = Modifier.size(20.dp),
316
316
)
317
317
Spacer(Modifier.width(4.dp))
318
318
Text(
319
319
text = "Friend Code",
320
320
fontSize = 12.sp,
321
321
-
color = PrimaryBlue,
321
321
+
color = MaterialTheme.colorScheme.primary,
322
322
)
323
323
}
324
324
}
325
325
},
326
326
details = {
327
327
if (friendCode != null && showFriendCode) {
328
328
-
Card(
328
328
+
ExpressiveGlassCard(
329
329
modifier = Modifier.fillMaxWidth(),
330
330
-
shape = RoundedCornerShape(6.dp),
331
331
-
colors = CardDefaults.cardColors(containerColor = Color(0xFFF3F4F6)),
330
330
+
cornerRadius = 16.dp,
331
331
+
containerColor = Color.White,
332
332
) {
333
333
Row(
334
334
modifier = Modifier.fillMaxWidth().padding(12.dp),
···
339
339
text = friendCode.code,
340
340
fontSize = 20.sp,
341
341
fontWeight = FontWeight.Bold,
342
342
-
color = DarkText,
342
342
+
color = MaterialTheme.colorScheme.onSurface,
343
343
)
344
344
CopyButton(friendCode.code)
345
345
}
···
1
1
package com.derakkuma.ui.tabs
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
3
4
import androidx.compose.foundation.layout.*
4
5
import androidx.compose.foundation.rememberScrollState
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
6
6
import androidx.compose.foundation.verticalScroll
7
7
import androidx.compose.material.icons.Icons
8
8
import androidx.compose.material.icons.filled.Link
···
16
16
import com.derakkuma.data.PlayStore
17
17
import com.derakkuma.kamaitachi.KamaitachiClient
18
18
import com.derakkuma.kamaitachi.KamaitachiSyncProgress
19
19
+
import com.derakkuma.ui.components.ExpressiveButton
20
20
+
import com.derakkuma.ui.components.ExpressiveGlassCard
21
21
+
import com.derakkuma.ui.components.ExpressiveSnackbar
22
22
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
23
23
+
import com.derakkuma.ui.components.ExpressiveScaffold
24
24
+
import com.derakkuma.ui.components.ExpressiveOutlinedButton
19
25
import com.derakkuma.ui.theme.*
20
26
import kotlinx.coroutines.launch
21
27
···
29
35
var snackMsg by remember { mutableStateOf<String?>(null) }
30
36
val scope = rememberCoroutineScope()
31
37
32
32
-
Scaffold(
38
38
+
ExpressiveScaffold(
33
39
contentWindowInsets = WindowInsets(0.dp),
34
40
snackbarHost = {
35
41
if (snackMsg != null) {
36
36
-
Snackbar(
42
42
+
ExpressiveSnackbar(
37
43
modifier = Modifier.padding(12.dp),
38
38
-
shape = RoundedCornerShape(8.dp),
39
39
-
action = { TextButton(onClick = { snackMsg = null }) { Text("OK") } },
44
44
+
action = { com.derakkuma.ui.components.ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") } },
40
45
) { Text(snackMsg!!) }
41
46
}
42
47
},
···
49
54
.padding(start = 12.dp, end = 12.dp, bottom = 88.dp),
50
55
verticalArrangement = Arrangement.spacedBy(8.dp),
51
56
) {
52
52
-
Text("Kamaitachi", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(bottom = 4.dp))
57
57
+
Text("Kamaitachi", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 4.dp))
53
58
54
54
-
Card(
59
59
+
ExpressiveGlassCard(
55
60
modifier = Modifier.fillMaxWidth(),
56
56
-
shape = RoundedCornerShape(8.dp),
57
57
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
58
61
) {
59
62
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
60
60
-
Text("Score Sync", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
61
61
-
Text("Sync your recent and archived maimai DX records to Kamaitachi.", fontSize = 12.sp, color = MutedText)
63
63
+
Text("Score Sync", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
64
64
+
Text("Sync your recent and archived maimai DX records to Kamaitachi.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
62
65
63
66
var syncing by remember { mutableStateOf(false) }
64
67
var syncProgress by remember { mutableStateOf<KamaitachiSyncProgress?>(null) }
65
68
66
69
if (kamaitachiClient.lastStatus.isNotBlank()) {
67
67
-
Text(kamaitachiClient.lastStatus, fontSize = 12.sp, color = MutedText)
70
70
+
Text(kamaitachiClient.lastStatus, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
68
71
}
69
72
if (syncing && syncProgress != null) {
70
73
val progress = syncProgress!!.current.toFloat() / syncProgress!!.total.coerceAtLeast(1).toFloat()
71
71
-
LinearProgressIndicator(
74
74
+
ExpressiveLinearProgressIndicator(
72
75
progress = { progress.coerceIn(0f, 1f) },
73
76
modifier = Modifier.fillMaxWidth(),
74
74
-
color = PrimaryBlue,
77
77
+
color = MaterialTheme.colorScheme.primary,
75
78
)
76
76
-
Text(syncProgress!!.status, fontSize = 12.sp, color = MutedText)
79
79
+
Text(syncProgress!!.status, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
77
80
}
78
81
79
82
if (kamaitachiClient.isLinked) {
80
83
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
81
81
-
Button(
84
84
+
ExpressiveButton(
82
85
onClick = {
83
86
syncing = true
84
87
syncProgress = null
···
99
102
}
100
103
},
101
104
modifier = Modifier.weight(1f),
102
102
-
shape = RoundedCornerShape(8.dp),
103
103
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
105
105
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
104
106
enabled = !syncing,
105
107
) {
106
108
if (syncing) {
107
107
-
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = CardWhite, strokeWidth = 2.dp)
109
109
+
ExpressiveLoadingIndicator(modifier = Modifier, color = MaterialTheme.colorScheme.onPrimary, size = 16.dp)
108
110
Spacer(Modifier.width(8.dp))
109
111
}
110
112
Text(if (syncing) "Syncing..." else "Sync Records", fontWeight = FontWeight.SemiBold)
111
113
}
112
112
-
OutlinedButton(
114
114
+
ExpressiveOutlinedButton(
113
115
onClick = {
114
116
kamaitachiClient.disconnect()
115
117
snackMsg = "Kamaitachi disconnected"
116
118
},
117
117
-
shape = RoundedCornerShape(8.dp),
118
119
enabled = !syncing,
119
120
) {
120
121
Text("Disconnect")
121
122
}
122
123
}
123
124
} else {
124
124
-
Button(
125
125
+
ExpressiveButton(
125
126
onClick = { kamaitachiClient.beginLogin() },
126
127
modifier = Modifier.fillMaxWidth(),
127
127
-
shape = RoundedCornerShape(8.dp),
128
128
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
128
128
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
129
129
) {
130
130
Icon(Icons.Default.Link, contentDescription = null, modifier = Modifier.size(18.dp))
131
131
Spacer(Modifier.width(8.dp))
···
4
4
import androidx.compose.foundation.layout.*
5
5
import androidx.compose.foundation.lazy.LazyColumn
6
6
import androidx.compose.foundation.lazy.items
7
7
-
import androidx.compose.foundation.shape.RoundedCornerShape
8
7
import androidx.compose.material.icons.Icons
9
8
import androidx.compose.material.icons.outlined.LocationOn
10
9
import androidx.compose.material3.*
···
21
20
import com.derakkuma.data.distanceKm
22
21
import com.derakkuma.location.UserLocationProvider
23
22
import com.derakkuma.maps.openMapLocation
24
24
-
import com.derakkuma.ui.theme.CardWhite
25
25
-
import com.derakkuma.ui.theme.DarkText
26
26
-
import com.derakkuma.ui.theme.MutedText
27
27
-
import com.derakkuma.ui.theme.PrimaryBlue
23
23
+
import com.derakkuma.ui.components.ExpressiveGlassCard
24
24
+
import com.derakkuma.ui.components.ExpressiveTextButton
25
25
+
import com.derakkuma.ui.components.ExpressiveTopLoadingIndicator
28
26
import com.swmansion.kmpmaps.core.CameraPosition
29
27
import com.swmansion.kmpmaps.core.ClusterSettings
30
28
import com.swmansion.kmpmaps.core.Coordinates
···
77
75
Column(Modifier.fillMaxSize().padding(start = 12.dp, end = 12.dp, top = 12.dp)) {
78
76
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
79
77
Column {
80
80
-
Text("Cab Map", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
81
81
-
Text(locationStatus(userLocation, locations.size), fontSize = 12.sp, color = MutedText)
78
78
+
Text("Cab Map", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
79
79
+
Text(locationStatus(userLocation, locations.size), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
82
80
}
83
83
-
TextButton(onClick = { refresh(force = true) }, enabled = !loading) {
81
81
+
ExpressiveTextButton(onClick = { refresh(force = true) }, enabled = !loading) {
84
82
Text("Refresh")
85
83
}
86
84
}
87
85
88
86
Spacer(Modifier.height(8.dp))
89
87
90
90
-
Card(Modifier.fillMaxWidth().height(260.dp), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
88
88
+
if (loading) {
89
89
+
ExpressiveTopLoadingIndicator(color = MaterialTheme.colorScheme.primary, trackColor = androidx.compose.ui.graphics.Color.Transparent)
90
90
+
Spacer(Modifier.height(6.dp))
91
91
+
}
92
92
+
93
93
+
ExpressiveGlassCard(Modifier.fillMaxWidth().height(260.dp), cornerRadius = 28.dp) {
91
94
if (locations.isEmpty() && loading) {
92
92
-
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(color = PrimaryBlue) }
95
95
+
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Loading arcade locations…", color = MaterialTheme.colorScheme.onSurfaceVariant) }
93
96
} else if (locations.isEmpty()) {
94
97
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
95
95
-
Text(error ?: "No locations loaded yet", color = MutedText)
98
98
+
Text(error ?: "No locations loaded yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
96
99
}
97
100
} else {
98
101
Map(
···
114
117
}
115
118
116
119
if (error != null && locations.isNotEmpty()) {
117
117
-
Text("using cached locations: $error", modifier = Modifier.padding(top = 8.dp), fontSize = 12.sp, color = MutedText)
120
120
+
Text("using cached locations: $error", modifier = Modifier.padding(top = 8.dp), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
118
121
}
119
122
120
123
Spacer(Modifier.height(8.dp))
···
129
132
130
133
@Composable
131
134
private fun ArcadeLocationRow(location: ArcadeLocation, userLocation: UserLocation?) {
132
132
-
Card(
135
135
+
ExpressiveGlassCard(
133
136
Modifier.fillMaxWidth().clickable { openMapLocation(location.name, location.latitude, location.longitude, location.mapsUrl) },
134
134
-
shape = RoundedCornerShape(8.dp),
135
135
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
137
137
+
cornerRadius = 24.dp,
136
138
) {
137
139
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
138
138
-
Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(22.dp))
140
140
+
Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp))
139
141
Column(Modifier.weight(1f)) {
140
140
-
Text(location.name, fontWeight = FontWeight.Bold, color = DarkText, fontSize = 14.sp)
141
141
-
if (location.address.isNotBlank()) Text(location.address, color = MutedText, fontSize = 12.sp, lineHeight = 15.sp)
142
142
+
Text(location.name, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp)
143
143
+
if (location.address.isNotBlank()) Text(location.address, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 12.sp, lineHeight = 15.sp)
142
144
}
143
145
userLocation?.let {
144
144
-
Text(formatDistance(distanceKm(it, location)), color = PrimaryBlue, fontWeight = FontWeight.Bold, fontSize = 12.sp)
146
146
+
Text(formatDistance(distanceKm(it, location)), color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
145
147
}
146
148
}
147
149
}
···
2
2
3
3
import androidx.compose.animation.core.LinearEasing
4
4
import androidx.compose.animation.core.animateFloat
5
5
+
import androidx.compose.animation.core.animateFloatAsState
5
6
import androidx.compose.animation.core.infiniteRepeatable
6
7
import androidx.compose.animation.core.rememberInfiniteTransition
8
8
+
import androidx.compose.animation.core.spring
7
9
import androidx.compose.animation.core.tween
8
10
import androidx.compose.foundation.clickable
11
11
+
import androidx.compose.foundation.interaction.MutableInteractionSource
12
12
+
import androidx.compose.foundation.interaction.collectIsPressedAsState
9
13
import androidx.compose.foundation.layout.*
10
14
import androidx.compose.foundation.rememberScrollState
11
11
-
import androidx.compose.foundation.shape.RoundedCornerShape
12
15
import androidx.compose.foundation.verticalScroll
13
16
import androidx.compose.material.icons.Icons
14
17
import androidx.compose.material.icons.outlined.*
···
17
20
import androidx.compose.ui.Alignment
18
21
import androidx.compose.ui.Modifier
19
22
import androidx.compose.ui.draw.alpha
23
23
+
import androidx.compose.ui.draw.scale
20
24
import androidx.compose.ui.graphics.Color
21
25
import androidx.compose.ui.graphics.compositeOver
22
26
import androidx.compose.ui.graphics.vector.ImageVector
···
32
36
import com.derakkuma.ui.icons.PersonPinCircleOutlined
33
37
import com.derakkuma.ui.icons.Signature
34
38
import com.derakkuma.ui.icons.SocialLeaderboardOutlined
39
39
+
import com.derakkuma.ui.components.ExpressiveGlassCard
40
40
+
import com.derakkuma.ui.components.ExpressiveTextButton
35
41
import com.derakkuma.ui.images.BundledIcon
36
42
import com.derakkuma.ui.images.BundledImage
37
43
import com.derakkuma.ui.theme.*
38
38
-
import com.derakkuma.ui.theme.DangerRed
39
44
40
40
-
private val SettingsCardTint = Color(0xFFBA47DE).asCardTint()
41
41
-
private val CollectionCardTint = Color(0xFF3CB9ED).asCardTint()
42
42
-
private val ShopCardTint = Color(0xFF5FDDB7).asCardTint()
43
43
-
private val PlayDataCardTint = Color(0xFFEFB944).asCardTint()
44
44
-
private val RankingCardTint = Color(0xFFBA6EEE).asCardTint()
45
45
-
private val EventCardTint = Color(0xFF7CAB44).asCardTint()
46
46
-
private val KamaitachiCardTint = Color(0xFFCD226F).asCardTint()
47
45
48
46
@Composable
49
47
fun MoreMenu(
···
68
66
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 8_000, easing = LinearEasing)),
69
67
label = "app-icon-hue",
70
68
)
71
71
-
val appIconRainbowTint = Color.hsv(appIconHue, 0.55f, 1f).asCardTint(alpha = 0.2f)
69
69
+
val appIconRainbowTint = Color.hsv(appIconHue, 0.55f, 1f).asCardTint(base = MaterialTheme.colorScheme.surface, alpha = 0.2f)
70
70
+
val settingsCardTint = MaterialTheme.colorScheme.primaryContainer
71
71
+
val collectionCardTint = MaterialTheme.colorScheme.tertiaryContainer
72
72
+
val shopCardTint = MaterialTheme.colorScheme.secondaryContainer
73
73
+
val playDataCardTint = Color.White
74
74
+
val rankingCardTint = MaterialTheme.colorScheme.primaryContainer
75
75
+
val eventCardTint = MaterialTheme.colorScheme.tertiaryContainer
76
76
+
val kamaitachiCardTint = MaterialTheme.colorScheme.errorContainer
77
77
+
val favoriteSongsCardTint = ExpressiveBubblegum.copy(alpha = 0.18f).compositeOver(Color.White)
78
78
+
val playerNameCardTint = ExpressivePeach.copy(alpha = 0.22f).compositeOver(Color.White)
79
79
+
val mapCardTint = ExpressiveSky.copy(alpha = 0.18f).compositeOver(Color.White)
80
80
+
val bubbleCardTint = ExpressiveMint.copy(alpha = 0.5f).compositeOver(Color.White)
81
81
+
val appSettingsCardTint = ExpressiveGrape.copy(alpha = 0.16f).compositeOver(Color.White)
72
82
73
83
Column(
74
84
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 88.dp),
75
85
verticalArrangement = Arrangement.spacedBy(8.dp),
76
86
) {
77
77
-
Text("More", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(bottom = 4.dp))
87
87
+
Text("More", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 4.dp))
78
88
79
89
BentoCard(
80
90
icon = Icons.Outlined.Settings,
···
83
93
description = "Note speed, timing, sound, display options, friend registration, etc.",
84
94
onClick = onSettings,
85
95
disabled = maintenanceMode,
86
86
-
containerColor = SettingsCardTint,
96
96
+
containerColor = settingsCardTint,
87
97
modifier = Modifier.fillMaxWidth(),
88
98
)
89
99
···
95
105
description = "",
96
106
onClick = onCollection,
97
107
disabled = maintenanceMode,
98
98
-
containerColor = CollectionCardTint,
108
108
+
containerColor = collectionCardTint,
99
109
modifier = Modifier.weight(1f).fillMaxHeight(),
100
110
)
101
111
BentoCard(
···
107
117
onClick = {},
108
118
disabled = true,
109
119
disabledLabel = "Coming soon",
110
110
-
containerColor = ShopCardTint,
120
120
+
containerColor = shopCardTint,
111
121
modifier = Modifier.weight(1f).fillMaxHeight(),
112
122
)
113
123
}
···
119
129
title = "Player Data",
120
130
onClick = onPlayerData,
121
131
compact = true,
122
122
-
containerColor = PlayDataCardTint,
132
132
+
containerColor = playDataCardTint,
123
133
modifier = Modifier.weight(1f).fillMaxHeight(),
124
134
)
125
135
BentoCard(
···
129
139
onClick = onFavorites,
130
140
disabled = maintenanceMode,
131
141
compact = true,
142
142
+
containerColor = favoriteSongsCardTint,
132
143
modifier = Modifier.weight(1f).fillMaxHeight(),
133
144
)
134
145
BentoCard(
···
138
149
onClick = onPlayerName,
139
150
disabled = maintenanceMode,
140
151
compact = true,
152
152
+
containerColor = playerNameCardTint,
141
153
modifier = Modifier.weight(1f).fillMaxHeight(),
142
154
)
143
155
}
···
149
161
title = "Leaderboards",
150
162
onClick = onRanking,
151
163
disabled = maintenanceMode,
152
152
-
containerColor = RankingCardTint,
164
164
+
containerColor = rankingCardTint,
153
165
modifier = Modifier.weight(1f).fillMaxHeight(),
154
166
)
155
167
BentoCard(
···
159
171
onClick = {},
160
172
disabled = true,
161
173
disabledLabel = "Coming soon",
162
162
-
containerColor = EventCardTint,
174
174
+
containerColor = eventCardTint,
163
175
modifier = Modifier.weight(1f).fillMaxHeight(),
164
176
)
165
177
}
···
171
183
title = "Cab Map",
172
184
description = "",
173
185
onClick = onMap,
186
186
+
containerColor = mapCardTint,
174
187
modifier = Modifier.weight(1f).fillMaxHeight(),
175
188
)
176
189
BentoCard(
···
179
192
title = "Bubble",
180
193
description = "",
181
194
onClick = onBubble,
195
195
+
containerColor = bubbleCardTint,
182
196
modifier = Modifier.weight(1f).fillMaxHeight(),
183
197
)
184
198
}
···
190
204
title = "App Settings",
191
205
description = "Cache, account, about",
192
206
onClick = onAppSettings,
207
207
+
containerColor = appSettingsCardTint,
193
208
modifier = Modifier.weight(1f).fillMaxHeight(),
194
209
)
195
210
BentoCard(
···
199
214
description = "Sync records",
200
215
onClick = onKamaitachi,
201
216
disabled = maintenanceMode,
202
202
-
containerColor = KamaitachiCardTint,
217
217
+
containerColor = kamaitachiCardTint,
203
218
modifier = Modifier.weight(1f).fillMaxHeight(),
204
219
)
205
220
}
···
220
235
}
221
236
222
237
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
223
223
-
TextButton(onClick = { openOAuthUrl("https://derakkuma.com/terms") }) {
238
238
+
ExpressiveTextButton(onClick = { openOAuthUrl("https://derakkuma.com/terms") }) {
224
239
Text("Terms of Service", fontSize = 11.sp, color = Color.White)
225
240
}
226
226
-
TextButton(onClick = { openOAuthUrl("https://derakkuma.com/privacy") }) {
241
241
+
ExpressiveTextButton(onClick = { openOAuthUrl("https://derakkuma.com/privacy") }) {
227
242
Text("Privacy Policy", fontSize = 11.sp, color = Color.White)
228
243
}
229
244
}
230
245
}
231
246
}
232
247
233
233
-
private fun Color.asCardTint(alpha: Float = 0.14f): Color = copy(alpha = alpha).compositeOver(Color.White)
248
248
+
private fun Color.asCardTint(base: Color, alpha: Float = 0.14f): Color = copy(alpha = alpha).compositeOver(base)
234
249
235
250
@Composable
236
251
private fun BentoRow(content: @Composable RowScope.() -> Unit) {
···
254
269
descriptionIcon: BundledImage? = null,
255
270
onClick: () -> Unit,
256
271
modifier: Modifier = Modifier,
257
257
-
containerColor: Color = CardWhite,
272
272
+
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
258
273
disabled: Boolean = false,
259
274
disabledLabel: String = "Maintenance",
260
275
compact: Boolean = false,
261
276
) {
262
277
val hasDetails = description.isNotBlank() || disabled
278
278
+
val interactionSource = remember { MutableInteractionSource() }
279
279
+
val pressed by interactionSource.collectIsPressedAsState()
280
280
+
val scale by animateFloatAsState(
281
281
+
targetValue = if (pressed && !disabled) 0.97f else 1f,
282
282
+
animationSpec = spring(dampingRatio = 0.62f, stiffness = 460f),
283
283
+
label = "more-card-press-scale",
284
284
+
)
263
285
264
264
-
Card(
265
265
-
modifier = modifier,
266
266
-
shape = RoundedCornerShape(8.dp),
267
267
-
colors = CardDefaults.cardColors(containerColor = containerColor),
286
286
+
ExpressiveGlassCard(
287
287
+
modifier = modifier.scale(scale),
288
288
+
cornerRadius = if (compact) 22.dp else 28.dp,
289
289
+
containerColor = containerColor,
268
290
) {
269
269
-
Box(modifier = Modifier.fillMaxSize().clickable(enabled = !disabled, onClick = onClick)) {
291
291
+
Box(
292
292
+
modifier = Modifier
293
293
+
.fillMaxSize()
294
294
+
.clickable(enabled = !disabled, interactionSource = interactionSource, indication = null, onClick = onClick),
295
295
+
) {
270
296
backgroundStartImage?.let { image ->
271
297
BundledIcon(
272
298
image = image,
···
297
323
modifier = Modifier.size(26.dp).alpha(if (disabled) 0.45f else 1f),
298
324
)
299
325
} else {
300
300
-
Icon(icon, contentDescription = null, modifier = Modifier.size(26.dp), tint = if (disabled) MutedText.copy(alpha = 0.4f) else PrimaryBlue)
326
326
+
Icon(icon, contentDescription = null, modifier = Modifier.size(26.dp), tint = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primary)
301
327
}
302
328
}
303
303
-
Text(title, fontSize = if (compact) 13.sp else 15.sp, fontWeight = FontWeight.SemiBold, color = if (disabled) MutedText else DarkText)
329
329
+
Text(title, fontSize = if (compact) 13.sp else 15.sp, fontWeight = FontWeight.SemiBold, color = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface)
304
330
}
305
331
if (hasDetails) {
306
332
Column(
···
323
349
Text(
324
350
description,
325
351
fontSize = 11.sp,
326
326
-
color = MutedText,
352
352
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
327
353
lineHeight = 13.sp,
328
354
textAlign = if (centerText) TextAlign.Center else TextAlign.Start,
329
355
)
330
356
}
331
357
}
332
358
if (disabled) {
333
333
-
Text(disabledLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = DangerRed.copy(alpha = 0.7f))
359
359
+
Text(disabledLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f))
334
360
}
335
361
}
336
362
}
···
2
2
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.rememberScrollState
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
6
5
import androidx.compose.foundation.verticalScroll
6
6
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
7
7
+
import androidx.compose.material3.MaterialShapes
7
8
import androidx.compose.material3.*
9
9
+
import androidx.compose.material3.toShape
8
10
import androidx.compose.runtime.*
9
11
import androidx.compose.ui.Alignment
10
12
import androidx.compose.ui.Modifier
···
22
24
import com.derakkuma.data.CacheStore
23
25
import com.derakkuma.ui.components.EmptyState
24
26
import com.derakkuma.ui.components.ErrorState
27
27
+
import com.derakkuma.ui.components.ExpressiveGlassCard
28
28
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
29
29
+
import com.derakkuma.ui.components.ExpressiveBadgeSurface
25
30
import com.derakkuma.ui.components.LoadingState
26
31
import com.derakkuma.ui.images.*
27
32
import com.derakkuma.ui.theme.*
28
33
import com.derakkuma.util.formatScore
29
34
35
35
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
30
36
@Composable
31
37
fun MusicDetailTab(client: MaimaiClient, cookie: String, idx: String, maintenanceMode: Boolean = false, cacheStore: CacheStore = CacheStore(), atProtoClient: AtProtoClient? = null) {
32
38
var detail by remember { mutableStateOf<MusicDetail?>(null) }
···
77
83
val d = detail!!
78
84
79
85
// Song header
80
80
-
Card(
81
81
-
modifier = Modifier.fillMaxWidth(),
82
82
-
shape = RoundedCornerShape(8.dp),
83
83
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
84
84
-
) {
86
86
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
85
87
Row(
86
88
modifier = Modifier.padding(12.dp),
87
89
verticalAlignment = Alignment.CenterVertically,
···
90
92
if (d.albumArtUrl != null) {
91
93
MaimaiRemoteImage(
92
94
url = d.albumArtUrl,
93
93
-
modifier = Modifier.size(60.dp).clip(RoundedCornerShape(4.dp)),
95
95
+
modifier = Modifier.size(60.dp).clip(MaterialShapes.Square.toShape()),
94
96
contentDescription = d.name,
95
97
contentScale = ContentScale.Crop,
96
98
)
97
99
}
98
100
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
99
99
-
Text(d.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, maxLines = 2)
101
101
+
Text(d.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 2)
100
102
if (d.artist.isNotBlank()) {
101
101
-
Text(d.artist, fontSize = 12.sp, color = MutedText)
103
103
+
Text(d.artist, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
102
104
}
103
105
}
104
106
}
···
108
110
109
111
// All difficulty scores
110
112
if (d.scores.isNotEmpty()) {
111
111
-
Card(
112
112
-
modifier = Modifier.fillMaxWidth(),
113
113
-
shape = RoundedCornerShape(8.dp),
114
114
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
115
115
-
) {
113
113
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
116
114
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
117
117
-
Text("All Difficulties", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
115
115
+
Text("All Difficulties", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
118
116
119
117
d.scores.forEach { score ->
120
118
DifficultyScoreRow(score)
121
119
if (score != d.scores.last()) {
122
122
-
HorizontalDivider(color = MutedText.copy(alpha = 0.15f))
120
120
+
ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f))
123
121
}
124
122
}
125
123
}
126
124
}
127
125
} else {
128
128
-
Card(
129
129
-
modifier = Modifier.fillMaxWidth(),
130
130
-
shape = RoundedCornerShape(8.dp),
131
131
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
132
132
-
) {
126
126
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 24.dp) {
133
127
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
134
134
-
Text("No scores recorded", fontSize = 14.sp, color = MutedText)
128
128
+
Text("No scores recorded", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
135
129
}
136
130
}
137
131
}
···
147
141
leaderboards: Map<String, List<AtProtoLeaderboardEntry>>,
148
142
error: String?,
149
143
) {
150
150
-
Card(
151
151
-
modifier = Modifier.fillMaxWidth(),
152
152
-
shape = RoundedCornerShape(8.dp),
153
153
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
154
154
-
) {
144
144
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
155
145
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
156
156
-
Text("AT Protocol Leaderboards", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
146
146
+
Text("AT Protocol Leaderboards", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
157
147
if (error != null) {
158
148
Text(error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
159
149
return@Column
160
150
}
161
151
if (leaderboards.isEmpty()) {
162
162
-
Text("No published scores yet.", fontSize = 12.sp, color = MutedText)
152
152
+
Text("No published scores yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
163
153
} else {
164
154
leaderboards.forEach { (difficulty, entries) ->
165
155
Text(difficulty.toAtProtoDifficultyName(), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = difficultyColor(difficulty))
166
156
if (entries.isEmpty()) {
167
167
-
Text("No entries", fontSize = 11.sp, color = MutedText)
157
157
+
Text("No entries", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
168
158
} else {
169
159
entries.take(5).forEachIndexed { index, entry ->
170
160
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
171
161
val label = entry.playerName.ifBlank { entry.did.take(12) + "…" }
172
172
-
Text("#${entry.rank.takeIf { it > 0 } ?: index + 1} $label", fontSize = 12.sp, color = DarkText, modifier = Modifier.weight(1f))
173
173
-
Text("${formatScore(entry.achievement.toDoubleOrNull() ?: 0.0)}%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentPink)
162
162
+
Text("#${entry.rank.takeIf { it > 0 } ?: index + 1} $label", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
163
163
+
Text("${formatScore(entry.achievement.toDoubleOrNull() ?: 0.0)}%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
174
164
}
175
165
}
176
166
}
···
187
177
verticalAlignment = Alignment.CenterVertically,
188
178
) {
189
179
// Difficulty badge
190
190
-
Surface(
191
191
-
shape = RoundedCornerShape(4.dp),
192
192
-
color = difficultyColor(score.difficulty).copy(alpha = 0.15f),
180
180
+
ExpressiveBadgeSurface(
181
181
+
shape = MaterialTheme.shapes.large,
182
182
+
containerColor = difficultyColor(score.difficulty).copy(alpha = 0.15f),
193
183
) {
194
184
Text(
195
185
" ${score.difficulty.replaceFirstChar { it.uppercase() }} ${score.level} ",
···
214
204
"${formatScore(score.achievement)}%",
215
205
fontSize = 13.sp,
216
206
fontWeight = FontWeight.Bold,
217
217
-
color = AccentPink,
207
207
+
color = MaterialTheme.colorScheme.secondary,
218
208
)
219
209
}
220
210
if (score.scoreRank.isNotBlank()) {
···
237
227
// DX score + play info
238
228
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
239
229
if (score.dxScore.achieved > 0) {
240
240
-
Text("DX ${score.dxScore.achieved}/${score.dxScore.total}", fontSize = 10.sp, color = MutedText)
230
230
+
Text("DX ${score.dxScore.achieved}/${score.dxScore.total}", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
241
231
}
242
232
if (score.playCount > 0) {
243
243
-
Text("Plays: ${score.playCount}", fontSize = 10.sp, color = MutedText)
233
233
+
Text("Plays: ${score.playCount}", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
244
234
}
245
235
if (score.lastPlayed.isNotBlank()) {
246
246
-
Text(score.lastPlayed, fontSize = 10.sp, color = MutedText)
236
236
+
Text(score.lastPlayed, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
247
237
}
248
238
}
249
239
}
···
2
2
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.rememberScrollState
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
6
5
import androidx.compose.foundation.verticalScroll
6
6
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
7
7
+
import androidx.compose.material3.MaterialShapes
7
8
import androidx.compose.material3.*
9
9
+
import androidx.compose.material3.toShape
8
10
import androidx.compose.runtime.*
9
11
import androidx.compose.ui.Alignment
10
12
import androidx.compose.ui.Modifier
···
20
22
import com.derakkuma.data.CacheStore
21
23
import com.derakkuma.ui.components.EmptyState
22
24
import com.derakkuma.ui.components.ErrorState
25
25
+
import com.derakkuma.ui.components.ExpressiveGlassCard
26
26
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
23
27
import com.derakkuma.ui.components.LoadingState
24
28
import com.derakkuma.ui.images.*
25
29
import com.derakkuma.ui.theme.*
···
34
38
import network.chaintech.cmpcharts.ui.segmentprogresschart.model.SegmentProgressBarColors
35
39
import network.chaintech.cmpcharts.ui.segmentprogresschart.model.SegmentProgressBarConfig
36
40
41
41
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
37
42
@Composable
38
43
fun PlayDetailTab(client: MaimaiClient, cookie: String, idx: String, onMusicDetail: ((idx: String) -> Unit)? = null, maintenanceMode: Boolean = false, cacheStore: CacheStore = CacheStore(), playStore: PlayStore? = null) {
39
44
var detail by remember { mutableStateOf<PlayLogDetail?>(null) }
···
73
78
val d = detail!!
74
79
75
80
// Song header
76
76
-
Card(
77
77
-
modifier = Modifier.fillMaxWidth(),
78
78
-
shape = RoundedCornerShape(8.dp),
79
79
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
80
80
-
) {
81
81
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
81
82
Row(
82
83
modifier = Modifier.padding(12.dp),
83
84
verticalAlignment = Alignment.CenterVertically,
···
86
87
if (d.albumArtUrl != null) {
87
88
MaimaiRemoteImage(
88
89
url = d.albumArtUrl,
89
89
-
modifier = Modifier.size(60.dp).clip(RoundedCornerShape(4.dp)),
90
90
+
modifier = Modifier.size(60.dp).clip(MaterialShapes.Square.toShape()),
90
91
contentDescription = d.name,
91
92
contentScale = ContentScale.Crop,
92
93
)
93
94
}
94
95
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
95
95
-
Text(d.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, maxLines = 2)
96
96
+
Text(d.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 2)
96
97
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
97
98
val levelText = songDbLevel?.let { "Lv ${d.level} (${formatScore(it, 1)})" } ?: "Lv ${d.level}"
98
99
Text(levelText, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = difficultyColor(d.difficulty))
99
100
if (d.isDx) BundledIcon(image = BundledImage.MusicDx, modifier = Modifier.height(12.dp), contentDescription = "DX")
100
101
}
101
101
-
Text(d.datetime, fontSize = 11.sp, color = MutedText)
102
102
+
Text(d.datetime, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
102
103
}
103
104
}
104
105
}
···
106
107
Spacer(Modifier.height(8.dp))
107
108
108
109
// Score header
109
109
-
Card(
110
110
-
modifier = Modifier.fillMaxWidth(),
111
111
-
shape = RoundedCornerShape(8.dp),
112
112
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
113
113
-
) {
110
110
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
114
111
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
115
112
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
116
113
BigStat("${formatScore(d.achievement)}%", "Achievement")
···
123
120
val deltaColor = if (d.ratingDelta > 0) {
124
121
DiffBasic
125
122
} else if (d.ratingDelta < 0) {
126
126
-
DangerRed
123
123
+
MaterialTheme.colorScheme.error
127
124
} else {
128
128
-
MutedText
125
125
+
MaterialTheme.colorScheme.onSurfaceVariant
129
126
}
130
127
val sign = if (d.ratingDelta > 0) "+" else ""
131
128
BigStat("$sign${d.ratingDelta}", "Delta", color = deltaColor)
···
140
137
BigStat("${d.maxSync.achieved}/${d.maxSync.total}", "Max Sync")
141
138
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
142
139
FastLateStat("Fast", d.fast, DiffBasic)
143
143
-
FastLateStat("Late", d.late, DangerRed)
140
140
+
FastLateStat("Late", d.late, MaterialTheme.colorScheme.error)
144
141
}
145
142
}
146
143
}
···
153
150
Spacer(Modifier.height(8.dp))
154
151
155
152
// Note breakdown table
156
156
-
Card(
157
157
-
modifier = Modifier.fillMaxWidth(),
158
158
-
shape = RoundedCornerShape(8.dp),
159
159
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
160
160
-
) {
153
153
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
161
154
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
162
162
-
Text("Note Breakdown", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
155
155
+
Text("Note Breakdown", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
163
156
NoteBreakdownTable(d.notes)
164
157
}
165
158
}
···
171
164
// Button(
172
165
// onClick = { onMusicDetail(d.musicIdx) },
173
166
// modifier = Modifier.fillMaxWidth(),
174
174
-
// shape = RoundedCornerShape(8.dp),
175
175
-
// colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
167
167
+
// shape = MaterialTheme.shapes.medium,
168
168
+
// colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
176
169
// ) {
177
170
// Text("View All Difficulties", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
178
171
// }
···
188
181
if (rankIcon != null) {
189
182
BundledIcon(image = rankIcon, modifier = Modifier.height(28.dp), contentDescription = rank)
190
183
} else {
191
191
-
Text(rank.uppercase(), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = DarkText)
184
184
+
Text(rank.uppercase(), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
192
185
}
193
193
-
Text("Rank", fontSize = 10.sp, color = MutedText)
186
186
+
Text("Rank", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
194
187
}
195
188
}
196
189
197
190
@Composable
198
198
-
private fun BigStat(value: String, label: String, color: androidx.compose.ui.graphics.Color = DarkText) {
191
191
+
private fun BigStat(value: String, label: String, color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface) {
199
192
Column(horizontalAlignment = Alignment.CenterHorizontally) {
200
193
Text(value, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = color)
201
201
-
Text(label, fontSize = 10.sp, color = MutedText)
194
194
+
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
202
195
}
203
196
}
204
197
···
206
199
private fun FastLateStat(label: String, count: Int, color: androidx.compose.ui.graphics.Color) {
207
200
Column(horizontalAlignment = Alignment.CenterHorizontally) {
208
201
Text("$count", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = color)
209
209
-
Text(label, fontSize = 10.sp, color = MutedText)
202
202
+
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
210
203
}
211
204
}
212
205
···
226
219
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
227
220
horizontalArrangement = Arrangement.SpaceBetween,
228
221
) {
229
229
-
Text("Type", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MutedText, modifier = Modifier.weight(1.2f))
222
222
+
Text("Type", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1.2f))
230
223
columns.drop(1).forEach { col ->
231
231
-
Text(col, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MutedText, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
224
224
+
Text(col, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
232
225
}
233
226
}
234
227
235
235
-
HorizontalDivider(color = MutedText.copy(alpha = 0.15f))
228
228
+
ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f))
236
229
237
230
rows.forEach { (label, judgement) ->
238
231
Row(
239
232
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
240
233
horizontalArrangement = Arrangement.SpaceBetween,
241
234
) {
242
242
-
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = DarkText, modifier = Modifier.weight(1.2f))
235
235
+
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1.2f))
243
236
val values = listOf(judgement.criticalPerfect, judgement.perfect, judgement.great, judgement.good, judgement.miss)
244
237
values.forEach { v ->
245
238
Text(
246
239
if (v > 0) "$v" else "-",
247
240
fontSize = 12.sp,
248
248
-
color = if (v > 0) DarkText else MutedText.copy(alpha = 0.4f),
241
241
+
color = if (v > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
249
242
modifier = Modifier.weight(1f),
250
243
textAlign = TextAlign.Center,
251
244
)
···
257
250
@Composable
258
251
private fun PlayBreakdownCharts(detail: PlayLogDetail) {
259
252
val radarData = detail.notes.noteTypeScores()
260
260
-
Card(
261
261
-
modifier = Modifier.fillMaxWidth(),
262
262
-
shape = RoundedCornerShape(8.dp),
263
263
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
264
264
-
) {
253
253
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
265
254
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
266
266
-
Text("Performance Shape", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
255
255
+
Text("Performance Shape", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
267
256
if (radarData.any { it.second > 0.0 }) {
268
257
RadarChart(
269
258
modifier = Modifier.fillMaxWidth().height(230.dp),
···
272
261
scaleStepsCount = 4,
273
262
maxLabelWidth = 40.dp,
274
263
radarChartLineStyle = RadarChartLineStyle(
275
275
-
color = MutedText.copy(alpha = 0.25f),
264
264
+
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
276
265
strokeWidth = 1f,
277
266
strokeCap = StrokeCap.Butt,
278
267
),
279
268
radarChartDataSet = listOf(
280
269
RadarChartDataSet(
281
270
style = RadarChartDataSetStyle(
282
282
-
color = PrimaryBlue,
283
283
-
borderColor = AccentPink,
271
271
+
color = MaterialTheme.colorScheme.primary,
272
272
+
borderColor = MaterialTheme.colorScheme.secondary,
284
273
colorAlpha = 0.22f,
285
274
strokeWidth = 2f,
286
275
strokeCap = StrokeCap.Round,
···
288
277
data = radarData.map { it.second },
289
278
),
290
279
),
291
291
-
labelTextStyle = TextStyle(color = DarkText, fontSize = 11.sp, fontWeight = FontWeight.SemiBold),
280
280
+
labelTextStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface, fontSize = 11.sp, fontWeight = FontWeight.SemiBold),
292
281
scaleTextStyle = TextStyle(color = androidx.compose.ui.graphics.Color.Transparent, fontSize = 1.sp),
293
282
scaleUnit = "%",
294
283
maxScaleValue = 100.0,
···
296
285
),
297
286
)
298
287
} else {
299
299
-
Text("No judgement data for this play", fontSize = 12.sp, color = MutedText)
288
288
+
Text("No judgement data for this play", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
300
289
}
301
290
302
291
val timingTotal = detail.fast + detail.late
303
292
if (timingTotal > 0) {
304
304
-
Text("Timing Bias", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
305
305
-
Text("Late ${detail.late} • Fast ${detail.fast}", fontSize = 12.sp, color = MutedText)
293
293
+
Text("Timing Bias", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
294
294
+
Text("Late ${detail.late} • Fast ${detail.fast}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
306
295
SegmentedProgressBarChart(
307
296
modifier = Modifier.fillMaxWidth(),
308
297
segmentProgressBarConfig = SegmentProgressBarConfig(
···
313
302
progress = (detail.fast.toFloat() / timingTotal.toFloat()).coerceIn(0f, 1f),
314
303
scaleLabels = listOf("Late", "", "Even", "", "Fast"),
315
304
segmentsList = listOf(
316
316
-
SegmentInfo(DangerRed, 1f),
305
305
+
SegmentInfo(MaterialTheme.colorScheme.error, 1f),
317
306
SegmentInfo(DiffAdvanced, 1f),
318
307
SegmentInfo(DiffBasic.copy(alpha = 0.55f), 1f),
319
308
SegmentInfo(DiffAdvanced, 1f),
320
320
-
SegmentInfo(DangerRed, 1f),
309
309
+
SegmentInfo(MaterialTheme.colorScheme.error, 1f),
321
310
),
322
311
displayScale = true,
323
312
scaleFontSize = 9.sp,
324
313
shouldAnimateProgress = true,
325
314
segmentProgressBarColors = SegmentProgressBarColors(
326
326
-
pointerColor = DarkText,
327
327
-
scaleTextColor = MutedText,
328
328
-
scaleColor = MutedText.copy(alpha = 0.35f),
315
315
+
pointerColor = MaterialTheme.colorScheme.onSurface,
316
316
+
scaleTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
317
317
+
scaleColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
329
318
),
330
319
),
331
320
)
···
1
1
package com.derakkuma.ui.tabs
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
3
4
import androidx.compose.animation.core.animateFloatAsState
4
5
import androidx.compose.animation.core.tween
5
6
import androidx.compose.foundation.Canvas
6
7
import androidx.compose.foundation.Image
8
8
+
import androidx.compose.foundation.background
7
9
import androidx.compose.foundation.layout.*
8
10
import androidx.compose.foundation.rememberScrollState
9
9
-
import androidx.compose.foundation.shape.CircleShape
10
11
import androidx.compose.foundation.shape.RoundedCornerShape
11
12
import androidx.compose.foundation.verticalScroll
12
13
import androidx.compose.material3.*
···
28
29
import com.derakkuma.data.CacheStore
29
30
import com.derakkuma.ui.components.BigStat
30
31
import com.derakkuma.ui.components.ErrorState
32
32
+
import com.derakkuma.ui.components.ExpressiveGlassCard
33
33
+
import com.derakkuma.ui.components.ExpressiveDot
31
34
import com.derakkuma.ui.components.LoadingState
32
35
import com.derakkuma.ui.images.*
33
36
import com.derakkuma.ui.theme.*
···
81
84
82
85
// Mini profile header
83
86
if (profile != null) {
84
84
-
Card(
85
85
-
modifier = Modifier.fillMaxWidth(),
86
86
-
shape = RoundedCornerShape(8.dp),
87
87
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
88
88
-
) {
87
87
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 24.dp) {
89
88
Row(
90
89
modifier = Modifier.padding(12.dp),
91
90
verticalAlignment = Alignment.CenterVertically,
···
93
92
) {
94
93
MaimaiRemoteImage(
95
94
url = profile.profileImageUrl,
96
96
-
modifier = Modifier.size(44.dp).clip(CircleShape),
95
95
+
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(2.dp)),
97
96
contentDescription = "Profile icon",
98
97
contentScale = ContentScale.Crop,
99
98
)
100
99
Column(modifier = Modifier.weight(1f)) {
101
101
-
Text(profile.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
102
102
-
Text("Rating: ${profile.rating}", fontSize = 13.sp, color = MutedText)
100
100
+
Text(profile.name, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
101
101
+
Text("Rating: ${profile.rating}", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
103
102
}
104
103
}
105
104
}
···
108
107
}
109
108
110
109
// Play count stats
111
111
-
Card(
112
112
-
modifier = Modifier.fillMaxWidth(),
113
113
-
shape = RoundedCornerShape(8.dp),
114
114
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
115
115
-
) {
110
110
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
116
111
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
117
117
-
Text("Play Statistics", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
112
112
+
Text("Play Statistics", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
118
113
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
119
114
BigStat("${pd.playCount.currentVersion}", "Current Ver")
120
115
BigStat("${pd.playCount.total}", "Dashboard")
···
126
121
Spacer(Modifier.height(8.dp))
127
122
128
123
// Maimile
129
129
-
Card(
130
130
-
modifier = Modifier.fillMaxWidth(),
131
131
-
shape = RoundedCornerShape(8.dp),
132
132
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
133
133
-
) {
124
124
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 24.dp) {
134
125
Row(
135
126
modifier = Modifier.padding(16.dp).fillMaxWidth(),
136
127
verticalAlignment = Alignment.CenterVertically,
···
145
136
modifier = Modifier.height(18.dp),
146
137
contentDescription = "maimile",
147
138
)
148
148
-
Text("maimile", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
139
139
+
Text("maimile", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
149
140
}
150
150
-
Text("${pd.maimile}", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = AccentPink)
141
141
+
Text("${pd.maimile}", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
151
142
}
152
143
}
153
144
···
163
154
164
155
// Class CP
165
156
if (pd.classCP.max > 0) {
166
166
-
Card(
167
167
-
modifier = Modifier.fillMaxWidth(),
168
168
-
shape = RoundedCornerShape(8.dp),
169
169
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
170
170
-
) {
157
157
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
171
158
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
172
172
-
Text("Class CP", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
159
159
+
Text("Class CP", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
173
160
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
174
161
BigStat("${pd.classCP.current}", "Current")
175
162
BigStat("${pd.classCP.max}", "Max")
···
181
168
animationSpec = tween(durationMillis = 450),
182
169
label = "class-cp-progress",
183
170
)
184
184
-
LinearProgressIndicator(
171
171
+
ExpressiveLinearProgressIndicator(
185
172
progress = { progress },
186
186
-
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
187
187
-
color = PrimaryBlue,
188
188
-
trackColor = MutedText.copy(alpha = 0.15f),
173
173
+
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
174
174
+
color = MaterialTheme.colorScheme.primary,
175
175
+
trackColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
189
176
)
190
177
}
191
178
}
···
196
183
197
184
// Score rank counters
198
185
if (pd.scoreCounters.isNotEmpty()) {
199
199
-
Card(
200
200
-
modifier = Modifier.fillMaxWidth(),
201
201
-
shape = RoundedCornerShape(8.dp),
202
202
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
203
203
-
) {
186
186
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
204
187
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
205
205
-
Text("Score Counters", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
206
206
-
pd.scoreCounters.forEach { counter ->
207
207
-
Row(
208
208
-
modifier = Modifier.fillMaxWidth(),
209
209
-
horizontalArrangement = Arrangement.SpaceBetween,
210
210
-
) {
211
211
-
Text(counter.rank.uppercase(), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
212
212
-
Text("${counter.achieved} / ${counter.total}", fontSize = 14.sp, color = MutedText)
213
213
-
}
214
214
-
if (counter.total > 0) {
215
215
-
val progress by animateFloatAsState(
216
216
-
targetValue = (counter.achieved.toFloat() / counter.total.toFloat()).coerceIn(0f, 1f),
217
217
-
animationSpec = tween(durationMillis = 450),
218
218
-
label = "score-counter-progress",
219
219
-
)
220
220
-
LinearProgressIndicator(
221
221
-
progress = { progress },
222
222
-
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
223
223
-
color = PrimaryBlue,
224
224
-
trackColor = MutedText.copy(alpha = 0.15f),
225
225
-
)
226
226
-
}
227
227
-
}
188
188
+
Text("Score Counters", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
189
189
+
ScoreCounterBarChart(pd.scoreCounters, modifier = Modifier.fillMaxWidth().height(230.dp))
228
190
}
229
191
}
230
192
···
233
195
234
196
// Missions
235
197
if (pd.missions.tasks.isNotEmpty() || pd.missions.deadline.isNotEmpty()) {
236
236
-
Card(
237
237
-
modifier = Modifier.fillMaxWidth(),
238
238
-
shape = RoundedCornerShape(8.dp),
239
239
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
240
240
-
) {
198
198
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
241
199
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
242
242
-
Text("Missions", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
200
200
+
Text("Missions", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
243
201
if (pd.missions.deadline.isNotBlank()) {
244
244
-
Text("Until ${pd.missions.deadline}", fontSize = 12.sp, color = MutedText)
202
202
+
Text("Until ${pd.missions.deadline}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
245
203
}
246
246
-
Text("Progress: ${pd.missions.progress}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
204
204
+
Text("Progress: ${pd.missions.progress}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
247
205
pd.missions.tasks.forEach { task ->
248
206
Row(
249
207
modifier = Modifier.fillMaxWidth(),
250
208
horizontalArrangement = Arrangement.SpaceBetween,
251
209
) {
252
252
-
Text(task.description, fontSize = 13.sp, color = DarkText, modifier = Modifier.weight(1f))
253
253
-
Text("+${task.reward}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = AccentPink)
210
210
+
Text(task.description, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
211
211
+
Text("+${task.reward}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
254
212
}
255
213
}
256
214
}
···
272
230
}
273
231
274
232
@Composable
233
233
+
private fun ScoreCounterBarChart(counters: List<ScoreCounter>, modifier: Modifier = Modifier) {
234
234
+
val displayCounters = remember(counters) { counters.filter { it.total > 0 || it.achieved > 0 } }
235
235
+
if (displayCounters.isEmpty()) return
236
236
+
val animatedFractions = displayCounters.mapIndexed { index, counter ->
237
237
+
val target = if (counter.total > 0) counter.achieved.toFloat() / counter.total.toFloat() else 0f
238
238
+
animateFloatAsState(
239
239
+
targetValue = target.coerceIn(0f, 1f),
240
240
+
animationSpec = tween(durationMillis = 520 + index * 55),
241
241
+
label = "score-counter-progress-${counter.rank}",
242
242
+
).value
243
243
+
}
244
244
+
245
245
+
Column(
246
246
+
modifier = modifier,
247
247
+
verticalArrangement = Arrangement.spacedBy(10.dp),
248
248
+
) {
249
249
+
displayCounters.forEachIndexed { index, counter ->
250
250
+
val color = scoreCounterColor(index, MaterialTheme.colorScheme.primary)
251
251
+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
252
252
+
Row(
253
253
+
modifier = Modifier.fillMaxWidth(),
254
254
+
horizontalArrangement = Arrangement.SpaceBetween,
255
255
+
verticalAlignment = Alignment.CenterVertically,
256
256
+
) {
257
257
+
Text(counter.rank.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color)
258
258
+
Text("${counter.achieved} / ${counter.total}", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
259
259
+
}
260
260
+
Box(
261
261
+
modifier = Modifier
262
262
+
.fillMaxWidth()
263
263
+
.height(12.dp)
264
264
+
.clip(MaterialTheme.shapes.extraLarge)
265
265
+
.background(color.copy(alpha = 0.14f)),
266
266
+
) {
267
267
+
Box(
268
268
+
modifier = Modifier
269
269
+
.fillMaxHeight()
270
270
+
.fillMaxWidth(animatedFractions[index])
271
271
+
.clip(MaterialTheme.shapes.extraLarge)
272
272
+
.background(color),
273
273
+
)
274
274
+
}
275
275
+
}
276
276
+
}
277
277
+
}
278
278
+
}
279
279
+
280
280
+
private fun scoreCounterColor(index: Int, primaryColor: Color): Color {
281
281
+
val colors = listOf(primaryColor, DiffMaster, DiffExpert, DiffAdvanced, DiffBasic)
282
282
+
return colors[index % colors.size]
283
283
+
}
284
284
+
285
285
+
@Composable
275
286
private fun StatusChip(label: String, has: Boolean) {
276
276
-
Card(
277
277
-
shape = RoundedCornerShape(8.dp),
278
278
-
colors = CardDefaults.cardColors(
279
279
-
containerColor = if (has) PrimaryBlue.copy(alpha = 0.1f) else MutedText.copy(alpha = 0.08f),
280
280
-
),
287
287
+
ExpressiveGlassCard(
288
288
+
cornerRadius = 22.dp,
289
289
+
containerColor = if (has) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
281
290
) {
282
291
Row(
283
292
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
284
293
horizontalArrangement = Arrangement.spacedBy(6.dp),
285
294
verticalAlignment = Alignment.CenterVertically,
286
295
) {
287
287
-
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = if (has) PrimaryBlue else MutedText)
296
296
+
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = if (has) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
288
297
if (has) {
289
289
-
Text("!", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = AccentPink)
298
298
+
Text("!", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.secondary)
290
299
}
291
300
}
292
301
}
···
299
308
val trend = remember(validPlays) { validPlays.weeklyAverageTrend().takeLast(24) }
300
309
val levelTrend = remember(validPlays, songDbReady) { validPlays.weeklyLevelTrend(songDb).takeLast(24) }
301
310
302
302
-
Card(
303
303
-
modifier = Modifier.fillMaxWidth(),
304
304
-
shape = RoundedCornerShape(8.dp),
305
305
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
306
306
-
) {
311
311
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
307
312
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
308
308
-
Text("Archived Play Averages", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
313
313
+
Text("Archived Play Averages", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
309
314
if (validPlays.isEmpty()) {
310
310
-
Text("No archived score data yet", fontSize = 12.sp, color = MutedText)
315
315
+
Text("No archived score data yet", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
311
316
return@Column
312
317
}
313
318
···
317
322
}
318
323
319
324
if (trend.size >= 2) {
320
320
-
Text("Average Over Time", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
325
325
+
Text("Average Over Time", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
321
326
TransparentLineChart(trend, modifier = Modifier.fillMaxWidth().height(165.dp))
322
327
}
323
328
324
329
if (levelTrend.size >= 2) {
325
325
-
Text("Average Difficulty Over Time", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
330
330
+
Text("Average Difficulty Over Time", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
326
331
TransparentLineChart(levelTrend, modifier = Modifier.fillMaxWidth().height(165.dp), unit = "")
327
332
}
328
333
}
···
331
336
332
337
@Composable
333
338
private fun ScoreRankDistributionCard(plays: List<PlayStore.HistoricalPlay>) {
334
334
-
val rankCounts = remember(plays) { plays.rankCounts() }
335
335
-
val modeCounts = remember(plays) { plays.modeCounts() }
339
339
+
val primaryColor = MaterialTheme.colorScheme.primary
340
340
+
val rankCounts = remember(plays, primaryColor) { plays.rankCounts(primaryColor) }
341
341
+
val modeCounts = remember(plays, primaryColor) { plays.modeCounts(primaryColor) }
336
342
val diffAverages = remember(plays) { plays.filter { it.achievement > 0.0 }.difficultyAverages() }
337
343
if (rankCounts.isEmpty() && modeCounts.isEmpty() && diffAverages.isEmpty()) return
338
344
339
339
-
Card(
340
340
-
modifier = Modifier.fillMaxWidth(),
341
341
-
shape = RoundedCornerShape(8.dp),
342
342
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
343
343
-
) {
345
345
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
344
346
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
345
345
-
Text("Play Mix", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
347
347
+
Text("Play Mix", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
346
348
347
349
if (modeCounts.isNotEmpty() || diffAverages.size >= 3) {
348
350
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) {
349
351
if (modeCounts.isNotEmpty()) {
350
352
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
351
351
-
Text("Chart Type", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
353
353
+
Text("Chart Type", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
352
354
TransparentPieChart(modeCounts, modifier = Modifier.fillMaxWidth().height(150.dp))
353
355
modeCounts.forEach { (label, count, color) ->
354
356
LegendRow(label, count, color)
···
357
359
}
358
360
if (diffAverages.size >= 3) {
359
361
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
360
360
-
Text("Avg by Diff", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
362
362
+
Text("Avg by Diff", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
361
363
DifficultyRadarChart(diffAverages, modifier = Modifier.fillMaxWidth().height(180.dp))
362
364
}
363
365
}
···
365
367
}
366
368
367
369
if (rankCounts.isNotEmpty()) {
368
368
-
Text("Rank Distribution", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
370
370
+
Text("Rank Distribution", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
369
371
TransparentBarChart(rankCounts, modifier = Modifier.fillMaxWidth().height(220.dp))
370
372
}
371
373
}
···
381
383
scaleStepsCount = 4,
382
384
maxLabelWidth = 42.dp,
383
385
radarChartLineStyle = RadarChartLineStyle(
384
384
-
color = MutedText.copy(alpha = 0.25f),
386
386
+
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
385
387
strokeWidth = 1f,
386
388
strokeCap = StrokeCap.Butt,
387
389
),
388
390
radarChartDataSet = listOf(
389
391
RadarChartDataSet(
390
392
style = RadarChartDataSetStyle(
391
391
-
color = PrimaryBlue,
392
392
-
borderColor = AccentPink,
393
393
+
color = MaterialTheme.colorScheme.primary,
394
394
+
borderColor = MaterialTheme.colorScheme.secondary,
393
395
colorAlpha = 0.2f,
394
396
strokeWidth = 2f,
395
397
strokeCap = StrokeCap.Round,
···
397
399
data = diffAverages.map { it.second },
398
400
),
399
401
),
400
400
-
labelTextStyle = androidx.compose.ui.text.TextStyle(color = DarkText, fontSize = 9.sp, fontWeight = FontWeight.SemiBold),
402
402
+
labelTextStyle = androidx.compose.ui.text.TextStyle(color = MaterialTheme.colorScheme.onSurface, fontSize = 9.sp, fontWeight = FontWeight.SemiBold),
401
403
scaleTextStyle = androidx.compose.ui.text.TextStyle(color = Color.Transparent, fontSize = 1.sp),
402
404
scaleUnit = "%",
403
405
maxScaleValue = 101.0,
···
409
411
@Composable
410
412
private fun TransparentLineChart(trend: List<AverageBucket>, modifier: Modifier = Modifier, unit: String = "%") {
411
413
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
414
414
+
val primaryColor = MaterialTheme.colorScheme.primary
415
415
+
val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
412
416
val minValue = trend.minOf { it.average }.coerceAtMost(100.0)
413
417
val maxValue = trend.maxOf { it.average }.coerceAtLeast(minValue + 0.1)
414
418
val axisMin = ((minValue - 0.25).coerceAtLeast(0.0) * 10).toInt() / 10.0
415
419
val axisMax = (((maxValue + 0.25).coerceAtMost(101.0) * 10).toInt() + 1) / 10.0
416
420
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
417
421
Column(modifier = Modifier.width(34.dp).fillMaxHeight().padding(top = 2.dp, bottom = 24.dp), verticalArrangement = Arrangement.SpaceBetween) {
418
418
-
Text("${formatScore(axisMax, 1)}$unit", fontSize = 9.sp, color = MutedText, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
419
419
-
Text("${formatScore((axisMin + axisMax) / 2.0, 1)}$unit", fontSize = 9.sp, color = MutedText, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
420
420
-
Text("${formatScore(axisMin, 1)}$unit", fontSize = 9.sp, color = MutedText, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
422
422
+
Text("${formatScore(axisMax, 1)}$unit", fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
423
423
+
Text("${formatScore((axisMin + axisMax) / 2.0, 1)}$unit", fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
424
424
+
Text("${formatScore(axisMin, 1)}$unit", fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth())
421
425
}
422
426
Canvas(modifier = Modifier.weight(1f).fillMaxHeight()) {
423
427
val left = 0f
···
427
431
428
432
repeat(4) { i ->
429
433
val y = top + ((bottom - top) * i / 3f)
430
430
-
drawLine(MutedText.copy(alpha = 0.16f), start = androidx.compose.ui.geometry.Offset(left, y), end = androidx.compose.ui.geometry.Offset(right, y), strokeWidth = 1.dp.toPx())
434
434
+
drawLine(gridColor, start = androidx.compose.ui.geometry.Offset(left, y), end = androidx.compose.ui.geometry.Offset(right, y), strokeWidth = 1.dp.toPx())
431
435
}
432
436
433
437
val points = trend.mapIndexed { index, bucket ->
···
439
443
moveTo(points.first().x, points.first().y)
440
444
points.drop(1).forEach { lineTo(it.x, it.y) }
441
445
}
442
442
-
drawPath(path, color = PrimaryBlue, style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round))
446
446
+
drawPath(path, color = primaryColor, style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round))
443
447
}
444
448
}
445
449
Row(modifier = Modifier.fillMaxWidth().padding(start = 40.dp), horizontalArrangement = Arrangement.SpaceBetween) {
446
446
-
Text(trend.first().label, fontSize = 10.sp, color = MutedText)
447
447
-
Text(trend.last().label, fontSize = 10.sp, color = MutedText)
450
450
+
Text(trend.first().label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
451
451
+
Text(trend.last().label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
448
452
}
449
453
}
450
454
}
···
452
456
@Composable
453
457
private fun TransparentPieChart(slices: List<Triple<String, Int, Color>>, modifier: Modifier = Modifier) {
454
458
val total = slices.sumOf { it.second }.coerceAtLeast(1)
459
459
+
val centerCutoutColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
455
460
Canvas(modifier = modifier) {
456
461
val diameter = minOf(size.width, size.height) * 0.86f
457
462
val topLeft = androidx.compose.ui.geometry.Offset((size.width - diameter) / 2f, (size.height - diameter) / 2f)
···
461
466
drawArc(color = color, startAngle = start, sweepAngle = sweep, useCenter = true, topLeft = topLeft, size = androidx.compose.ui.geometry.Size(diameter, diameter))
462
467
start += sweep
463
468
}
464
464
-
drawCircle(CardWhite.copy(alpha = 0.9f), radius = diameter * 0.28f, center = center)
469
469
+
drawCircle(centerCutoutColor, radius = diameter * 0.28f, center = center)
465
470
}
466
471
}
467
472
468
473
@Composable
469
474
private fun TransparentBarChart(rankCounts: List<RankCount>, modifier: Modifier = Modifier) {
470
475
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
476
476
+
val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
471
477
Box(modifier = modifier) {
472
478
Canvas(modifier = Modifier.matchParentSize()) {
473
479
val maxCount = (rankCounts.maxOfOrNull { it.count } ?: 1).coerceAtLeast(1)
···
477
483
val barWidth = (slot * 0.55f).coerceAtMost(24.dp.toPx())
478
484
repeat(4) { i ->
479
485
val y = top + ((bottom - top) * i / 3f)
480
480
-
drawLine(MutedText.copy(alpha = 0.14f), start = androidx.compose.ui.geometry.Offset(0f, y), end = androidx.compose.ui.geometry.Offset(size.width, y), strokeWidth = 1.dp.toPx())
486
486
+
drawLine(gridColor, start = androidx.compose.ui.geometry.Offset(0f, y), end = androidx.compose.ui.geometry.Offset(size.width, y), strokeWidth = 1.dp.toPx())
481
487
}
482
488
rankCounts.forEachIndexed { index, item ->
483
489
val height = (bottom - top) * item.count / maxCount.toFloat()
···
499
505
"${item.count}",
500
506
fontSize = 10.sp,
501
507
fontWeight = FontWeight.Bold,
502
502
-
color = DarkText,
508
508
+
color = MaterialTheme.colorScheme.onSurface,
503
509
textAlign = TextAlign.Center,
504
510
modifier = Modifier
505
511
.align(Alignment.BottomCenter)
···
514
520
} ?: Text(
515
521
item.label,
516
522
fontSize = 9.sp,
517
517
-
color = MutedText,
523
523
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
518
524
textAlign = TextAlign.Center,
519
525
modifier = Modifier.align(Alignment.BottomCenter),
520
526
)
···
528
534
@Composable
529
535
private fun LegendRow(label: String, count: Int, color: Color) {
530
536
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
531
531
-
Surface(modifier = Modifier.size(10.dp), shape = RoundedCornerShape(2.dp), color = color, content = {})
532
532
-
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = DarkText)
533
533
-
Text("$count", fontSize = 12.sp, color = MutedText)
537
537
+
ExpressiveDot(color = color)
538
538
+
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
539
539
+
Text("$count", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
534
540
}
535
541
}
536
542
···
626
632
return WeekBucketKey(year, month, week)
627
633
}
628
634
629
629
-
private fun List<PlayStore.HistoricalPlay>.modeCounts(): List<Triple<String, Int, Color>> {
635
635
+
private fun List<PlayStore.HistoricalPlay>.modeCounts(primaryColor: Color): List<Triple<String, Int, Color>> {
630
636
val dx = count { it.isDx }
631
637
val standard = size - dx
638
638
+
val colorsByMode = mapOf(
639
639
+
"DX" to primaryColor,
640
640
+
"Standard" to DiffAdvanced,
641
641
+
)
632
642
return listOf("DX" to dx, "Standard" to standard)
633
643
.filter { it.second > 0 }
634
634
-
.map { (label, count) -> Triple(label, count, if (label == "DX") PrimaryBlue else AccentPink) }
644
644
+
.map { (label, count) -> Triple(label, count, colorsByMode[label] ?: primaryColor) }
635
645
}
636
646
637
637
-
private fun List<PlayStore.HistoricalPlay>.rankCounts(): List<RankCount> {
647
647
+
private fun List<PlayStore.HistoricalPlay>.rankCounts(primaryColor: Color): List<RankCount> {
638
648
val order = listOf("sssp", "sss", "ssp", "ss", "sp", "s", "aaa", "aa", "a", "bbb", "bb", "b", "c", "d")
639
639
-
val colors = listOf(AccentPink, PrimaryBlue, DiffMaster, DiffExpert, DiffAdvanced, DiffBasic)
649
649
+
val colors = listOf(primaryColor, DiffMaster, DiffExpert, DiffAdvanced, DiffBasic)
640
650
return groupBy { it.scoreRank.normalizedRank() }
641
651
.filterKeys { it.isNotBlank() }
642
652
.mapValues { (_, plays) -> plays.size }
···
2
2
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.rememberScrollState
5
5
-
import androidx.compose.foundation.shape.RoundedCornerShape
6
5
import androidx.compose.foundation.verticalScroll
7
6
import androidx.compose.material.icons.Icons
8
7
import androidx.compose.material.icons.filled.Check
···
17
16
import com.derakkuma.atproto.syncCachedProfileToAtProto
18
17
import com.derakkuma.data.*
19
18
import com.derakkuma.ui.components.EmptyState
19
19
+
import com.derakkuma.ui.components.ExpressiveGlassCard
20
20
+
import com.derakkuma.ui.components.ExpressiveTextButton
21
21
+
import com.derakkuma.ui.components.ExpressiveSnackbar
22
22
+
import com.derakkuma.ui.components.ExpressiveScaffold
23
23
+
import com.derakkuma.ui.components.ExpressiveFloatingActionButton
24
24
+
import com.derakkuma.ui.components.ExpressiveTextField
20
25
import com.derakkuma.ui.components.LoadingState
21
26
import com.derakkuma.ui.theme.*
22
27
import kotlinx.coroutines.launch
···
53
58
loading = false
54
59
}
55
60
56
56
-
Scaffold(
61
61
+
ExpressiveScaffold(
57
62
contentWindowInsets = WindowInsets(0.dp),
58
63
snackbarHost = {
59
64
if (snackMsg != null) {
60
60
-
Snackbar(
65
65
+
ExpressiveSnackbar(
61
66
modifier = Modifier.padding(12.dp),
62
62
-
shape = RoundedCornerShape(8.dp),
63
63
-
action = { TextButton(onClick = { snackMsg = null }) { Text("OK") } },
67
67
+
action = { ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") } },
64
68
) { Text(snackMsg!!) }
65
69
}
66
70
},
67
71
floatingActionButton = {
68
72
if (dirty && !saving && !maintenanceMode) {
69
69
-
FloatingActionButton(
73
73
+
ExpressiveFloatingActionButton(
70
74
onClick = {
71
71
-
val settings = nameSettings ?: return@FloatingActionButton
75
75
+
val settings = nameSettings ?: return@ExpressiveFloatingActionButton
72
76
saving = true
73
77
scope.launch {
74
78
try {
···
89
93
saving = false
90
94
}
91
95
},
92
92
-
containerColor = PrimaryBlue,
96
96
+
containerColor = MaterialTheme.colorScheme.primary,
93
97
) {
94
94
-
Icon(Icons.Default.Check, contentDescription = "Save", tint = CardWhite)
98
98
+
Icon(Icons.Default.Check, contentDescription = "Save", tint = MaterialTheme.colorScheme.onPrimary)
95
99
}
96
100
}
97
101
},
···
110
114
} else if (nameSettings != null) {
111
115
val ns = nameSettings!!
112
116
113
113
-
Card(
117
117
+
ExpressiveGlassCard(
114
118
modifier = Modifier.fillMaxWidth(),
115
115
-
shape = RoundedCornerShape(8.dp),
116
116
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
117
119
) {
118
120
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
119
119
-
Text("Player Name", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = DarkText)
121
121
+
Text("Player Name", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
120
122
121
121
-
OutlinedTextField(
123
123
+
ExpressiveTextField(
122
124
value = newName,
123
125
onValueChange = {
124
124
-
if (maintenanceMode) return@OutlinedTextField
126
126
+
if (maintenanceMode) return@ExpressiveTextField
125
127
if (it.length <= ns.maxLength) {
126
128
newName = it
127
129
dirty = it != ns.currentName
···
132
134
singleLine = true,
133
135
enabled = !maintenanceMode,
134
136
supportingText = { Text("${newName.length} / ${ns.maxLength}") },
135
135
-
shape = RoundedCornerShape(8.dp),
136
136
-
)
137
137
+
)
137
138
138
139
if (ns.allowedSymbols.isNotEmpty()) {
139
139
-
Text("Additional Symbols", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MutedText)
140
140
+
Text("Additional Symbols", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
140
141
// Symbol grid
141
142
FlowRow(
142
143
modifier = Modifier.fillMaxWidth(),
···
144
145
verticalArrangement = Arrangement.spacedBy(4.dp),
145
146
) {
146
147
ns.allowedSymbols.forEach { symbol ->
147
147
-
TextButton(
148
148
+
ExpressiveTextButton(
148
149
onClick = {
149
149
-
if (maintenanceMode) return@TextButton
150
150
+
if (maintenanceMode) return@ExpressiveTextButton
150
151
if (newName.length + symbol.length <= ns.maxLength) {
151
152
newName += symbol
152
153
dirty = newName != ns.currentName
···
156
157
enabled = !maintenanceMode,
157
158
contentPadding = PaddingValues(0.dp),
158
159
) {
159
159
-
Text(symbol, fontSize = 16.sp, color = PrimaryBlue)
160
160
+
Text(symbol, fontSize = 16.sp, color = MaterialTheme.colorScheme.primary)
160
161
}
161
162
}
162
163
}
···
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.lazy.LazyColumn
5
5
import androidx.compose.foundation.lazy.items
6
6
+
import androidx.compose.foundation.shape.CircleShape
6
7
import androidx.compose.foundation.shape.RoundedCornerShape
7
8
import androidx.compose.material3.*
8
9
import androidx.compose.runtime.*
···
15
16
import androidx.compose.ui.unit.sp
16
17
import com.derakkuma.data.*
17
18
import com.derakkuma.ui.components.ErrorState
19
19
+
import com.derakkuma.ui.components.ExpressiveButton
20
20
+
import com.derakkuma.ui.components.ExpressiveGlassCard
21
21
+
import com.derakkuma.ui.components.ExpressiveBadgeSurface
22
22
+
import com.derakkuma.ui.components.ExpressiveFilterChip
23
23
+
import com.derakkuma.ui.components.ExpressiveTextButton
24
24
+
import com.derakkuma.ui.components.ExpressiveDropdownMenu
25
25
+
import com.derakkuma.ui.components.ExpressiveDropdownMenuItem
18
26
import com.derakkuma.ui.components.LoadingState
19
27
import com.derakkuma.ui.theme.*
20
28
import com.derakkuma.util.formatScore
···
124
132
125
133
Column(Modifier.fillMaxSize()) {
126
134
// Sub-section tabs
127
127
-
PrimaryScrollableTabRow(
128
128
-
selectedTabIndex = section.ordinal,
129
129
-
containerColor = Color.Transparent,
130
130
-
contentColor = Color.White,
131
131
-
edgePadding = 1.dp,
132
132
-
divider = {},
133
133
-
) {
134
134
-
RankingSection.entries.forEach { s ->
135
135
-
Tab(
136
136
-
selected = section == s,
137
137
-
onClick = { section = s },
138
138
-
text = { Text(s.label, fontSize = 12.sp, fontWeight = if (section == s) FontWeight.Bold else FontWeight.Normal) },
139
139
-
)
140
140
-
}
141
141
-
}
135
135
+
RankingSectionTabs(
136
136
+
selected = section,
137
137
+
onSelect = { section = it },
138
138
+
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
139
139
+
)
142
140
143
141
if (loading) {
144
144
-
LoadingState(Modifier.fillMaxSize(), color = PrimaryBlue)
142
142
+
LoadingState(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary)
145
143
} else if (error != null) {
146
144
ErrorState(error!!, onRetry = { reload() })
147
145
} else {
···
208
206
}
209
207
}
210
208
209
209
+
@Composable
210
210
+
private fun RankingSectionTabs(
211
211
+
selected: RankingSection,
212
212
+
onSelect: (RankingSection) -> Unit,
213
213
+
modifier: Modifier = Modifier,
214
214
+
) {
215
215
+
ExpressiveGlassCard(modifier = modifier, cornerRadius = 26.dp) {
216
216
+
Column(
217
217
+
modifier = Modifier.fillMaxWidth().padding(12.dp),
218
218
+
verticalArrangement = Arrangement.spacedBy(8.dp),
219
219
+
) {
220
220
+
Text("Ranking board", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
221
221
+
FlowRow(
222
222
+
modifier = Modifier.fillMaxWidth(),
223
223
+
horizontalArrangement = Arrangement.spacedBy(6.dp),
224
224
+
verticalArrangement = Arrangement.spacedBy(6.dp),
225
225
+
) {
226
226
+
RankingSection.entries.forEach { option ->
227
227
+
RankingExpressiveFilterChip(
228
228
+
selected = option == selected,
229
229
+
selectedColor = when (option) {
230
230
+
RankingSection.Song -> MaterialTheme.colorScheme.primary
231
231
+
RankingSection.Course -> MaterialTheme.colorScheme.tertiary
232
232
+
RankingSection.Season -> MaterialTheme.colorScheme.secondary
233
233
+
RankingSection.DeluxeRating -> MaterialTheme.colorScheme.error
234
234
+
RankingSection.Total -> MaterialTheme.colorScheme.primary
235
235
+
RankingSection.Partner -> MaterialTheme.colorScheme.tertiary
236
236
+
RankingSection.Finale -> MaterialTheme.colorScheme.secondary
237
237
+
},
238
238
+
onClick = { onSelect(option) },
239
239
+
) {
240
240
+
Text(option.label, fontSize = 11.sp, fontWeight = if (option == selected) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1)
241
241
+
}
242
242
+
}
243
243
+
}
244
244
+
}
245
245
+
}
246
246
+
}
247
247
+
211
248
// ---- Filter Chips ----
212
249
213
250
@Composable
214
251
private fun FilterChipRow(label: String, options: List<RankingFilterOption>, selected: String, onSelect: (String) -> Unit) {
215
252
if (options.isEmpty()) return
216
253
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
217
217
-
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MutedText, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
254
254
+
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
218
255
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
219
256
options.take(8).forEach { opt ->
220
220
-
RankingFilterChip(selected = opt.value == selected, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) }
257
257
+
RankingExpressiveFilterChip(selected = opt.value == selected, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) }
221
258
}
222
259
if (options.size > 8) {
223
260
// Show dropdown for too many options
224
261
var expanded by remember { mutableStateOf(false) }
225
262
Box {
226
226
-
TextButton(onClick = { expanded = true }, modifier = Modifier.height(32.dp)) {
227
227
-
Text("More...", fontSize = 11.sp, color = PrimaryBlue)
263
263
+
ExpressiveTextButton(onClick = { expanded = true }, modifier = Modifier.height(32.dp)) {
264
264
+
Text("More...", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary)
228
265
}
229
229
-
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
230
230
-
options.drop(8).forEach { opt ->
231
231
-
DropdownMenuItem(
232
232
-
text = { Text(opt.label, fontSize = 12.sp) },
266
266
+
ExpressiveDropdownMenu(
267
267
+
expanded = expanded,
268
268
+
onDismissRequest = { expanded = false },
269
269
+
) {
270
270
+
val extraOptions = options.drop(8)
271
271
+
extraOptions.forEachIndexed { index, opt ->
272
272
+
val isSelected = opt.value == selected
273
273
+
ExpressiveDropdownMenuItem(
274
274
+
selected = isSelected,
275
275
+
text = { Text(opt.label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) },
276
276
+
leadingIcon = if (isSelected) ({ Text("✓", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) }) else null,
277
277
+
index = index,
278
278
+
lastIndex = extraOptions.lastIndex,
233
279
onClick = {
234
280
onSelect(opt.value)
235
281
expanded = false
···
244
290
}
245
291
246
292
@Composable
247
247
-
private fun RankingFilterChip(selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit) {
248
248
-
FilterChip(
293
293
+
private fun RankingExpressiveFilterChip(
294
294
+
selected: Boolean,
295
295
+
onClick: () -> Unit,
296
296
+
selectedColor: Color = MaterialTheme.colorScheme.primary,
297
297
+
label: @Composable () -> Unit,
298
298
+
) {
299
299
+
ExpressiveFilterChip(
249
300
selected = selected,
250
301
onClick = onClick,
251
302
label = label,
252
252
-
modifier = Modifier.height(32.dp),
253
253
-
colors = FilterChipDefaults.filterChipColors(
254
254
-
containerColor = Color.White,
255
255
-
labelColor = DarkText,
256
256
-
selectedContainerColor = PrimaryBlue,
257
257
-
selectedLabelColor = Color.White,
258
258
-
),
259
259
-
border = FilterChipDefaults.filterChipBorder(
260
260
-
enabled = true,
261
261
-
selected = selected,
262
262
-
borderColor = Color.Transparent,
263
263
-
selectedBorderColor = PrimaryBlue,
264
264
-
),
303
303
+
selectedColor = selectedColor,
265
304
)
266
305
}
267
306
···
271
310
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
272
311
options.forEach { opt ->
273
312
val chipColor = when (opt.value) {
274
274
-
"0" -> Color(0xFF22A7F0)
313
313
+
"0" -> MaterialTheme.colorScheme.primary
275
314
276
315
// Basic - blue
277
277
-
"1" -> Color(0xFF2ECC71)
316
316
+
"1" -> DiffBasic
278
317
279
318
// Advanced - green
280
280
-
"2" -> Color(0xFFFFC312)
319
319
+
"2" -> DiffAdvanced
281
320
282
321
// Expert - yellow
283
283
-
"3" -> Color(0xFFE74C3C)
322
322
+
"3" -> DiffExpert
284
323
285
324
// Master - red
286
286
-
"4" -> Color(0xFF9B59B6)
325
325
+
"4" -> DiffMaster
287
326
288
327
// Re:MASTER - purple
289
289
-
"10" -> Color(0xFFE91E63)
328
328
+
"10" -> DiffRemaster
290
329
291
330
// Utage - pink
292
292
-
"99" -> Color(0xFF7F8C8D)
331
331
+
"99" -> MaterialTheme.colorScheme.outline
293
332
294
333
// All - gray
295
295
-
else -> PrimaryBlue
334
334
+
else -> MaterialTheme.colorScheme.primary
296
335
}
297
297
-
RankingFilterChip(selected = opt.value == selected, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) }
336
336
+
RankingExpressiveFilterChip(selected = opt.value == selected, selectedColor = chipColor, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) }
298
337
}
299
338
}
300
339
}
···
317
356
318
357
@Composable
319
358
private fun ApplyButton(onClick: () -> Unit) {
320
320
-
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) {
359
359
+
ExpressiveButton(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)) {
321
360
Text("Apply Filters", fontWeight = FontWeight.SemiBold)
322
361
}
323
362
}
···
329
368
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
330
369
if (yourRank.isNotBlank() && yourRank != "―") {
331
370
item(key = "your-rank") {
332
332
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = PrimaryBlue.copy(alpha = 0.1f))) {
371
371
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 22.dp, containerColor = MaterialTheme.colorScheme.primaryContainer) {
333
372
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
334
334
-
Text("Your Rank: ", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
335
335
-
Text(yourRank, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue)
373
373
+
Text("Your Rank: ", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer)
374
374
+
Text(yourRank, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
336
375
}
337
376
}
338
377
}
339
378
}
340
379
if (updatedAt.isNotBlank()) {
341
380
item(key = "updated") {
342
342
-
Text("Updated: $updatedAt", fontSize = 11.sp, color = MutedText, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
381
381
+
Text("Updated: $updatedAt", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
343
382
}
344
383
}
345
384
if (headerLabel.isNotBlank()) {
346
385
item(key = "header-label") {
347
347
-
Text(headerLabel, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
386
386
+
Text(headerLabel, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp))
348
387
}
349
388
}
350
389
items(entries, key = { "pr-${it.rank}-${it.name}" }) { entry ->
···
356
395
@Composable
357
396
private fun PlayerRankingRow(entry: PlayerRankingEntry) {
358
397
val rankColor = when {
359
359
-
entry.rank == 1 -> Color(0xFFFFD700)
360
360
-
entry.rank == 2 -> Color(0xFFC0C0C0)
361
361
-
entry.rank == 3 -> Color(0xFFCD7F32)
362
362
-
entry.isTop3 -> Color(0xFFFFD700)
398
398
+
entry.rank == 1 -> MedalGold
399
399
+
entry.rank == 2 -> MedalSilver
400
400
+
entry.rank == 3 -> MedalBronze
401
401
+
entry.isTop3 -> MedalGold
363
402
else -> MutedText.copy(alpha = 0.3f)
364
403
}
365
365
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
404
404
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
366
405
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
367
406
Surface(Modifier.size(28.dp), shape = RoundedCornerShape(6.dp), color = rankColor) {
368
407
Box(contentAlignment = Alignment.Center) {
···
370
409
}
371
410
}
372
411
Spacer(Modifier.width(8.dp))
373
373
-
Text(entry.name, fontSize = 14.sp, fontWeight = if (entry.isTop3) FontWeight.SemiBold else FontWeight.Normal, color = DarkText, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
412
412
+
Text(entry.name, fontSize = 14.sp, fontWeight = if (entry.isTop3) FontWeight.SemiBold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
374
413
Spacer(Modifier.width(8.dp))
375
375
-
Text(entry.score, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (entry.isTop3) PrimaryBlue else MutedText)
414
414
+
Text(entry.score, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (entry.isTop3) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
376
415
}
377
416
}
378
417
}
···
430
469
if (song.genre != lastGenre && song.genre.isNotBlank()) {
431
470
lastGenre = song.genre
432
471
item(key = "genre-$i") {
433
433
-
Text(song.genre, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue, modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp))
472
472
+
Text(song.genre, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp))
434
473
}
435
474
}
436
475
item(key = "song-$i") {
···
440
479
}
441
480
} else if (data != null && data.songs.isEmpty()) {
442
481
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
443
443
-
Text("No songs found for these filters", fontSize = 14.sp, color = MutedText)
482
482
+
Text("No songs found for these filters", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
444
483
}
445
484
}
446
485
}
···
455
494
"remaster" -> Color(0xFF9B59B6)
456
495
else -> MutedText
457
496
}
458
458
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = CardWhite)) {
497
497
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
459
498
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
460
499
// Difficulty badge
461
500
Surface(Modifier.size(28.dp), shape = RoundedCornerShape(6.dp), color = diffColor.copy(alpha = 0.2f)) {
···
465
504
}
466
505
Spacer(Modifier.width(8.dp))
467
506
// Song name
468
468
-
Text(song.name, fontSize = 14.sp, fontWeight = FontWeight.Normal, color = DarkText, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
507
507
+
Text(song.name, fontSize = 14.sp, fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
469
508
// DX badge
470
509
if (song.isDx) {
471
510
Spacer(Modifier.width(4.dp))
472
472
-
Surface(shape = RoundedCornerShape(4.dp), color = Color(0xFFE91E63).copy(alpha = 0.15f)) {
473
473
-
Text("DX", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color(0xFFE91E63), modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
511
511
+
ExpressiveBadgeSurface(shape = MaterialTheme.shapes.large, containerColor = MaterialTheme.colorScheme.secondaryContainer) {
512
512
+
Text("DX", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp))
474
513
}
475
514
}
476
515
}
···
567
606
// Your rating
568
607
if (data.yourRating > 0) {
569
608
item(key = "your-rating") {
570
570
-
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = PrimaryBlue.copy(alpha = 0.1f))) {
609
609
+
ExpressiveGlassCard(Modifier.fillMaxWidth(), cornerRadius = 22.dp, containerColor = MaterialTheme.colorScheme.primaryContainer) {
571
610
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
572
572
-
Text("Your DX Rating: ", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
573
573
-
Text("${data.yourRating}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = PrimaryBlue)
611
611
+
Text("Your DX Rating: ", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer)
612
612
+
Text("${data.yourRating}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
574
613
}
575
614
}
576
615
}
577
616
}
578
617
if (data.updatedAt.isNotBlank()) {
579
579
-
item(key = "updated") { Text("Updated: ${data.updatedAt}", fontSize = 11.sp, color = MutedText, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) }
618
618
+
item(key = "updated") { Text("Updated: ${data.updatedAt}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) }
580
619
}
581
620
items(data.entries, key = { "dx-${it.rank}-${it.name}" }) { entry -> PlayerRankingRow(entry) }
582
621
}
···
3
3
import androidx.compose.foundation.layout.*
4
4
import androidx.compose.foundation.lazy.LazyColumn
5
5
import androidx.compose.foundation.lazy.itemsIndexed
6
6
-
import androidx.compose.foundation.shape.RoundedCornerShape
7
6
import androidx.compose.material3.*
8
8
-
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
9
7
import androidx.compose.runtime.*
10
8
import androidx.compose.ui.Alignment
11
9
import androidx.compose.ui.Modifier
···
21
19
import com.derakkuma.data.RatingSong
22
20
import com.derakkuma.data.SessionExpiredException
23
21
import com.derakkuma.ui.components.ErrorState
22
22
+
import com.derakkuma.ui.components.ExpressiveGlassCard
23
23
+
import com.derakkuma.ui.components.ExpressivePullToRefreshBox
24
24
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
24
25
import com.derakkuma.ui.components.LoadingState
25
26
import com.derakkuma.ui.components.SongRow
26
27
import com.derakkuma.ui.theme.*
···
64
65
isRefreshing = false
65
66
}
66
67
67
67
-
PullToRefreshBox(
68
68
+
ExpressivePullToRefreshBox(
68
69
isRefreshing = isRefreshing,
69
70
onRefresh = {
70
70
-
if (maintenanceMode) return@PullToRefreshBox
71
71
+
if (maintenanceMode) return@ExpressivePullToRefreshBox
71
72
isRefreshing = true
72
73
refreshKey += 1
73
74
},
···
83
84
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
84
85
// New Songs section
85
86
item {
86
86
-
Card(
87
87
-
modifier = Modifier.fillMaxWidth(),
88
88
-
shape = RoundedCornerShape(8.dp),
89
89
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
90
90
-
) {
91
91
-
Text("New Songs (${newSongs.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
87
87
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 22.dp) {
88
88
+
Text("New Songs (${newSongs.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
92
89
}
93
90
}
94
91
itemsIndexed(newSongs, key = { index, song -> "new-$index-${song.name}-${song.difficulty}" }) { i, song ->
95
95
-
Card(
96
96
-
modifier = Modifier.fillMaxWidth(),
97
97
-
shape = RoundedCornerShape(8.dp),
98
98
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
99
99
-
) {
92
92
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 22.dp) {
100
93
Column(modifier = Modifier.padding(horizontal = 10.dp)) {
101
101
-
if (i > 0) HorizontalDivider(color = MutedText.copy(alpha = 0.15f))
94
94
+
if (i > 0) ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
102
95
RatingSongRow(song)
103
96
}
104
97
}
···
106
99
107
100
// Other Songs section
108
101
item {
109
109
-
Card(
110
110
-
modifier = Modifier.fillMaxWidth(),
111
111
-
shape = RoundedCornerShape(8.dp),
112
112
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
113
113
-
) {
114
114
-
Text("Other Songs (${otherSongs.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
102
102
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 22.dp) {
103
103
+
Text("Other Songs (${otherSongs.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
115
104
}
116
105
}
117
106
itemsIndexed(otherSongs, key = { index, song -> "other-$index-${song.name}-${song.difficulty}" }) { i, song ->
118
118
-
Card(
119
119
-
modifier = Modifier.fillMaxWidth(),
120
120
-
shape = RoundedCornerShape(8.dp),
121
121
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
122
122
-
) {
107
107
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 22.dp) {
123
108
Column(modifier = Modifier.padding(horizontal = 10.dp)) {
124
124
-
if (i > 0) HorizontalDivider(color = MutedText.copy(alpha = 0.15f))
109
109
+
if (i > 0) ExpressiveHorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
125
110
RatingSongRow(song)
126
111
}
127
112
}
···
5
5
import androidx.compose.foundation.lazy.LazyColumn
6
6
import androidx.compose.foundation.lazy.items
7
7
import androidx.compose.foundation.lazy.rememberLazyListState
8
8
-
import androidx.compose.foundation.shape.RoundedCornerShape
9
8
import androidx.compose.material.icons.Icons
10
9
import androidx.compose.material.icons.filled.Sync
11
10
import androidx.compose.material3.*
12
12
-
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
13
11
import androidx.compose.runtime.*
14
12
import androidx.compose.ui.Alignment
15
13
import androidx.compose.ui.Modifier
···
30
28
import com.derakkuma.data.RecentPlay
31
29
import com.derakkuma.data.SessionExpiredException
32
30
import com.derakkuma.ui.components.ErrorState
31
31
+
import com.derakkuma.ui.components.ExpressiveButton
32
32
+
import com.derakkuma.ui.components.ExpressiveGlassCard
33
33
+
import com.derakkuma.ui.components.ExpressivePullToRefreshBox
34
34
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
35
35
+
import com.derakkuma.ui.components.ExpressiveBadgeSurface
36
36
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
37
37
+
import com.derakkuma.ui.components.ExpressiveLoadingIndicator
33
38
import com.derakkuma.ui.components.LoadingState
34
39
import com.derakkuma.ui.components.SongRow
35
40
import com.derakkuma.ui.images.BundledIcon
···
141
146
historicalPlays = ps.getHistoricalPlaysForDisplay()
142
147
}
143
148
144
144
-
PullToRefreshBox(
149
149
+
ExpressivePullToRefreshBox(
145
150
isRefreshing = isRefreshing,
146
151
onRefresh = {
147
147
-
if (maintenanceMode) return@PullToRefreshBox
152
152
+
if (maintenanceMode) return@ExpressivePullToRefreshBox
148
153
isRefreshing = true
149
154
refreshKey += 1
150
155
},
···
189
194
) {
190
195
if (loading) {
191
196
item {
192
192
-
LinearProgressIndicator(
197
197
+
ExpressiveLinearProgressIndicator(
193
198
modifier = Modifier.fillMaxWidth().height(2.dp),
194
194
-
color = PrimaryBlue,
199
199
+
color = MaterialTheme.colorScheme.primary,
195
200
trackColor = Color.Transparent,
196
201
)
197
202
}
198
203
}
199
204
200
200
-
// Stats summary
205
205
+
// Stats summary. Keep the original records summary as the first real content
206
206
+
// item so Current Ver / Dashboard / Archived / maimile remain visible at top.
201
207
item {
202
202
-
Card(
208
208
+
ExpressiveGlassCard(
203
209
modifier = Modifier.fillMaxWidth(),
204
204
-
shape = RoundedCornerShape(8.dp),
205
205
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
210
210
+
cornerRadius = 28.dp,
211
211
+
containerColor = Color.White,
206
212
) {
207
207
-
Row(
213
213
+
Column(
208
214
modifier = Modifier.fillMaxWidth().padding(12.dp),
209
209
-
horizontalArrangement = Arrangement.SpaceEvenly,
215
215
+
verticalArrangement = Arrangement.spacedBy(12.dp),
210
216
) {
211
211
-
StatItem("Current Ver", "${data!!.playCount.currentVersion}")
212
212
-
StatItem("Dashboard", "${data!!.playCount.total}")
213
213
-
StatItem(
214
214
-
"Archived",
215
215
-
"${historicalPlays.size}",
216
216
-
showSyncChip = historicalPlays.isEmpty(),
217
217
-
onClick = { scope.launch { listState.animateScrollToItem(historicalSectionIndex) } },
218
218
-
)
219
219
-
StatItem("maimile", "${data!!.maimile}", useMaimileIcon = true)
220
220
-
}
217
217
+
Text("Play Statistics", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
218
218
+
Row(
219
219
+
modifier = Modifier.fillMaxWidth(),
220
220
+
horizontalArrangement = Arrangement.SpaceEvenly,
221
221
+
) {
222
222
+
StatItem("Current Ver", "${data!!.playCount.currentVersion}")
223
223
+
StatItem("Dashboard", "${data!!.playCount.total}")
224
224
+
StatItem(
225
225
+
"Archived",
226
226
+
"${historicalPlays.size}",
227
227
+
showSyncChip = historicalPlays.isEmpty(),
228
228
+
onClick = { scope.launch { listState.animateScrollToItem(historicalSectionIndex) } },
229
229
+
)
230
230
+
StatItem("maimile", "${data!!.maimile}", useMaimileIcon = true)
231
231
+
}
221
232
222
222
-
// Rating target music shortcut
223
223
-
Column(
224
224
-
modifier = Modifier.fillMaxWidth().padding(10.dp),
225
225
-
verticalArrangement = Arrangement.spacedBy(8.dp),
226
226
-
) {
227
227
-
Button(
233
233
+
// Rating target music shortcut
234
234
+
ExpressiveButton(
228
235
onClick = { onRatingTargetClick?.invoke() },
229
236
enabled = onRatingTargetClick != null,
230
237
modifier = Modifier.fillMaxWidth(),
231
231
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
238
238
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
232
239
) {
233
240
Text("View DX Rating Calculation Records")
234
241
}
···
238
245
239
246
// Recent plays header
240
247
item {
241
241
-
Card(
242
242
-
modifier = Modifier.fillMaxWidth(),
243
243
-
shape = RoundedCornerShape(8.dp),
244
244
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
245
245
-
) {
246
246
-
Text("Recent Plays (${data!!.playLog.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
248
248
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
249
249
+
Text("Recent Plays (${data!!.playLog.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
247
250
}
248
251
}
249
252
250
253
// Recent play items (last 50 from dashboard)
251
254
items(recentPlays, key = { "${it.datetime}:${it.name}:${it.difficulty}" }) { play ->
252
252
-
Card(
253
253
-
modifier = Modifier.fillMaxWidth(),
254
254
-
shape = RoundedCornerShape(8.dp),
255
255
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
256
256
-
) {
255
255
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
257
256
Row(
258
257
modifier = Modifier
259
258
.fillMaxWidth()
···
284
283
285
284
// Historical plays (backfilled from before 90 days)
286
285
item {
287
287
-
HorizontalDivider(
286
286
+
ExpressiveHorizontalDivider(
288
287
modifier = Modifier.padding(vertical = 8.dp),
289
289
-
color = MutedText.copy(alpha = 0.2f),
288
288
+
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
290
289
)
291
291
-
Card(
292
292
-
modifier = Modifier.fillMaxWidth(),
293
293
-
shape = RoundedCornerShape(8.dp),
294
294
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
295
295
-
) {
296
296
-
Text("Historical Plays (${historicalFiltered.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
290
290
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
291
291
+
Text("Historical Plays (${historicalFiltered.size})", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(10.dp).padding(bottom = 4.dp))
297
292
}
298
293
}
299
294
if (historicalFiltered.isEmpty()) {
···
329
324
}
330
325
} else {
331
326
items(historicalFiltered, key = { "h:${it.datetime}:${it.name}:${it.difficulty}" }) { play ->
332
332
-
Card(
333
333
-
modifier = Modifier.fillMaxWidth(),
334
334
-
shape = RoundedCornerShape(8.dp),
335
335
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
336
336
-
) {
327
327
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 28.dp) {
337
328
Row(
338
329
modifier = Modifier
339
330
.fillMaxWidth()
···
371
362
horizontalAlignment = Alignment.CenterHorizontally,
372
363
) {
373
364
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
374
374
-
Text(value, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = DarkText)
365
365
+
Text(value, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
375
366
if (showSyncChip) {
376
376
-
Surface(shape = RoundedCornerShape(999.dp), color = DangerRed) {
377
377
-
Icon(Icons.Default.Sync, contentDescription = "Sync historical plays", modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).size(12.dp), tint = Color.White)
367
367
+
ExpressiveBadgeSurface(containerColor = MaterialTheme.colorScheme.error) {
368
368
+
Icon(Icons.Default.Sync, contentDescription = "Sync historical plays", modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).size(12.dp), tint = MaterialTheme.colorScheme.onError)
378
369
}
379
370
}
380
371
}
···
385
376
modifier = Modifier.height(12.dp),
386
377
contentDescription = null,
387
378
)
388
388
-
Text(label, fontSize = 11.sp, color = MutedText)
379
379
+
Text(label, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
389
380
}
390
381
} else {
391
391
-
Text(label, fontSize = 11.sp, color = MutedText)
382
382
+
Text(label, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
392
383
}
393
384
}
394
385
}
···
401
392
result: HistoricalCrawler.Result?,
402
393
onStart: () -> Unit,
403
394
) {
404
404
-
Card(
405
405
-
modifier = Modifier.fillMaxWidth(),
406
406
-
shape = RoundedCornerShape(8.dp),
407
407
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
408
408
-
) {
395
395
+
ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 26.dp) {
409
396
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
410
410
-
Text("Historical Play Data", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
411
411
-
Text("Backfill your complete play history from before the 90-day window. This crawls your song list across all difficulties.", fontSize = 12.sp, color = MutedText)
397
397
+
Text("Historical Play Data", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
398
398
+
Text("Backfill your complete play history from before the 90-day window. This crawls your song list across all difficulties.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
412
399
if (backfilling && progress != null) {
413
400
val fraction = progress.current.toFloat() / progress.total.coerceAtLeast(1).toFloat()
414
414
-
LinearProgressIndicator(
401
401
+
ExpressiveLinearProgressIndicator(
415
402
progress = { fraction.coerceIn(0f, 1f) },
416
403
modifier = Modifier.fillMaxWidth(),
417
417
-
color = PrimaryBlue,
404
404
+
color = MaterialTheme.colorScheme.primary,
418
405
)
419
419
-
Text(progress.status, fontSize = 12.sp, color = MutedText)
420
420
-
Text("Please keep the app open and on this screen until complete.", fontSize = 11.sp, color = MutedText, fontWeight = FontWeight.Medium)
406
406
+
Text(progress.status, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
407
407
+
Text("Please keep the app open and on this screen until complete.", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
421
408
} else if (result != null) {
422
422
-
Text("Done! ${result.totalPlays} plays from ${result.totalSongs} songs.", fontSize = 13.sp, color = DarkText, fontWeight = FontWeight.Medium)
409
409
+
Text("Done! ${result.totalPlays} plays from ${result.totalSongs} songs.", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium)
423
410
} else if (error != null) {
424
424
-
Text(error, fontSize = 12.sp, color = DangerRed)
411
411
+
Text(error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
425
412
}
426
426
-
Button(
413
413
+
ExpressiveButton(
427
414
onClick = onStart,
428
415
modifier = Modifier.fillMaxWidth(),
429
429
-
shape = RoundedCornerShape(8.dp),
430
430
-
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
416
416
+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
431
417
enabled = !backfilling,
432
418
) {
433
419
if (backfilling) {
434
434
-
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = CardWhite, strokeWidth = 2.dp)
420
420
+
ExpressiveLoadingIndicator(modifier = Modifier, color = MaterialTheme.colorScheme.onPrimary, size = 16.dp)
435
421
Spacer(Modifier.width(8.dp))
436
422
}
437
423
Text(if (backfilling) "Backfilling..." else "Backfill Historical Data", fontWeight = FontWeight.SemiBold)
···
1
1
package com.derakkuma.ui.tabs
2
2
3
3
+
import com.derakkuma.ui.components.ExpressiveSlider
4
4
+
import com.derakkuma.ui.components.ExpressiveLinearProgressIndicator
5
5
+
import com.derakkuma.ui.components.ExpressiveGlassCard
6
6
+
import com.derakkuma.ui.components.ExpressiveHorizontalDivider
7
7
+
import com.derakkuma.ui.components.ExpressiveTextButton
8
8
+
import com.derakkuma.ui.components.ExpressiveDropdownMenu
9
9
+
import com.derakkuma.ui.components.ExpressiveDropdownMenuItem
10
10
+
import com.derakkuma.ui.components.ExpressiveSnackbar
11
11
+
import com.derakkuma.ui.components.ExpressiveScaffold
12
12
+
import com.derakkuma.ui.components.ExpressiveFloatingActionButton
13
13
+
import com.derakkuma.ui.components.ExpressiveSwitch
3
14
import androidx.compose.foundation.Image
4
4
-
import androidx.compose.foundation.background
5
15
import androidx.compose.foundation.clickable
6
16
import androidx.compose.foundation.layout.*
7
17
import androidx.compose.foundation.rememberScrollState
8
8
-
import androidx.compose.foundation.shape.RoundedCornerShape
9
18
import androidx.compose.foundation.verticalScroll
10
19
import androidx.compose.material.icons.Icons
11
20
import androidx.compose.material.icons.filled.Check
···
18
27
import androidx.compose.ui.graphics.vector.ImageVector
19
28
import androidx.compose.ui.layout.ContentScale
20
29
import androidx.compose.ui.text.font.FontWeight
21
21
-
import androidx.compose.ui.text.style.TextAlign
22
30
import androidx.compose.ui.unit.dp
23
31
import androidx.compose.ui.unit.sp
24
32
import com.derakkuma.data.*
···
31
39
import com.derakkuma.ui.images.rememberBundledImage
32
40
import com.derakkuma.ui.theme.*
33
41
import kotlinx.coroutines.launch
34
34
-
import com.derakkuma.ui.components.SplitButtonRow as SharedSplitButtonRow
42
42
+
import com.derakkuma.ui.components.SplitButtonRow
35
43
36
44
@Composable
37
45
fun SettingsTab(client: MaimaiClient, cookie: String, maintenanceMode: Boolean = false) {
···
62
70
loading = false
63
71
}
64
72
65
65
-
Scaffold(
73
73
+
ExpressiveScaffold(
66
74
contentWindowInsets = WindowInsets(0.dp),
67
75
snackbarHost = {
68
76
if (snackMsg != null) {
69
69
-
Snackbar(
77
77
+
ExpressiveSnackbar(
70
78
modifier = Modifier.padding(12.dp),
71
71
-
shape = RoundedCornerShape(8.dp),
72
79
action = {
73
73
-
TextButton(onClick = { snackMsg = null }) {
74
74
-
Text("OK")
75
75
-
}
80
80
+
ExpressiveTextButton(onClick = { snackMsg = null }) { Text("OK") }
76
81
},
77
82
) { Text(snackMsg!!) }
78
83
}
79
84
},
80
85
floatingActionButton = {
81
86
if (dirty && !saving && !maintenanceMode) {
82
82
-
FloatingActionButton(
87
87
+
ExpressiveFloatingActionButton(
83
88
onClick = {
84
84
-
val opts = options ?: return@FloatingActionButton
89
89
+
val opts = options ?: return@ExpressiveFloatingActionButton
85
90
saving = true
86
91
scope.launch {
87
92
try {
···
94
99
saving = false
95
100
}
96
101
},
97
97
-
containerColor = PrimaryBlue,
102
102
+
containerColor = MaterialTheme.colorScheme.primary,
98
103
) {
99
99
-
Icon(Icons.Default.Check, contentDescription = "Save", tint = CardWhite)
104
104
+
Icon(Icons.Default.Check, contentDescription = "Save", tint = MaterialTheme.colorScheme.onPrimary)
100
105
}
101
106
}
102
107
},
···
115
120
val opts = options!!
116
121
117
122
if (saving) {
118
118
-
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = PrimaryBlue)
123
123
+
ExpressiveLinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary)
119
124
}
120
125
121
126
// Friend Registration Skip
122
127
if (friendSkip != null) {
123
123
-
Card(
128
128
+
ExpressiveGlassCard(
124
129
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
125
125
-
shape = RoundedCornerShape(8.dp),
126
126
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
130
130
+
cornerRadius = 26.dp,
127
131
) {
128
132
Row(
129
133
modifier = Modifier.fillMaxWidth().padding(12.dp),
···
131
135
verticalAlignment = Alignment.CenterVertically,
132
136
) {
133
137
Column(modifier = Modifier.weight(1f)) {
134
134
-
Text("Friend Registration Skip", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
135
135
-
Text("Skip friend registration after songs", fontSize = 11.sp, color = MutedText)
138
138
+
Text("Friend Registration Skip", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
139
139
+
Text("Skip friend registration after songs", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
136
140
}
137
141
var skipState by remember { mutableStateOf(friendSkip!!.skip) }
138
138
-
Switch(
142
142
+
ExpressiveSwitch(
139
143
checked = skipState,
140
144
enabled = !maintenanceMode,
141
145
onCheckedChange = { newValue ->
···
157
161
saving = false
158
162
}
159
163
},
160
160
-
colors = SwitchDefaults.colors(checkedTrackColor = PrimaryBlue),
161
164
)
162
165
}
163
166
}
···
199
202
) {
200
203
if (fields.isEmpty()) return
201
204
202
202
-
Card(
205
205
+
ExpressiveGlassCard(
203
206
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
204
204
-
shape = RoundedCornerShape(8.dp),
205
205
-
colors = CardDefaults.cardColors(containerColor = CardWhite),
207
207
+
cornerRadius = 28.dp,
206
208
) {
207
209
Column(modifier = Modifier.padding(12.dp)) {
208
208
-
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = DarkText)
210
210
+
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
209
211
Spacer(Modifier.height(8.dp))
210
212
211
213
fields.forEach { field ->
212
214
OptionRow(field, disabled, onChanged)
213
215
if (field != fields.last()) {
214
214
-
HorizontalDivider(
216
216
+
ExpressiveHorizontalDivider(
215
217
modifier = Modifier.padding(vertical = 4.dp),
216
216
-
color = MutedText.copy(alpha = 0.12f),
218
218
+
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
217
219
)
218
220
}
219
221
}
···
222
224
}
223
225
224
226
/** Fields that should always be split buttons regardless of option count. */
225
225
-
private val FORCE_SPLIT_BUTTON = setOf("mirrorMode", "tapDesign", "holdDesign", "slideDesign")
227
227
+
private val FORCE_SPLIT_BUTTON = setOf("mirrorMode", "holdDesign", "slideDesign")
226
228
227
229
/** Icon overrides for specific option indices within a field. */
228
230
private val OPTION_ICONS: Map<String, Map<Int, ImageVector>> = mapOf(
···
263
265
264
266
cleaned.name in FORCE_SPLIT_BUTTON -> {
265
267
val icons = OPTION_ICONS[cleaned.name]
266
266
-
if (cleaned.name == "tapDesign") {
267
267
-
TapDesignSplitRow(cleaned, disabled, onChanged)
268
268
-
} else {
269
269
-
SplitButtonRow(cleaned, disabled, onChanged, icons)
270
270
-
}
268
268
+
val label = OPTION_LABELS[cleaned.name] ?: cleaned.name
269
269
+
SplitButtonRow(
270
270
+
label = label,
271
271
+
options = cleaned.options,
272
272
+
selected = cleaned.selected,
273
273
+
disabled = disabled,
274
274
+
icons = icons,
275
275
+
onSelect = { onChanged(cleaned.name, it) },
276
276
+
)
271
277
}
272
278
273
279
isSliderField(cleaned) -> SliderRow(cleaned, disabled, onChanged)
274
280
275
275
-
cleaned.options.size in 2..3 -> SplitButtonRow(cleaned, disabled, onChanged)
281
281
+
cleaned.options.size in 2..3 -> {
282
282
+
val label = OPTION_LABELS[cleaned.name] ?: cleaned.name
283
283
+
SplitButtonRow(
284
284
+
label = label,
285
285
+
options = cleaned.options,
286
286
+
selected = cleaned.selected,
287
287
+
disabled = disabled,
288
288
+
onSelect = { onChanged(cleaned.name, it) },
289
289
+
)
290
290
+
}
276
291
277
292
else -> DropdownRow(cleaned, disabled, onChanged)
278
293
}
···
295
310
verticalAlignment = Alignment.CenterVertically,
296
311
horizontalArrangement = Arrangement.SpaceBetween,
297
312
) {
298
298
-
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = DarkText)
313
313
+
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface)
299
314
Text(
300
315
labels[selectedIdx],
301
316
fontSize = 13.sp,
302
302
-
color = PrimaryBlue,
317
317
+
color = MaterialTheme.colorScheme.primary,
303
318
fontWeight = FontWeight.SemiBold,
304
319
)
305
320
}
306
306
-
Slider(
321
321
+
ExpressiveSlider(
307
322
value = selectedIdx.toFloat(),
308
323
onValueChange = { raw ->
309
309
-
if (disabled) return@Slider
324
324
+
if (disabled) return@ExpressiveSlider
310
325
val i = (raw + 0.5f).toInt().coerceIn(0, stepCount)
311
326
onChanged(field.name, field.options[i].first)
312
327
},
313
328
valueRange = 0f..stepCount.toFloat(),
314
329
enabled = !disabled,
315
315
-
colors = SliderDefaults.colors(
316
316
-
thumbColor = PrimaryBlue,
317
317
-
activeTrackColor = PrimaryBlue,
318
318
-
inactiveTrackColor = MutedText.copy(alpha = 0.2f),
319
319
-
),
320
330
)
321
331
}
322
322
-
}
323
323
-
324
324
-
@Composable
325
325
-
private fun SplitButtonRow(
326
326
-
field: OptionField,
327
327
-
disabled: Boolean = false,
328
328
-
onChanged: (fieldName: String, newValue: String) -> Unit,
329
329
-
icons: Map<Int, ImageVector>? = null,
330
330
-
) {
331
331
-
val label = OPTION_LABELS[field.name] ?: field.name
332
332
-
SharedSplitButtonRow(
333
333
-
label = label,
334
334
-
options = field.options,
335
335
-
selected = field.selected,
336
336
-
disabled = disabled,
337
337
-
icons = icons,
338
338
-
onSelect = { onChanged(field.name, it) },
339
339
-
)
340
332
}
341
333
342
334
@OptIn(ExperimentalMaterial3Api::class)
···
358
350
label,
359
351
fontSize = 13.sp,
360
352
fontWeight = FontWeight.Medium,
361
361
-
color = DarkText,
353
353
+
color = MaterialTheme.colorScheme.onSurface,
362
354
modifier = Modifier.weight(1f),
363
355
)
364
356
···
372
364
Text(
373
365
selectedLabel,
374
366
fontSize = 13.sp,
375
375
-
color = if (disabled) MutedText else PrimaryBlue,
367
367
+
color = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
376
368
fontWeight = FontWeight.SemiBold,
377
369
)
378
370
Icon(
379
371
Icons.Default.ExpandMore,
380
372
contentDescription = null,
381
373
modifier = Modifier.size(18.dp),
382
382
-
tint = if (disabled) MutedText else PrimaryBlue,
374
374
+
tint = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
383
375
)
384
376
}
385
377
386
386
-
DropdownMenu(
378
378
+
ExpressiveDropdownMenu(
387
379
expanded = expanded && !disabled,
388
380
onDismissRequest = { expanded = false },
389
389
-
containerColor = CardWhite,
390
381
) {
391
391
-
field.options.forEach { (value, optLabel) ->
392
392
-
DropdownMenuItem(
382
382
+
field.options.forEachIndexed { index, (value, optLabel) ->
383
383
+
val selected = value == field.selected
384
384
+
ExpressiveDropdownMenuItem(
385
385
+
selected = selected,
393
386
text = {
394
387
Text(
395
388
optLabel,
396
389
fontSize = 13.sp,
397
397
-
fontWeight = if (value == field.selected) FontWeight.Bold else FontWeight.Normal,
398
398
-
color = if (value == field.selected) PrimaryBlue else DarkText,
390
390
+
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
399
391
)
400
392
},
393
393
+
leadingIcon = if (selected) ({ Icon(Icons.Default.Check, contentDescription = null) }) else null,
394
394
+
index = index,
395
395
+
lastIndex = field.options.lastIndex,
401
396
onClick = {
402
397
onChanged(field.name, value)
403
398
expanded = false
···
439
434
label,
440
435
fontSize = 13.sp,
441
436
fontWeight = FontWeight.Medium,
442
442
-
color = DarkText,
437
437
+
color = MaterialTheme.colorScheme.onSurface,
443
438
modifier = Modifier.weight(1f),
444
439
)
445
440
···
453
448
Text(
454
449
selectedLabel,
455
450
fontSize = 13.sp,
456
456
-
color = if (disabled) MutedText else PrimaryBlue,
451
451
+
color = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
457
452
fontWeight = FontWeight.SemiBold,
458
453
)
459
454
Icon(
460
455
Icons.Default.ExpandMore,
461
456
contentDescription = null,
462
457
modifier = Modifier.size(18.dp),
463
463
-
tint = if (disabled) MutedText else PrimaryBlue,
458
458
+
tint = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
464
459
)
465
460
}
466
461
467
467
-
DropdownMenu(
462
462
+
ExpressiveDropdownMenu(
468
463
expanded = expanded && !disabled,
469
464
onDismissRequest = { expanded = false },
470
470
-
containerColor = CardWhite,
471
465
) {
472
466
// DISPLAY OFF option
473
473
-
DropdownMenuItem(
467
467
+
ExpressiveDropdownMenuItem(
468
468
+
selected = field.selected == "-1",
474
469
text = {
475
470
Text(
476
471
"DISPLAY OFF",
477
472
fontSize = 13.sp,
478
473
fontWeight = if (field.selected == "-1") FontWeight.Bold else FontWeight.Normal,
479
479
-
color = if (field.selected == "-1") PrimaryBlue else DarkText,
480
474
)
481
475
},
476
476
+
leadingIcon = if (field.selected == "-1") ({ Icon(Icons.Default.Check, contentDescription = null) }) else null,
477
477
+
index = 0,
478
478
+
lastIndex = field.options.size,
482
479
onClick = {
483
480
onChanged(field.name, "-1")
484
481
expanded = false
485
482
},
486
483
)
487
487
-
field.options.forEach { (value, optLabel) ->
488
488
-
DropdownMenuItem(
484
484
+
field.options.forEachIndexed { index, (value, optLabel) ->
485
485
+
val selected = value == field.selected
486
486
+
ExpressiveDropdownMenuItem(
487
487
+
selected = selected,
489
488
text = {
490
489
Text(
491
490
optLabel,
492
491
fontSize = 13.sp,
493
493
-
fontWeight = if (value == field.selected) FontWeight.Bold else FontWeight.Normal,
494
494
-
color = if (value == field.selected) PrimaryBlue else DarkText,
492
492
+
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
495
493
)
496
494
},
495
495
+
leadingIcon = if (selected) ({ Icon(Icons.Default.Check, contentDescription = null) }) else null,
496
496
+
index = index + 1,
497
497
+
lastIndex = field.options.size,
497
498
onClick = {
498
499
onChanged(field.name, value)
499
500
expanded = false
···
513
514
contentDescription = "Judge display preview",
514
515
modifier = Modifier
515
516
.fillMaxWidth()
516
516
-
.clip(RoundedCornerShape(6.dp)),
517
517
+
.clip(MaterialTheme.shapes.large),
517
518
contentScale = ContentScale.FillWidth,
518
519
)
519
520
}
520
521
}
521
522
}
522
522
-
523
523
-
/** Tap Design split button — last option shows tapkun icon instead of text. */
524
524
-
@Composable
525
525
-
private fun TapDesignSplitRow(
526
526
-
field: OptionField,
527
527
-
disabled: Boolean = false,
528
528
-
onChanged: (fieldName: String, newValue: String) -> Unit,
529
529
-
) {
530
530
-
val label = OPTION_LABELS[field.name] ?: field.name
531
531
-
val tapkunBitmap = remember { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
532
532
-
LaunchedEffect(Unit) {
533
533
-
tapkunBitmap.value = MaimaiAssets.load("tapkun.png")
534
534
-
}
535
535
-
536
536
-
Row(
537
537
-
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
538
538
-
verticalAlignment = Alignment.CenterVertically,
539
539
-
) {
540
540
-
Text(
541
541
-
label,
542
542
-
fontSize = 13.sp,
543
543
-
fontWeight = FontWeight.Medium,
544
544
-
color = DarkText,
545
545
-
modifier = Modifier.weight(1f),
546
546
-
)
547
547
-
548
548
-
Row(
549
549
-
horizontalArrangement = Arrangement.spacedBy(0.dp),
550
550
-
modifier = Modifier.clip(RoundedCornerShape(6.dp)),
551
551
-
) {
552
552
-
field.options.forEachIndexed { i, (value, optLabel) ->
553
553
-
val selected = value == field.selected
554
554
-
val bgColor = if (selected) PrimaryBlue else MutedText.copy(alpha = 0.1f)
555
555
-
val textColor = if (selected) CardWhite else DarkText
556
556
-
557
557
-
Box(
558
558
-
modifier = Modifier
559
559
-
.defaultMinSize(minWidth = 44.dp, minHeight = 38.dp)
560
560
-
.background(if (disabled) bgColor.copy(alpha = 0.4f) else bgColor)
561
561
-
.clickable(enabled = !disabled) { onChanged(field.name, value) }
562
562
-
.padding(horizontal = 10.dp, vertical = 6.dp),
563
563
-
contentAlignment = Alignment.Center,
564
564
-
) {
565
565
-
// Last option (index 4) = Tapkun — show icon if loaded
566
566
-
if (i == 4 && tapkunBitmap.value != null) {
567
567
-
Image(
568
568
-
bitmap = tapkunBitmap.value!!,
569
569
-
contentDescription = "Tapkun",
570
570
-
modifier = Modifier.size(24.dp),
571
571
-
contentScale = ContentScale.Fit,
572
572
-
)
573
573
-
} else {
574
574
-
Text(
575
575
-
optLabel,
576
576
-
fontSize = 12.sp,
577
577
-
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
578
578
-
color = textColor,
579
579
-
textAlign = TextAlign.Center,
580
580
-
)
581
581
-
}
582
582
-
}
583
583
-
}
584
584
-
}
585
585
-
}
586
586
-
}
···
1
1
package com.derakkuma.ui.theme
2
2
3
3
-
import androidx.compose.material3.MaterialTheme
3
3
+
import androidx.compose.foundation.shape.RoundedCornerShape
4
4
+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
5
5
+
import androidx.compose.material3.MaterialExpressiveTheme
6
6
+
import androidx.compose.material3.MotionScheme
7
7
+
import androidx.compose.material3.Shapes
4
8
import androidx.compose.material3.Typography
5
9
import androidx.compose.material3.lightColorScheme
6
10
import androidx.compose.runtime.Composable
7
11
import androidx.compose.ui.graphics.Color
12
12
+
import androidx.compose.ui.text.TextStyle
8
13
import androidx.compose.ui.text.font.FontFamily
9
14
import androidx.compose.ui.text.font.FontWeight
15
15
+
import androidx.compose.ui.unit.dp
16
16
+
import androidx.compose.ui.unit.sp
10
17
import derakkuma.composeapp.generated.resources.Res
11
18
import derakkuma.composeapp.generated.resources.segamaru_db
12
19
import org.jetbrains.compose.resources.ExperimentalResourceApi
13
20
import org.jetbrains.compose.resources.Font
14
21
15
22
val MaimaiBlue = Color(0xFF51BCF3)
16
16
-
val CardWhite = Color(0xE0FFFFFF)
23
23
+
val CardWhite = Color(0xF2FFFFFF)
17
24
val DarkText = Color(0xFF111827)
18
25
val MutedText = Color(0xFF6B7280)
19
26
val AccentPink = Color(0xFFBE185D)
20
20
-
val PrimaryBlue = Color(0xFF2563EB)
27
27
+
val PrimaryBlue = Color(0xFF1769F5)
21
28
val DangerRed = Color(0xFFDC2626)
22
29
30
30
+
// Material 3 expressive palette: saturated, game-like colors with soft candy surfaces.
31
31
+
val ExpressiveSky = Color(0xFF43BDF4)
32
32
+
val ExpressiveOcean = Color(0xFF1769F5)
33
33
+
val ExpressiveBubblegum = Color(0xFFFF5FA2)
34
34
+
val ExpressiveLemon = Color(0xFFFFD84D)
35
35
+
val ExpressiveLime = Color(0xFF7CD82E)
36
36
+
val ExpressiveGrape = Color(0xFF865DFF)
37
37
+
val ExpressivePeach = Color(0xFFFF8D5C)
38
38
+
val ExpressiveMint = Color(0xFFBFF7E2)
39
39
+
val ExpressiveSurface = Color(0xFFF8FCFF)
40
40
+
val ExpressiveSurfaceVariant = Color(0xFFE7F4FF)
41
41
+
val ExpressiveOutline = Color(0x332563EB)
42
42
+
23
43
// maimai difficulty colors
24
44
val DiffBasic = Color(0xFF22BB5B)
25
45
val DiffAdvanced = Color(0xFFF9A825)
···
27
47
val DiffMaster = Color(0xFFAB47BC)
28
48
val DiffRemaster = Color(0xFFE0C3FF)
29
49
50
50
+
// Ranking medal colors
51
51
+
val MedalGold = Color(0xFFFFD700)
52
52
+
val MedalSilver = Color(0xFFC0C0C0)
53
53
+
val MedalBronze = Color(0xFFCD7F32)
54
54
+
30
55
fun difficultyColor(difficulty: String): Color = when (difficulty.lowercase()) {
31
56
"basic" -> DiffBasic
32
57
"advanced" -> DiffAdvanced
···
37
62
}
38
63
39
64
private val colorScheme = lightColorScheme(
40
40
-
primary = PrimaryBlue,
41
41
-
background = MaimaiBlue,
42
42
-
surface = CardWhite,
65
65
+
primary = ExpressiveOcean,
66
66
+
onPrimary = Color.White,
67
67
+
primaryContainer = Color(0xFFD7E7FF),
68
68
+
onPrimaryContainer = Color(0xFF06285E),
69
69
+
secondary = ExpressiveBubblegum,
70
70
+
onSecondary = Color.White,
71
71
+
secondaryContainer = Color(0xFFFFD8E6),
72
72
+
onSecondaryContainer = Color(0xFF5A0C2C),
73
73
+
tertiary = ExpressiveGrape,
74
74
+
onTertiary = Color.White,
75
75
+
tertiaryContainer = Color(0xFFE9DDFF),
76
76
+
onTertiaryContainer = Color(0xFF27105C),
77
77
+
background = ExpressiveSky,
78
78
+
onBackground = Color.White,
79
79
+
surface = ExpressiveSurface,
43
80
onSurface = DarkText,
81
81
+
surfaceVariant = ExpressiveSurfaceVariant,
82
82
+
onSurfaceVariant = MutedText,
83
83
+
outline = ExpressiveOutline,
44
84
error = DangerRed,
85
85
+
errorContainer = Color(0xFFFFDAD6),
86
86
+
onErrorContainer = Color(0xFF410002),
87
87
+
)
88
88
+
89
89
+
val ExpressiveShapes = Shapes(
90
90
+
extraSmall = RoundedCornerShape(12.dp),
91
91
+
small = RoundedCornerShape(16.dp),
92
92
+
medium = RoundedCornerShape(24.dp),
93
93
+
large = RoundedCornerShape(32.dp),
94
94
+
extraLarge = RoundedCornerShape(40.dp),
45
95
)
46
96
47
97
@OptIn(ExperimentalResourceApi::class)
···
56
106
@Composable
57
107
fun SegaMaruTypography() = Typography().run {
58
108
val fontFamily = SegaMaruFontFamily()
109
109
+
fun TextStyle.withDerakkuma(
110
110
+
weight: FontWeight = FontWeight.Normal,
111
111
+
tracking: Float = 0f,
112
112
+
) = copy(
113
113
+
fontFamily = fontFamily,
114
114
+
fontWeight = weight,
115
115
+
letterSpacing = tracking.sp,
116
116
+
)
117
117
+
59
118
copy(
60
60
-
displayLarge = displayLarge.copy(fontFamily = fontFamily),
61
61
-
displayMedium = displayMedium.copy(fontFamily = fontFamily),
62
62
-
displaySmall = displaySmall.copy(fontFamily = fontFamily),
63
63
-
headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
64
64
-
headlineMedium = headlineMedium.copy(fontFamily = fontFamily),
65
65
-
headlineSmall = headlineSmall.copy(fontFamily = fontFamily),
66
66
-
titleLarge = titleLarge.copy(fontFamily = fontFamily),
67
67
-
titleMedium = titleMedium.copy(fontFamily = fontFamily),
68
68
-
titleSmall = titleSmall.copy(fontFamily = fontFamily),
69
69
-
bodyLarge = bodyLarge.copy(fontFamily = fontFamily),
70
70
-
bodyMedium = bodyMedium.copy(fontFamily = fontFamily),
71
71
-
bodySmall = bodySmall.copy(fontFamily = fontFamily),
72
72
-
labelLarge = labelLarge.copy(fontFamily = fontFamily),
73
73
-
labelMedium = labelMedium.copy(fontFamily = fontFamily),
74
74
-
labelSmall = labelSmall.copy(fontFamily = fontFamily),
119
119
+
displayLarge = displayLarge.withDerakkuma(FontWeight.ExtraBold, -0.8f),
120
120
+
displayMedium = displayMedium.withDerakkuma(FontWeight.ExtraBold, -0.6f),
121
121
+
displaySmall = displaySmall.withDerakkuma(FontWeight.ExtraBold, -0.4f),
122
122
+
headlineLarge = headlineLarge.withDerakkuma(FontWeight.ExtraBold, -0.3f),
123
123
+
headlineMedium = headlineMedium.withDerakkuma(FontWeight.ExtraBold, -0.2f),
124
124
+
headlineSmall = headlineSmall.withDerakkuma(FontWeight.Bold, -0.1f),
125
125
+
titleLarge = titleLarge.withDerakkuma(FontWeight.ExtraBold, -0.1f),
126
126
+
titleMedium = titleMedium.withDerakkuma(FontWeight.Bold),
127
127
+
titleSmall = titleSmall.withDerakkuma(FontWeight.SemiBold),
128
128
+
bodyLarge = bodyLarge.withDerakkuma(FontWeight.Normal),
129
129
+
bodyMedium = bodyMedium.withDerakkuma(FontWeight.Normal),
130
130
+
bodySmall = bodySmall.withDerakkuma(FontWeight.Normal),
131
131
+
labelLarge = labelLarge.withDerakkuma(FontWeight.ExtraBold, 0.2f),
132
132
+
labelMedium = labelMedium.withDerakkuma(FontWeight.Bold, 0.2f),
133
133
+
labelSmall = labelSmall.withDerakkuma(FontWeight.SemiBold, 0.2f),
75
134
)
76
135
}
77
136
137
137
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
78
138
@Composable
79
139
fun DerakkumaTheme(content: @Composable () -> Unit) {
80
80
-
MaterialTheme(
140
140
+
MaterialExpressiveTheme(
81
141
colorScheme = colorScheme,
142
142
+
motionScheme = MotionScheme.expressive(),
82
143
typography = SegaMaruTypography(),
144
144
+
shapes = ExpressiveShapes,
83
145
content = content,
84
146
)
85
147
}
···
3
3
import androidx.compose.material.icons.Icons
4
4
import androidx.compose.material.icons.filled.ContentCopy
5
5
import androidx.compose.material3.Icon
6
6
-
import androidx.compose.material3.IconButton
6
6
+
import androidx.compose.material3.MaterialTheme
7
7
import androidx.compose.runtime.Composable
8
8
import androidx.compose.runtime.rememberCoroutineScope
9
9
import androidx.compose.ui.Modifier
10
10
import androidx.compose.ui.platform.LocalClipboard
11
11
-
import com.derakkuma.ui.theme.PrimaryBlue
11
11
+
import com.derakkuma.ui.components.ExpressiveFilledTonalIconButton
12
12
import kotlinx.coroutines.launch
13
13
14
14
@Composable
···
16
16
val clipboard = LocalClipboard.current
17
17
val scope = rememberCoroutineScope()
18
18
19
19
-
IconButton(
19
19
+
ExpressiveFilledTonalIconButton(
20
20
onClick = {
21
21
scope.launch {
22
22
text.copyToClipboard(clipboard)
···
27
27
Icon(
28
28
imageVector = Icons.Default.ContentCopy,
29
29
contentDescription = "Copy",
30
30
-
tint = PrimaryBlue,
30
30
+
tint = MaterialTheme.colorScheme.primary,
31
31
)
32
32
}
33
33
}
···
19
19
@Composable
20
20
actual fun platformMonochromeAppIconColors(): Pair<Color, Color> = Color(0xFFEADDFF) to Color(0xFF4F378B)
21
21
22
22
-
actual suspend fun loadAppIconPreviewAsset(path: String): ImageBitmap? =
23
23
-
runCatching {
24
24
-
val name = path.substringAfterLast('/').substringBeforeLast(".")
25
25
-
val image = UIImage.imageNamed(name) ?: return@runCatching null
26
26
-
UIImagePNGRepresentation(image)?.toByteArray()?.decodeToImageBitmap()
27
27
-
}.getOrNull()
22
22
+
actual suspend fun loadAppIconPreviewAsset(path: String): ImageBitmap? = runCatching {
23
23
+
val name = path.substringAfterLast('/').substringBeforeLast(".")
24
24
+
val image = UIImage.imageNamed(name) ?: return@runCatching null
25
25
+
UIImagePNGRepresentation(image)?.toByteArray()?.decodeToImageBitmap()
26
26
+
}.getOrNull()
28
27
29
28
actual fun setAppIcon(option: AppIconOption): Boolean {
30
29
setAlternateAppIconName(option.iosName)
···
2
2
kotlin.code.style=official
3
3
android.useAndroidX=true
4
4
android.nonTransitiveRClass=true
5
5
+
org.gradle.configuration-cache=true
···
1
1
[versions]
2
2
kotlin = "2.3.20"
3
3
compose-multiplatform = "1.11.0"
4
4
-
agp = "8.9.3"
4
4
+
agp = "9.1.1"
5
5
ktor = "3.5.0"
6
6
kotlinx-serialization = "1.11.0"
7
7
kotlinx-coroutines = "1.10.2"
8
8
kotlinx-datetime = "0.7.1"
9
9
androidx-activity = "1.10.1"
10
10
androidx-lifecycle = "2.9.0"
11
11
+
androidx-browser = "1.8.0"
12
12
+
androidx-core = "1.17.0"
11
13
navigation-compose = "2.9.0"
12
14
datastore = "1.1.7"
13
15
sqldelight = "2.1.0"
···
34
36
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
35
37
36
38
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
39
39
+
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" }
40
40
+
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
37
41
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
38
42
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
39
43
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
···
65
69
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
66
70
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
67
71
android-application = { id = "com.android.application", version.ref = "agp" }
72
72
+
android-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
68
73
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
···
1
1
distributionBase=GRADLE_USER_HOME
2
2
distributionPath=wrapper/dists
3
3
-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
3
3
+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
4
4
networkTimeout=10000
5
5
retries=0
6
6
retryBackOffMs=500
···
17
17
}
18
18
19
19
include(":composeApp")
20
20
+
include(":androidApp")
20
21
21
22
includeBuild("third_party/Compose-HeatMap") {
22
23
dependencySubstitution {