diff --git a/build.gradle.kts b/build.gradle.kts index a5997a5..885cb60 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,11 @@ kotlin { dependencies { implementation(compose.desktop.currentOs) implementation(compose.materialIconsExtended) + implementation(compose.uiTooling) + // debugImplementation(compose.preview) + + // kotlinx-coroutines-swing + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.5.2") // TODO: md3 migration // implementation(compose.material3) @@ -35,6 +40,7 @@ kotlin { implementation("com.squareup.okhttp3:okhttp:4.10.0") implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") + implementation("org.jmdns:jmdns:3.5.8") } } val jvmTest by getting @@ -49,7 +55,7 @@ compose.desktop { modules("java.instrument", "java.prefs", "jdk.unsupported") targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "LifeUp Desktop" - packageVersion = "1.0.2" + packageVersion = "1.1.0" macOS { iconFile.set(project.file("icon.icns")) } @@ -57,6 +63,9 @@ compose.desktop { iconFile.set(project.file("icon.ico")) dirChooser = true // enables customizing the installation path during installation // console = true + shortcut = true + perUserInstall = true + upgradeUuid = "6400cdde-3cb6-4bad-b238-70b02cc8d210" menuGroup = "LifeUp Desktop" } linux { diff --git a/compose-desktop.pro b/compose-desktop.pro index ece7d62..8434408 100644 --- a/compose-desktop.pro +++ b/compose-desktop.pro @@ -25,4 +25,12 @@ } -keepclassmembers class <1>.<2> { <1>.<2>$Companion Companion; -} \ No newline at end of file +} + +-dontwarn kotlinx.datetime.** +-dontwarn org.slf4j.** +-keep class org.slf4j.**{ *; } +-keep class com.sun.jna.* { *; } +-keep class * implements com.sun.jna.* { *; } + +-keep class org.jmdns.** { *; } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e3baf72..bce5222 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official -kotlin.version=1.7.20 +kotlin.version=1.8.20 agp.version=7.3.0 -compose.version=1.3.0-rc05 \ No newline at end of file +compose.version=1.4.0 \ No newline at end of file diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index e314f55..106ad3a 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -37,8 +37,10 @@ import ui.page.list.TasksScreen import ui.page.status.StatusScreen import ui.theme.AppTheme import ui.view.fakeDialog +import java.awt.Toolkit import java.awt.event.WindowEvent import java.util.logging.Logger +import javax.swing.UIManager import kotlin.system.exitProcess @ExperimentalUnitApi @@ -180,6 +182,8 @@ fun main() { application(exitProcessOnExit = false) { // To fix the window crash issue: https://github.com/JetBrains/compose-jb/issues/610 System.setProperty("skiko.renderApi", "OPENGL") + // get native dialog UI + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) CompositionLocalProvider( LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> @@ -187,15 +191,23 @@ fun main() { lastError = it window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) // throw it + throw it } } ) { + // sum better window size + val screenSize = Toolkit.getDefaultToolkit().screenSize + val width = screenSize.width + val height = screenSize.height + val windowWidth = if (width > 1920) 1200 else 800 + val windowHeight = if (height > 1080) 900 else 600 + Window( onCloseRequest = ::exitApplication, title = "LifeUp", state = rememberWindowState( position = WindowPosition(alignment = Alignment.Center), - size = DpSize(1200.dp, 900.dp) + size = DpSize(windowWidth.dp, windowHeight.dp) ), icon = painterResource("icons/svg/icon.svg") ) { diff --git a/src/jvmMain/kotlin/base/JsonSerializer.kt b/src/jvmMain/kotlin/base/JsonSerializer.kt index c760e07..ef11bcf 100644 --- a/src/jvmMain/kotlin/base/JsonSerializer.kt +++ b/src/jvmMain/kotlin/base/JsonSerializer.kt @@ -4,4 +4,5 @@ import kotlinx.serialization.json.Json val json = Json { ignoreUnknownKeys = true + explicitNulls = false } \ No newline at end of file diff --git a/src/jvmMain/kotlin/base/Val.kt b/src/jvmMain/kotlin/base/Val.kt index 591fe18..7bef043 100644 --- a/src/jvmMain/kotlin/base/Val.kt +++ b/src/jvmMain/kotlin/base/Val.kt @@ -1,10 +1,23 @@ package base object Val { - // FIXME: get version from gradle - val version: String = "1.0.2" + val version: String + get() = System.getProperty("jpackage.app-version") ?: "UNKNOWN" - val targetLifeUpCloudVersion = "1.1.2+" + val versionCode: Int by lazy { + val version = version.split(".") + assert(version.size == 3) { "Invalid version format" } + if (version.size != 3) { + return@lazy 0 + } + val major = version[0].padStart(2, '0').toIntOrNull() ?: 0 + val minor = version[1].padStart(2, '0').toIntOrNull() ?: 0 + val patch = version[2].padStart(2, '0').toIntOrNull() ?: 0 + assert(major <= 99 && minor <= 99 && patch <= 99) { "Version number is too large" } + (major * 100000 + minor * 1000 + patch) + } - val targetLifeUpAndroidVersion = "1.91.0+" + const val targetLifeUpCloudVersion = "1.3.0+" + + const val targetLifeUpAndroidVersion = "1.91.3+" } \ No newline at end of file diff --git a/src/jvmMain/kotlin/datasource/ApiService.kt b/src/jvmMain/kotlin/datasource/ApiService.kt index e36691f..4c9d55d 100644 --- a/src/jvmMain/kotlin/datasource/ApiService.kt +++ b/src/jvmMain/kotlin/datasource/ApiService.kt @@ -2,6 +2,7 @@ package datasource import datasource.data.* import datasource.net.HttpResponse +import kotlinx.serialization.json.JsonElement interface ApiService { @@ -38,5 +39,10 @@ interface ApiService { suspend fun getInfo(): Info + suspend fun rawCall(api: String): JsonElement? + suspend fun purchaseItem(id: Long?, price: Long, desc: String) + + suspend fun checkUpdate(): ApiServiceImpl.LocalizedUpdateInfo? + } diff --git a/src/jvmMain/kotlin/datasource/ApiServiceImpl.kt b/src/jvmMain/kotlin/datasource/ApiServiceImpl.kt index a8b1419..4491437 100644 --- a/src/jvmMain/kotlin/datasource/ApiServiceImpl.kt +++ b/src/jvmMain/kotlin/datasource/ApiServiceImpl.kt @@ -6,12 +6,15 @@ import datasource.data.* import datasource.net.HttpResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request +import java.util.* object ApiServiceImpl : ApiService { @@ -130,6 +133,17 @@ object ApiServiceImpl : ApiService { } } + override suspend fun rawCall(api: String): JsonElement? { + return withContext(Dispatchers.IO) { + val url = (OkHttpClientHolder.host + "/api/contentprovider").toHttpUrl().newBuilder() + .addQueryParameter("url", api) + .build() + val request = Request.Builder().url(url).build() + val response = okHttpClient.newCall(request).execute() + json.decodeFromString>(response.body?.string() ?: "").successOrThrow() + } + } + override fun getIconUrl(icon: String): String { if (icon.isEmpty()) { return "" @@ -141,4 +155,57 @@ object ApiServiceImpl : ApiService { .build() return url.toString() } + + @Serializable + data class UpdateInfoMap( + val versionCode: Int, + val downloadUrl: String?, + val localeInfo: Map + ) + + @Serializable + data class UpdateInfo( + val versionName: String?, + val downloadUrl: String?, + val releaseNotes: String?, + val downloadWebsite: String? + ) + + data class LocalizedUpdateInfo( + val versionCode: Int, + val versionName: String?, + val downloadUrl: String?, + val releaseNotes: String?, + val downloadWebsite: String? + ) + + private const val UPDATE_URL = "http://cdn.lifeupapp.fun/version/version.json" + + + override suspend fun checkUpdate(): LocalizedUpdateInfo? { + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(UPDATE_URL).build() + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val jsonText = response.body?.string() + jsonText?.let { + val updateInfo = json.decodeFromString(it) + val locale = Locale.getDefault() + val bestMatchedUpdateInfo = + updateInfo.localeInfo["${locale.language.lowercase()}_${locale.country.lowercase()}"] + ?: updateInfo.localeInfo[locale.language.lowercase()] ?: updateInfo.localeInfo["en"] + + return@let LocalizedUpdateInfo( + versionCode = updateInfo.versionCode, + downloadUrl = updateInfo.downloadUrl, + versionName = bestMatchedUpdateInfo?.versionName ?: "", + releaseNotes = bestMatchedUpdateInfo?.releaseNotes ?: "", + downloadWebsite = bestMatchedUpdateInfo?.downloadWebsite ?: "" + ) + } + } else { + null + } + } + } } \ No newline at end of file diff --git a/src/jvmMain/kotlin/datasource/data/Achievement.kt b/src/jvmMain/kotlin/datasource/data/Achievement.kt index f676eba..b8cf286 100644 --- a/src/jvmMain/kotlin/datasource/data/Achievement.kt +++ b/src/jvmMain/kotlin/datasource/data/Achievement.kt @@ -82,5 +82,14 @@ data class Achievement( fun builder(block: Builder.() -> Unit): Achievement { return Builder().apply(block).build() } + + private const val TYPE_NORMAL = 0 + private const val TYPE_SUBCATEGORY = 1 } + + fun isNormalAchievement() = type == TYPE_NORMAL + + fun isSubcategory() = type == TYPE_SUBCATEGORY + + } \ No newline at end of file diff --git a/src/jvmMain/kotlin/datasource/data/TaskCategory.kt b/src/jvmMain/kotlin/datasource/data/TaskCategory.kt index d272195..60430b5 100644 --- a/src/jvmMain/kotlin/datasource/data/TaskCategory.kt +++ b/src/jvmMain/kotlin/datasource/data/TaskCategory.kt @@ -49,6 +49,14 @@ data class TaskCategory( } } + fun isNormalList(): Boolean { + return (id ?: 0 > 0L) && type == 0 + } + + fun isNotArchived(): Boolean { + return status == 0 + } + companion object { fun builder(block: Builder.() -> Unit): TaskCategory { return Builder().apply(block).build() diff --git a/src/jvmMain/kotlin/datasource/net/HttpResponse.kt b/src/jvmMain/kotlin/datasource/net/HttpResponse.kt index ae7066d..1d0c8f9 100644 --- a/src/jvmMain/kotlin/datasource/net/HttpResponse.kt +++ b/src/jvmMain/kotlin/datasource/net/HttpResponse.kt @@ -34,6 +34,14 @@ data class HttpResponse( return data } + + fun successOrThrow(): T? { + if (code != SUCCESS) { + throw HttpException(this) + } + return data + } + fun onSuccess(block: (T?) -> Unit): HttpResponse { if (code == SUCCESS) { block(data) diff --git a/src/jvmMain/kotlin/service/MdnsServiceDiscovery.kt b/src/jvmMain/kotlin/service/MdnsServiceDiscovery.kt new file mode 100644 index 0000000..a035414 --- /dev/null +++ b/src/jvmMain/kotlin/service/MdnsServiceDiscovery.kt @@ -0,0 +1,65 @@ +package service + +import logger +import java.net.InetAddress +import java.util.logging.Level +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceListener + +/** + * Service to discover the lifeup cloud server + */ +class MdnsServiceDiscovery { + + data class IpAndPort(val ip: String, val port: String) { + override fun toString(): String { + return "$ip:$port" + } + } + + val ipAndPorts = HashMap() + + private val listener = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent?) { + logger.log(Level.INFO, "Service added: ${event?.info}") + } + + override fun serviceRemoved(event: ServiceEvent?) { + logger.log(Level.INFO, "Service removed: ${event?.info}") + event?.info?.inetAddresses?.firstOrNull()?.hostAddress?.let { + ipAndPorts.remove(it) + } + } + + override fun serviceResolved(event: ServiceEvent?) { + logger.log(Level.INFO, "Service resolved: ${event?.info}") + runCatching { + if (event?.name?.contains("lifeup_cloud") == true) { + logger.log(Level.INFO, "Service resolved, address: ${event.info.inetAddresses}") + + val port = event.info.getPropertyString("port") + if (port.isNullOrEmpty()) { + logger.log(Level.INFO, "Service resolved, but data has not port") + return@runCatching + } + + val ip = event.info.inetAddresses.first().hostAddress + logger.log(Level.INFO, "Service resolved, ip: $ip") + ipAndPorts[ip] = IpAndPort(ip, port) + } + }.onFailure { + it.printStackTrace() + } + } + } + + + fun register() = runCatching { + // Create a JmDNS instance + val jmdns = JmDNS.create(InetAddress.getLocalHost()) + + // Add a service listener + jmdns.addServiceListener("_lifeup._tcp.local.", listener) + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/AppStoreImpl.kt b/src/jvmMain/kotlin/ui/AppStoreImpl.kt index 006d008..7853902 100644 --- a/src/jvmMain/kotlin/ui/AppStoreImpl.kt +++ b/src/jvmMain/kotlin/ui/AppStoreImpl.kt @@ -1,16 +1,24 @@ package ui +import AppScope import androidx.compose.material.MaterialTheme import androidx.compose.material.ScaffoldState -import androidx.compose.runtime.* +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import base.OkHttpClientHolder import datasource.ApiService +import datasource.ApiServiceImpl import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import logger import okhttp3.HttpUrl +import service.MdnsServiceDiscovery import ui.text.Localization import ui.text.StringText import java.io.File @@ -46,6 +54,7 @@ class AppStoreImpl( var dialogStatus: DialogStatus? by mutableStateOf(null) private set + var updateInfo: ApiServiceImpl.LocalizedUpdateInfo? = null var coinValue: Long? by mutableStateOf(null) private set @@ -59,6 +68,8 @@ class AppStoreImpl( private set private val fetching = AtomicBoolean(false) + private val mdnsServiceDiscovery = MdnsServiceDiscovery() + private fun initStrings(): StringText { return Localization.get() @@ -82,14 +93,58 @@ class AppStoreImpl( }.flowOn(kotlinx.coroutines.Dispatchers.IO) init { + AppScope.launch { + mdnsServiceDiscovery.register() + } updateIpOrPort() + + coroutineScope.launch { + var retryDelay = 5000L + while (true) { + if (checkUpdateAwait() != null) { + // Success, check for updates every 3 hours + retryDelay = 1000L * 60 * 60 * 3 // 3 hours + } else { + // Failure, retry with increasing delay + + retryDelay *= 2 // Double the delay time + if (retryDelay > 1000L * 60 * 60 * 3) { + retryDelay = 1000L * 60 * 60 * 3 + } + } + delay(retryDelay) + } + } + } + + fun listServerInfo(): List { + return mdnsServiceDiscovery.ipAndPorts.values.toList().mapNotNull { it } } + private fun checkUpdate() { + coroutineScope.launch { + checkUpdateAwait() + } + } + + suspend fun checkUpdateAwait(): ApiServiceImpl.LocalizedUpdateInfo? { + return apiService.checkUpdate()?.also { + this@AppStoreImpl.updateInfo = it + } + } fun updateIpOrPort(ip: String = this.ip, port: String = this.port) { this.ip = ip this.port = port + if (ip.isEmpty() || port.isEmpty()) { + Preferences.userRoot().apply { + put("ip", ip) + put("port", port) + } + return + } + val validHost = kotlin.runCatching { HttpUrl.Builder().scheme("http").host(ip).port(port = port.toIntOrNull() ?: 13276).build() }.onFailure { @@ -144,10 +199,11 @@ class AppStoreImpl( } } -val AppStore = compositionLocalOf { error("AppStore error") } +val AppStore = compositionLocalOf { + AppStoreImpl(GlobalScope) +} val ScaffoldState = compositionLocalOf { error("ScaffoldState error") } val Strings: StringText - @Composable - get() = AppStore.current.strings \ No newline at end of file + get() = Localization.get() \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/page/achievement/AchievementContent.kt b/src/jvmMain/kotlin/ui/page/achievement/AchievementContent.kt index a05a1bf..5b511dc 100644 --- a/src/jvmMain/kotlin/ui/page/achievement/AchievementContent.kt +++ b/src/jvmMain/kotlin/ui/page/achievement/AchievementContent.kt @@ -1,6 +1,7 @@ package ui.page.achievement import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -30,7 +31,6 @@ import logger import ui.Strings import ui.page.config.Spacer4dpH import ui.page.list.MARGIN_SCROLLBAR -import ui.page.list.VerticalScrollbar import ui.page.list.rememberScrollbarAdapter import ui.text.Localization.dateTimeFormatterWithNewLine import ui.theme.subTitle3 @@ -110,10 +110,14 @@ private fun ListContent( LazyColumn(state = listState) { items(items) { item -> - Item( - item = item, - onClicked = { onItemClicked(item.id ?: 0L) }, - ) + if (item.isNormalAchievement()) { + Item( + item = item, + onClicked = { onItemClicked(item.id ?: 0L) }, + ) + } else { + Subcategory(item = item) + } Divider() } @@ -126,43 +130,6 @@ private fun ListContent( } } -@Composable -private fun CoinRow( - number: Long, - onClicked: () -> Unit -) { - Row( - modifier = Modifier.clickable(onClick = onClicked).requiredSizeIn(minHeight = 56.dp) - .padding(vertical = 8.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width(8.dp)) - Image( - painter = painterResource("icons/xml/ic_coin.xml"), - contentDescription = "coin icon", - modifier = Modifier.size(40.dp) - ) - - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = AnnotatedString(Strings.coin), - modifier = Modifier.weight(1F).align(Alignment.CenterVertically), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.width(8.dp)) - - - val color = Color(255, 163, 0) - Text(number.toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = color) - - Spacer(modifier = Modifier.width(MARGIN_SCROLLBAR)) - } -} - @Composable private fun Item( item: Achievement, @@ -248,6 +215,52 @@ private fun Item( + Spacer(modifier = Modifier.width(MARGIN_SCROLLBAR)) + } +} + + +@Composable +private fun Subcategory( + item: Achievement, +) { + Row( + modifier = Modifier.requiredSizeIn(minHeight = 56.dp) + .padding(vertical = 8.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(8.dp)) + +// logger.log(Level.INFO, "item.icon: ${item.iconUri}") +// AsyncImage( +// condition = item.iconUri.isNotBlank(), +// load = { +// loadImageBitmap(item.iconUri) +// }, +// painterFor = { +// remember { BitmapPainter(it) } +// }, +// contentDescription = "skill icon", +// modifier = Modifier.size(40.dp), +// onError = { +// Image( +// painter = painterResource("icons/xml/ic_pic_loading_cir.xml"), +// contentDescription = "skill icon", +// modifier = Modifier.size(40.dp) +// ) +// } +// ) +// + + Column(Modifier.weight(1F).align(Alignment.CenterVertically)) { + Text( + text = AnnotatedString(item.name), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h6 + ) + } + Spacer(modifier = Modifier.width(MARGIN_SCROLLBAR)) } } diff --git a/src/jvmMain/kotlin/ui/page/config/ConfigContent.kt b/src/jvmMain/kotlin/ui/page/config/ConfigContent.kt index 17e2654..e662e28 100644 --- a/src/jvmMain/kotlin/ui/page/config/ConfigContent.kt +++ b/src/jvmMain/kotlin/ui/page/config/ConfigContent.kt @@ -1,6 +1,7 @@ package ui.page.config import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter @@ -10,21 +11,31 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.Val +import kotlinx.coroutines.launch +import service.MdnsServiceDiscovery import ui.AppStore +import ui.ScaffoldState import ui.Strings -import ui.page.list.VerticalScrollbar +import ui.page.list.Dialog import ui.theme.unimportantText +import java.awt.Desktop +import java.net.URI @Composable fun ConfigScreen(modifier: Modifier = Modifier) { val globalStore = AppStore.current val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + val model = remember { ConfigStore(coroutineScope, globalStore) } + val state = model.state Box { Column( modifier.padding(24.dp).verticalScroll(scrollState), @@ -51,7 +62,7 @@ fun ConfigScreen(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.SpaceBetween ) { if (coin == null) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Icon( imageVector = Icons.Default.Warning, contentDescription = null, @@ -59,13 +70,21 @@ fun ConfigScreen(modifier: Modifier = Modifier) { ) Text(modifier = Modifier.padding(start = 8.dp), text = Strings.not_connected) } - Button(onClick = { + OutlinedButton(onClick = { globalStore.fetchCoin() }, modifier = Modifier.padding(start = 8.dp)) { Text(Strings.test_connection) } + Button(onClick = { + // show dialog + model.updateState { + this.copy(isDialogShowing = true) + } + }, modifier = Modifier.padding(start = 8.dp)) { + Text(Strings.auto_detect) + } } else { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, @@ -73,11 +92,19 @@ fun ConfigScreen(modifier: Modifier = Modifier) { ) Text(modifier = Modifier.padding(start = 8.dp), text = Strings.connected.format(coin)) } - OutlinedButton(onClick = { + TextButton(onClick = { globalStore.fetchCoin() }, modifier = Modifier.padding(start = 8.dp)) { Text(Strings.test_connection) } + OutlinedButton(onClick = { + // show dialog + model.updateState { + this.copy(isDialogShowing = true) + } + }, modifier = Modifier.padding(start = 8.dp)) { + Text(Strings.auto_detect) + } } } Spacer16dpH() @@ -94,6 +121,38 @@ fun ConfigScreen(modifier: Modifier = Modifier) { color = MaterialTheme.colors.unimportantText, fontSize = 14.sp ) + Spacer24dpH() + val updateInfo = globalStore.updateInfo + if ((updateInfo?.versionCode ?: 0) > Val.versionCode) { + // Show a button to download the update + Button( + onClick = { + val uri = runCatching { URI(updateInfo?.downloadWebsite ?: "") }.getOrNull() + if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(uri) + } + } + ) { + Text(Strings.about_update_button) + } + } else { + // Show a secondary button to check for updates + val scaffoldState = ScaffoldState.current + + OutlinedButton( + onClick = { + coroutineScope.launch { + val result = globalStore.checkUpdateAwait() + if (result != null && result.versionCode <= Val.versionCode) { + scaffoldState.snackbarHostState.showSnackbar(Strings.about_message_no_update) + return@launch + } + } + } + ) { + Text(Strings.about_check_updates_button) + } + } Spacer16dpH() Divider() Spacer16dpH() @@ -110,14 +169,59 @@ fun ConfigScreen(modifier: Modifier = Modifier) { adapter = rememberScrollbarAdapter(scrollState = scrollState) ) } + + // showing the select ip dialog + if (model.state.isDialogShowing) { + val config = globalStore.listServerInfo() + SelectIpDialog(config, onIpSelected = { + globalStore.updateIpOrPort(it.ip, it.port) + model.updateState { + this.copy(isDialogShowing = false) + } + }) { + model.updateState { + this.copy(isDialogShowing = false) + } + } + } +} + +/** + * Dialog for showing discovered ips + */ +@Composable +internal fun SelectIpDialog( + ips: List, + onIpSelected: (MdnsServiceDiscovery.IpAndPort) -> Unit, + onCloseClicked: () -> Unit +) { + Dialog( + title = Strings.auto_detect_dialog_title, + onCloseRequest = onCloseClicked, + ) { + Column { + if (ips.isEmpty()) { + Text(Strings.auto_detect_dialog_empty_desc) + } + ips.forEach { + TextButton(onClick = { + onIpSelected(it) + }) { + Text(it.toString()) + } + } + } + } } + @Composable -fun Subtitle(text: String) { +fun Subtitle(text: String, modifier: Modifier = Modifier) { Text( text = text, style = MaterialTheme.typography.subtitle1.copy(color = MaterialTheme.colors.primary), - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = modifier ) } @@ -132,6 +236,11 @@ fun Spacer16dpH() { Spacer(Modifier.height(16.dp)) } +@Composable +fun Spacer16dpW() { + Spacer(Modifier.width(16.dp)) +} + @Composable fun Spacer4dpH() { Spacer(Modifier.height(4.dp)) diff --git a/src/jvmMain/kotlin/ui/page/config/ConfigStore.kt b/src/jvmMain/kotlin/ui/page/config/ConfigStore.kt index bfe4d95..d98e999 100644 --- a/src/jvmMain/kotlin/ui/page/config/ConfigStore.kt +++ b/src/jvmMain/kotlin/ui/page/config/ConfigStore.kt @@ -3,13 +3,22 @@ package ui.page.config import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import ui.AppStoreImpl -internal class ConfigStore { +internal class ConfigStore( + private val coroutineScope: CoroutineScope, + private val globalStore: AppStoreImpl +) { - var state: TemplateState by mutableStateOf(TemplateState(0)) + var state: TemplateState by mutableStateOf(TemplateState(isDialogShowing = false)) private set data class TemplateState( - val state: Int + val isDialogShowing: Boolean ) + + fun updateState(block: TemplateState.() -> TemplateState) { + state = state.block() + } } diff --git a/src/jvmMain/kotlin/ui/page/feelings/FeelingsContent.kt b/src/jvmMain/kotlin/ui/page/feelings/FeelingsContent.kt index 7a84b28..c2abff1 100644 --- a/src/jvmMain/kotlin/ui/page/feelings/FeelingsContent.kt +++ b/src/jvmMain/kotlin/ui/page/feelings/FeelingsContent.kt @@ -2,12 +2,14 @@ package ui.page.feelings import androidx.compose.foundation.HorizontalScrollbar import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -26,7 +28,6 @@ import ui.Strings import ui.page.config.Spacer4dpH import ui.page.config.Spacer8dpH import ui.page.list.MARGIN_SCROLLBAR -import ui.page.list.VerticalScrollbar import ui.page.list.rememberScrollbarAdapter import ui.text.Localization import ui.theme.important @@ -39,6 +40,7 @@ internal fun FeelingsContent( modifier: Modifier = Modifier, items: List, onItemClicked: (id: Long) -> Unit, + onExportClicked: () -> Unit, onRefreshClick: () -> Unit, onAttachmentClicked: (attachment: String) -> Unit ) { @@ -47,6 +49,9 @@ internal fun FeelingsContent( TopAppBar(title = { Text(text = Strings.module_feelings) }, backgroundColor = MaterialTheme.colors.primarySurface, elevation = 0.dp, actions = { + IconButton(onExportClicked) { + Icon(Icons.Default.FileDownload, "Export") + } IconButton(onRefreshClick) { Icon(Icons.Default.Refresh, "Refresh") } diff --git a/src/jvmMain/kotlin/ui/page/feelings/FeelingsScreen.kt b/src/jvmMain/kotlin/ui/page/feelings/FeelingsScreen.kt index 656b14e..5d3bffb 100644 --- a/src/jvmMain/kotlin/ui/page/feelings/FeelingsScreen.kt +++ b/src/jvmMain/kotlin/ui/page/feelings/FeelingsScreen.kt @@ -1,18 +1,26 @@ package ui.page.feelings +import androidx.compose.foundation.layout.* +import androidx.compose.material.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import base.launchSafely import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ui.AppStore +import ui.Strings +import ui.page.list.Dialog import utils.md5 import java.awt.Desktop import java.io.File import java.net.URL +import javax.swing.JFileChooser +@OptIn(ExperimentalMaterialApi::class) @Composable fun FeelingsScreen(modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() @@ -20,31 +28,116 @@ fun FeelingsScreen(modifier: Modifier = Modifier) { val model = remember { FeelingsStore(coroutineScope, globalStore) } val state = model.state - FeelingsContent( - modifier = modifier, - items = state.feelings, - onItemClicked = { - // TODO - }, - onRefreshClick = model::onRefresh, - onAttachmentClicked = { attachment -> - coroutineScope.launchSafely { - withContext(Dispatchers.IO) { - if (!Desktop.isDesktopSupported() || Desktop.getDesktop().isSupported(Desktop.Action.OPEN).not()) { - return@withContext + FeelingsContent(modifier = modifier, items = state.feelings, onItemClicked = { + // TODO + }, onExportClicked = { + model.setState { copy(showingDialog = true) } + + }, onRefreshClick = model::onRefresh, onAttachmentClicked = { attachment -> + coroutineScope.launchSafely { + withContext(Dispatchers.IO) { + if (!Desktop.isDesktopSupported() || Desktop.getDesktop().isSupported(Desktop.Action.OPEN).not()) { + return@withContext + } + val destFile = File(globalStore.cacheDir, attachment.md5() + ".jpg") + if (destFile.exists() && destFile.length() > 0) { + // + } else { + destFile.createNewFile() + URL(attachment).openStream().buffered().use { + it.copyTo(destFile.outputStream()) } - val destFile = File(globalStore.cacheDir, attachment.md5() + ".jpg") - if (destFile.exists() && destFile.length() > 0) { - // - } else { - destFile.createNewFile() - URL(attachment).openStream().buffered().use { - it.copyTo(destFile.outputStream()) + } + Desktop.getDesktop().open(destFile) + } + } + }) + + if (state.showingDialog) { + // AlertDialog() + AlertDialog(onDismissRequest = { + model.setState { copy(showingDialog = false) } + }, title = { + Text(Strings.feelings_export_dialog_title) + }, text = { + Text(Strings.feelings_export_dialog_desc) + }, buttons = { + Column { + val groupByMethods = listOf( + GroupMethod(Strings.feelings_export_group_by_day, "yyyy-MM-dd"), + GroupMethod(Strings.feelings_export_group_by_month, "yyyy-MM"), + GroupMethod(Strings.feelings_export_group_by_year, "yyyy"), + ) + val expended = remember { mutableStateOf(false) } + val index = remember { + mutableStateOf(0) + } + + TextButton(modifier = Modifier.fillMaxWidth().padding(start = 24.dp), onClick = { + expended.value = true + }) { + Text(groupByMethods[index.value].name) + } + + DropdownMenu(expanded = expended.value, onDismissRequest = { + expended.value = false + }) { + groupByMethods.forEachIndexed { i, s -> + DropdownMenuItem(onClick = { + index.value = i + expended.value = false + }) { + Text(text = s.name, modifier = Modifier.padding(start = 10.dp)) } } - Desktop.getDesktop().open(destFile) + } + + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.padding(top = 16.dp, end = 24.dp).fillMaxWidth() + ) { + TextButton(onClick = { + model.setState { copy(showingDialog = false) } + }) { + Text(Strings.cancel) + } + + val dialogTitleString = Strings.common_dir_select_title + val approveButtonTextString = Strings.common_dir_select_button + val approveButtonToolTipTextString = Strings.common_dir_select_button_tooltip + + TextButton(modifier = Modifier.padding(start = 16.dp), onClick = { + model.setState { copy(showingDialog = false) } + val fileChooser = JFileChooser("/").apply { + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + this.dialogTitle = dialogTitleString + this.approveButtonText = approveButtonTextString + approveButtonToolTipText = approveButtonToolTipTextString + } + fileChooser.showOpenDialog(null /* OR null */) + val result = fileChooser.selectedFile + model.onExportDirSelected(result, groupByMethods[index.value].dateFormat) + }) { + Text(Strings.yes) + } } } - } - ) -} \ No newline at end of file + }) + } + + if (state.exportProgress >= 0.0f) { + // dialog + Dialog(title = Strings.feelings_export_progress_dialog_title, onCloseRequest = { + model.setState { copy(exportProgress = -1f) } + }, content = { + Column { + // Text(Strings.feelings_export_progress_dialog_desc.format(state.exportProgress * 100)) + LinearProgressIndicator( + progress = state.exportProgress, modifier = Modifier.padding(top = 8.dp) + ) + } + }) + } +} + +data class GroupMethod(val name: String, val dateFormat: String) \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/page/feelings/FeelingsStore.kt b/src/jvmMain/kotlin/ui/page/feelings/FeelingsStore.kt index 0b7c904..9d17f37 100644 --- a/src/jvmMain/kotlin/ui/page/feelings/FeelingsStore.kt +++ b/src/jvmMain/kotlin/ui/page/feelings/FeelingsStore.kt @@ -13,8 +13,15 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import logger import ui.AppStoreImpl +import ui.text.Localization +import utils.md5 +import java.io.File +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* import java.util.logging.Level + internal class FeelingsStore( private val coroutineScope: CoroutineScope, private val globalStore: AppStoreImpl @@ -31,7 +38,9 @@ internal class FeelingsStore( data class FeelingsState( val state: Int, - val feelings: List = emptyList() + val feelings: List = emptyList(), + val showingDialog: Boolean = false, + val exportProgress: Float = -1f ) init { @@ -51,7 +60,86 @@ internal class FeelingsStore( } } - private inline fun setState(update: FeelingsState.() -> FeelingsState) { + + // ... + + fun onExportDirSelected(dir: File, dateFormat: String) { + coroutineScope.launchSafely(Dispatchers.IO) { + setState { + copy(showingDialog = false, exportProgress = 0f) + } + + // wait for loading all feelings + while (end.not()) { + delay(1000L) + } + + // group feelings by date + val feelings = state.feelings + val feelingsByDate = feelings.groupBy { + SimpleDateFormat(dateFormat, Locale.US).format(it.time) + } + + // create attachments directory + val attachmentsDir = File(dir, "attachments") + attachmentsDir.mkdir() + + // write each group to a markdown file + val total = feelings.size + var index = 0 + feelingsByDate.mapValues { (date, feelings) -> + val file = File(dir, "$date.md") + file.createNewFile() + file.writeText("## $date\n\n") + var previousDate: String? = null + feelings.forEach { feeling -> + val currentDate = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(feeling.time) + if (currentDate != previousDate && dateFormat != "yyyy-MM-dd") { + file.appendText("### $currentDate\n\n") + previousDate = currentDate + } + file.appendText("${feeling.content}\n\n") + feeling.attachments.forEach { attachment -> + val attachmentFile = File(attachmentsDir, attachment.md5() + ".jpg") + saveAttachmentFileTo(attachment, attachmentFile) + file.appendText("![](${attachmentFile.absolutePath})\n\n") + } + + file.appendText("> ${feeling.title}\n> ${Localization.dateTimeFormatter.format(feeling.time)}\n\n
\n\n") + index++ + setState { + copy(exportProgress = index / total.toFloat()) + } + } + } + setState { copy(exportProgress = -1f) } + } + } + + private fun saveAttachmentFileTo(attachment: String, destFile: File) { + val connection = URL(attachment).openConnection() + if (destFile.exists() && destFile.length() == connection.contentLengthLong) { + return + } + destFile.createNewFile() + val copied = connection.getInputStream().buffered().use { + it.copyTo(destFile.outputStream()) + } + // maybe failed + + if (copied == 0L) { + try { + val cachedFile = File(globalStore.cacheDir, attachment.md5() + ".jpg") + if (cachedFile.exists() && cachedFile.length() >= 0) { + cachedFile.copyTo(destFile, overwrite = true) + } + } catch (ignore: Exception) { + // do nothing + } + } + } + + inline fun setState(update: FeelingsState.() -> FeelingsState) { state = state.update() } diff --git a/src/jvmMain/kotlin/ui/page/item/ShopContent.kt b/src/jvmMain/kotlin/ui/page/item/ShopContent.kt index 07a848b..45f111a 100644 --- a/src/jvmMain/kotlin/ui/page/item/ShopContent.kt +++ b/src/jvmMain/kotlin/ui/page/item/ShopContent.kt @@ -1,6 +1,7 @@ package ui.page.item import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -28,7 +29,6 @@ import logger import ui.Strings import ui.page.config.Spacer4dpH import ui.page.list.MARGIN_SCROLLBAR -import ui.page.list.VerticalScrollbar import ui.page.list.rememberScrollbarAdapter import ui.theme.subTitle3 import ui.theme.unimportantText @@ -61,7 +61,7 @@ internal fun ShopContent( ) ) { if (selectedCategory == null) { - Text(text = Strings.module_achievements) + Text(text = Strings.module_shop) Icon(Icons.Default.ArrowDropDown, "") } else { Text(text = selectedCategory.name) @@ -204,12 +204,12 @@ private fun Item( painterFor = { remember { BitmapPainter(it) } }, - contentDescription = "skill icon", + contentDescription = "item icon", modifier = Modifier.size(56.dp), onError = { Image( painter = painterResource("icons/xml/ic_pic_loading_cir.xml"), - contentDescription = "skill icon", + contentDescription = "item icon", modifier = Modifier.size(56.dp) ) } diff --git a/src/jvmMain/kotlin/ui/page/item/ShopScreen.kt b/src/jvmMain/kotlin/ui/page/item/ShopScreen.kt index 0290fdc..19df094 100644 --- a/src/jvmMain/kotlin/ui/page/item/ShopScreen.kt +++ b/src/jvmMain/kotlin/ui/page/item/ShopScreen.kt @@ -4,9 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -25,7 +23,7 @@ fun ShopScreen(modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() val globalStore = AppStore.current val model = remember { ShopStore(coroutineScope, globalStore) } - val state = model.state + val state by model.state.collectAsState() val coin = state.coin val scaffoldState = ScaffoldState.current val text = Strings.snackbar_purchase_item diff --git a/src/jvmMain/kotlin/ui/page/item/ShopStore.kt b/src/jvmMain/kotlin/ui/page/item/ShopStore.kt index e88563a..737c3ce 100644 --- a/src/jvmMain/kotlin/ui/page/item/ShopStore.kt +++ b/src/jvmMain/kotlin/ui/page/item/ShopStore.kt @@ -1,8 +1,5 @@ package ui.page.item -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import base.launchSafely import datasource.ApiServiceImpl import datasource.data.ShopCategory @@ -11,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow import logger import ui.AppStoreImpl @@ -26,14 +24,13 @@ internal class ShopStore( private val purchaseSuccessEvent = Channel() val purchaseSuccessEventFlow = purchaseSuccessEvent.receiveAsFlow() - var state: ShopState by mutableStateOf( + var state = MutableStateFlow( ShopState( 0, shopItems = emptyList(), categories = emptyList() ) ) - private set data class ShopState( val state: Int, @@ -87,7 +84,7 @@ internal class ShopStore( } private inline fun setState(update: ShopState.() -> ShopState) { - state = state.update() + state.value = state.value.update() } private fun fetchCoin() { @@ -118,7 +115,7 @@ internal class ShopStore( apiService.getShopItemCategories() }.onSuccess { it -> val categories = it - if (state.currentCategoryId == null || state.currentCategoryId !in it.map { it.id }) { + if (state.value.currentCategoryId == null || state.value.currentCategoryId !in it.map { it.id }) { setState { copy( categories = categories, @@ -127,7 +124,7 @@ internal class ShopStore( ) } } - fetchItems(state.currentCategoryId ?: return@launchSafely) + fetchItems(state.value.currentCategoryId ?: return@launchSafely) }.onFailure { logger.log(Level.SEVERE, it.stackTraceToString()) delay(2000L) diff --git a/src/jvmMain/kotlin/ui/page/list/MainContent.kt b/src/jvmMain/kotlin/ui/page/list/MainContent.kt index 45d7b98..cd7afe6 100644 --- a/src/jvmMain/kotlin/ui/page/list/MainContent.kt +++ b/src/jvmMain/kotlin/ui/page/list/MainContent.kt @@ -1,5 +1,6 @@ package ui.page.list +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -39,7 +40,8 @@ internal fun MainContent( onCategoryClicked: (Long) -> Unit, onCategoryExpended: () -> Unit, onCategoryDismissed: () -> Unit, - onRefreshClick: () -> Unit + onRefreshClick: () -> Unit, + onAddClicked: () -> Unit ) { Column(modifier) { @@ -72,6 +74,9 @@ internal fun MainContent( } } }, backgroundColor = MaterialTheme.colors.primarySurface, elevation = 0.dp, actions = { + IconButton(onAddClicked) { + Icon(Icons.Default.Add, "Add") + } IconButton(onRefreshClick) { Icon(Icons.Default.Refresh, "Refresh") } diff --git a/src/jvmMain/kotlin/ui/page/list/RootStore.kt b/src/jvmMain/kotlin/ui/page/list/TaskStore.kt similarity index 81% rename from src/jvmMain/kotlin/ui/page/list/RootStore.kt rename to src/jvmMain/kotlin/ui/page/list/TaskStore.kt index 68e1a80..d3e2a42 100644 --- a/src/jvmMain/kotlin/ui/page/list/RootStore.kt +++ b/src/jvmMain/kotlin/ui/page/list/TaskStore.kt @@ -1,20 +1,18 @@ package ui.page.list -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import base.launchSafely import datasource.ApiServiceImpl import datasource.data.TaskCategory import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow import logger import ui.AppStoreImpl import java.util.logging.Level import java.util.logging.Logger -internal class RootStore( +internal class TaskStore( private val coroutineScope: CoroutineScope, private val globalStore: AppStoreImpl ) { @@ -35,10 +33,12 @@ internal class RootStore( ApiServiceImpl.getTaskCategories() }.onSuccess { it.onSuccess { - if (state.currentCategoryId == null || state.currentCategoryId !in (it?.map { it.id } + if (state.value.currentCategoryId == null || state.value.currentCategoryId !in (it?.map { it.id } ?: emptyList())) { setState { - copy(categories = it ?: emptyList(), currentCategoryId = it?.firstOrNull()?.id) + copy(categories = it?.filter { + it.isNormalList() && it.isNotArchived() + }?.sortedBy { it.order } ?: emptyList(), currentCategoryId = it?.firstOrNull()?.id) } } fetchTasks() @@ -53,7 +53,7 @@ internal class RootStore( private fun fetchTasks() { coroutineScope.launch { withContext(Dispatchers.IO) { - val currentCategoryId = state.currentCategoryId ?: return@withContext + val currentCategoryId = state.value.currentCategoryId ?: return@withContext kotlin.runCatching { ApiServiceImpl.getTasks(currentCategoryId) }.onSuccess { @@ -74,8 +74,7 @@ internal class RootStore( } } - var state: RootState by mutableStateOf(initialState()) - private set + val state = MutableStateFlow(initialState()) fun onItemClicked(id: Long) { setState { copy(editingItemId = id) } @@ -139,7 +138,7 @@ internal class RootStore( } } - private fun RootState.updateItem(id: Long, transformer: (TodoItem) -> TodoItem): RootState = + private fun TaskState.updateItem(id: Long, transformer: (TodoItem) -> TodoItem): TaskState = copy(items = items.updateItem(id = id, transformer = transformer)) private fun List.updateItem( @@ -148,13 +147,13 @@ internal class RootStore( ): List = map { item -> if (item.id == id) transformer(item) else item } - private fun initialState(): RootState = - RootState( + private fun initialState(): TaskState = + TaskState( items = emptyList() ) - private inline fun setState(update: RootState.() -> RootState) { - state = state.update() + private inline fun setState(update: TaskState.() -> TaskState) { + state.value = state.value.update() } fun onCategoryClicked(id: Long) { @@ -180,8 +179,20 @@ internal class RootStore( fetchCategories() } + fun showAddWindow() { + setState { + copy(showAddWindow = true) + } + } + + fun hideAddWindow() { + setState { + copy(showAddWindow = false) + } + } + - data class RootState( + data class TaskState( val categories: List = emptyList(), val currentCategoryId: Long? = null, val categoryExpanded: Boolean = false, @@ -189,5 +200,6 @@ internal class RootStore( val inputText: String = "", val editingItemId: Long? = null, val snackbarText: String? = null, + val showAddWindow: Boolean = false, ) } diff --git a/src/jvmMain/kotlin/ui/page/list/TasksScreen.kt b/src/jvmMain/kotlin/ui/page/list/TasksScreen.kt index af3239d..2050315 100644 --- a/src/jvmMain/kotlin/ui/page/list/TasksScreen.kt +++ b/src/jvmMain/kotlin/ui/page/list/TasksScreen.kt @@ -1,21 +1,28 @@ package ui.page.list -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState import base.launchSafely +import kotlinx.coroutines.Dispatchers import ui.AppStore import ui.ScaffoldState import ui.Strings -import ui.page.list.RootStore.RootState +import ui.page.list.TaskStore.TaskState +import ui.page.list.add.AddTaskScreen +import java.awt.Toolkit @Composable fun TasksScreen(modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() val globalStore = AppStore.current - val model = remember { RootStore(coroutineScope, globalStore) } - val state = model.state + val model = remember { TaskStore(coroutineScope, globalStore) } + val state by model.state.collectAsState(Dispatchers.Main) val scaffoldState = ScaffoldState.current val text = Strings.snackbar_complete_task @@ -40,9 +47,24 @@ fun TasksScreen(modifier: Modifier = Modifier) { onCategoryClicked = model::onCategoryClicked, onCategoryExpended = model::onCategoryExpended, onCategoryDismissed = model::onCategoryDismissed, - onRefreshClick = model::onRefresh + onRefreshClick = model::onRefresh, + onAddClicked = { + model.showAddWindow() + } ) + if (state.showAddWindow) { + val state = mutableStateOf(state.currentCategoryId) + showAddDialog(state.value, onCloseAction = { + model.hideAddWindow() + }, onCloseAndSuccessAdded = { + model.hideAddWindow() + coroutineScope.launchSafely { + scaffoldState.snackbarHostState.showSnackbar(Strings.add_tasks_success) + } + model.onRefresh() + }) + } // Button(onClick = { // coroutineScope.launch { @@ -69,7 +91,32 @@ fun TasksScreen(modifier: Modifier = Modifier) { } } -private val RootState.editingItem: TodoItem? +@Composable +private fun showAddDialog(defaultCategoryId: Long?, onCloseAction: () -> Unit, onCloseAndSuccessAdded: () -> Unit) { + val screenSize = Toolkit.getDefaultToolkit().screenSize + val width = screenSize.width + val height = screenSize.height + val windowWidth = if (width > 1920) 1200 else 800 + val windowHeight = if (height > 1080) 900 else 600 + + Window( + onCloseRequest = { + onCloseAction() + }, + state = rememberWindowState( + position = WindowPosition(alignment = Alignment.Center), + size = DpSize(windowWidth.dp, windowHeight.dp) + ), + title = Strings.add_tasks_dialog_title + ) { + AddTaskScreen( + defaultCategoryId = defaultCategoryId ?: 0L, + addSuccess = onCloseAndSuccessAdded + ) + } +} + +private val TaskState.editingItem: TodoItem? get() = editingItemId?.let(items::firstById) private fun List.firstById(id: Long): TodoItem = diff --git a/src/jvmMain/kotlin/ui/page/list/Utils.kt b/src/jvmMain/kotlin/ui/page/list/Utils.kt index 8d505e6..ab33727 100644 --- a/src/jvmMain/kotlin/ui/page/list/Utils.kt +++ b/src/jvmMain/kotlin/ui/page/list/Utils.kt @@ -18,7 +18,7 @@ internal val MARGIN_SCROLLBAR: Dp = 8.dp internal typealias ScrollbarAdapter = androidx.compose.foundation.ScrollbarAdapter @Composable -internal fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter = +internal fun rememberScrollbarAdapter(scrollState: LazyListState): androidx.compose.foundation.v2.ScrollbarAdapter = androidx.compose.foundation.rememberScrollbarAdapter(scrollState) @Composable diff --git a/src/jvmMain/kotlin/ui/page/list/add/AddTaskScreen.kt b/src/jvmMain/kotlin/ui/page/list/add/AddTaskScreen.kt new file mode 100644 index 0000000..1e51b1e --- /dev/null +++ b/src/jvmMain/kotlin/ui/page/list/add/AddTaskScreen.kt @@ -0,0 +1,478 @@ +package ui.page.list.add + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import base.launchSafely +import datasource.data.ShopItem +import datasource.data.Skill +import datasource.data.TaskCategory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import ui.AppStore +import ui.Strings +import ui.page.config.Spacer16dpH +import ui.page.config.Spacer16dpW +import ui.page.config.Spacer24dpH +import ui.page.config.Subtitle +import ui.page.item.ShopStore +import ui.page.list.TaskStore +import ui.page.status.StatusStore +import ui.page.status.getLocalIconFilePathBySkillType +import ui.view.AsyncImage +import ui.view.loadImageBitmap + +@Composable +fun AddTaskScreen(modifier: Modifier = Modifier, defaultCategoryId: Long, addSuccess: () -> Unit) { + val coroutineScope = rememberCoroutineScope() + val globalStore = AppStore.current + val model = remember { AddTasksStore(coroutineScope, globalStore, defaultCategoryId) } + // we need skill lists + val statusStore = remember { StatusStore(coroutineScope, globalStore) } + // we need item list + val shopStore = remember { ShopStore(coroutineScope, globalStore) } + val taskStore = remember { TaskStore(coroutineScope, globalStore) } + val state by model.state.collectAsState(Dispatchers.Main) + val statusState by statusStore.state.collectAsState(Dispatchers.Main) + val shopState by shopStore.state.collectAsState(Dispatchers.Main) + val taskStatus by taskStore.state.collectAsState(Dispatchers.Main) + val scaffoldState = rememberScaffoldState() + + coroutineScope.launchSafely { + launch { + model.addSuccessEventFlow.collect { + addSuccess() + } + } + + launch { + model.addFailedEventFlow.collect { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + + Scaffold( + scaffoldState = scaffoldState + ) { + AddTaskContent( + modifier, + state, + statusState.skills, + shopState.shopItems, + taskCategories = taskStatus.categories, + onInputToDo = { + model.updateState { + copy(todo = it) + } + }, + onInputNotes = { + model.updateState { + copy(notes = it) + } + }, + onSkillSelected = model::onSkillSelected, + onInputCoin = model::onInputCoin, + onInputCoinMax = model::onInputCoinVar, + onFrequencyChanged = model::onFrequencyChanged, + onRemoteBackgroundChanged = model::onRemoteBackgroundUrlChanged, + onItemSelected = model::onItemSelected, + onItemAmountChanged = model::onItemAmountChanged, + onCategorySelected = model::onCategorySelected, + onSkillExpChanged = model::onSkillExpChanged, + onSubmitClicked = model::onSubmitClicked + ) + } +} + +@Composable +private fun AddTaskContent( + modifier: Modifier = Modifier, + state: AddTasksStore.AddTaskState, + skills: List, + shopItems: List, + taskCategories: List, + onInputToDo: (String) -> Unit, + onInputNotes: (String) -> Unit, + onInputCoin: (String) -> Unit, + onInputCoinMax: (String) -> Unit, + onSkillSelected: (id: Long, selected: Boolean) -> Unit, + onFrequencyChanged: (value: Int) -> Unit, + onRemoteBackgroundChanged: (String) -> Unit /* TODO */, + onItemSelected: (Long?) -> Unit, + onItemAmountChanged: (Int) -> Unit, + onCategorySelected: (Long) -> Unit, + onSkillExpChanged: (Int) -> Unit, + onSubmitClicked: () -> Unit +) { + val scrollState = rememberScrollState() + Surface( + modifier = modifier.fillMaxSize() + ) { + Box { + Column(modifier = Modifier.padding(24.dp).verticalScroll(scrollState)) { + BaseConfigs( + state, + onInputToDo, + onInputNotes, + onFrequencyChanged, + taskCategories, + onCategorySelected, + onSubmitClicked + ) + + RewardConfigs( + state, + onInputCoin, + onInputCoinMax, + skills, + onSkillSelected, + shopItems, + onItemSelected, + onItemAmountChanged, + onSkillExpChanged + ) + } + + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(scrollState = scrollState) + ) + } + } +} + +@Composable +private fun RewardConfigs( + state: AddTasksStore.AddTaskState, + onInputCoin: (String) -> Unit, + onInputCoinMax: (String) -> Unit, + skills: List, + onSkillSelected: (id: Long, selected: Boolean) -> Unit, + shopItems: List, + onItemSelected: (Long?) -> Unit, + onItemAmountChanged: (Int) -> Unit, + onSkillExpChanged: (Int) -> Unit +) { + Subtitle(Strings.add_tasks_title_reward) + Spacer16dpH() + // coin input + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource("icons/xml/ic_coin.xml"), + contentDescription = "coin icon", + modifier = Modifier.size(40.dp) + ) + Spacer16dpW() + TextField(modifier = Modifier.width(120.dp), value = state.coinMin.toString(), onValueChange = { + onInputCoin(it) + }, label = { Text(Strings.add_tasks_reward_coin_min) }, singleLine = true) + Text(text = "-", modifier = Modifier.padding(horizontal = 8.dp)) + TextField( + modifier = Modifier.width(120.dp), + value = (state.coinMin + state.coinMax).toString(), + onValueChange = { + onInputCoinMax(it) + }, + label = { Text(Strings.add_tasks_reward_coin_max) }, + singleLine = true + ) + } + + Spacer16dpH() + + + Spacer24dpH() + SkillSelector(skills, state, onSkillSelected, onSkillExpChanged) + + Spacer24dpH() + Subtitle(Strings.add_tasks_reward_shop_items) + + Spacer16dpH() + // shop item selection + + val (shopExpanded, setShopExpanded) = remember { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically) { + val selectedItem = shopItems.find { it.id == state.itemId } + if (selectedItem != null) { + ItemIcon(selectedItem) + Spacer16dpW() + } + Box(modifier = Modifier.weight(1f)) { + val (searchText, setSearchText) = remember { mutableStateOf("") } + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { setShopExpanded(true) } + ) { + Text(selectedItem?.name ?: Strings.common_unselected) + } + DropdownMenu(expanded = shopExpanded, onDismissRequest = { setShopExpanded(false) }) { + TextField( + value = searchText, + onValueChange = setSearchText, + label = { Text(Strings.common_search) }, + singleLine = true + ) + TextButton(modifier = Modifier.fillMaxWidth(), onClick = { + onItemSelected(null) + setShopExpanded(false) + }) { + Text(Strings.common_unselected) + } + shopItems.filter { + if (searchText.trim().isNotBlank()) { + it.name.contains(searchText.trim(), ignoreCase = true) + } else { + true + } + }.forEachIndexed { index, item -> + Row { + ItemIcon(item, 32.dp) + DropdownMenuItem(onClick = { + onItemSelected(item.id) + setShopExpanded(false) + }) { + Text(text = item.name) + } + } + + } + } + Spacer(modifier = Modifier.width(16.dp)) + } + Spacer16dpW() + OutlinedTextField( + value = state.itemAmount.toString(), + onValueChange = { onItemAmountChanged(it.toIntOrNull() ?: 0) }, + label = { Text(Strings.add_tasks_reward_shop_items_quantity) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(120.dp) + ) + + } +} + +@Composable +private fun ItemIcon(selectedItem: ShopItem, size: Dp = 56.dp) { + AsyncImage( + condition = selectedItem.icon.isNotBlank() && selectedItem.icon.endsWith("/").not(), + load = { + loadImageBitmap(selectedItem.icon) + }, + painterFor = { + remember { BitmapPainter(it) } + }, + contentDescription = "item icon", + modifier = Modifier.size(size), + onError = { + Image( + painter = painterResource("icons/xml/ic_pic_loading_cir.xml"), + contentDescription = "item icon", + modifier = Modifier.size(size) + ) + } + ) +} + +@Composable +private fun SkillSelector( + skills: List, + state: AddTasksStore.AddTaskState, + onSkillSelected: (id: Long, selected: Boolean) -> Unit, + onSkillExpChanged: (Int) -> Unit +) { + Subtitle(Strings.add_tasks_title_skills) + + Spacer16dpH() + // skill selection + LazyRow { + items(skills.size) { index -> + val skill = skills[index] + val selected = state.skills.contains(skill.id) + + if (skill.type > Skill.SkillType.USER.type && skill.icon.isBlank()) { + Image(painter = painterResource("icons/xml/${getLocalIconFilePathBySkillType(skill.type)}"), + contentDescription = "skill icon", + colorFilter = if (selected) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }), + modifier = Modifier.size(60.dp).padding(8.dp).clickable { + onSkillSelected(skill.id!!, !selected) + }) + } else { + AsyncImage(condition = skill.icon.isNotBlank(), load = { + loadImageBitmap(skill.icon) + }, painterFor = { + remember { BitmapPainter(it) } + }, contentDescription = skill.name, modifier = Modifier.size(60.dp).padding(8.dp).clickable { + onSkillSelected(skill.id!!, !selected) + }, colorFilter = if (selected) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }), onError = { + Image( + painter = painterResource("icons/xml/ic_pic_loading_cir.xml"), + contentDescription = "skill icon", + modifier = Modifier.size(60.dp), + colorFilter = if (selected) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }), + ) + }) + } + } + } + + if (state.skills.isNotEmpty()) { + Spacer16dpH() + TextField( + value = state.exp.toString(), + onValueChange = { onSkillExpChanged(it.toIntOrNull() ?: 0) }, + label = { Text(Strings.add_tasks_exp) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun BaseConfigs( + state: AddTasksStore.AddTaskState, + onInputToDo: (String) -> Unit, + onInputNotes: (String) -> Unit, + onFrequencyChanged: (value: Int) -> Unit, + taskCategories: List, + onCategorySelected: (Long) -> Unit, + onSubmitClicked: () -> Unit +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Subtitle(Strings.add_tasks_title_base, modifier = Modifier.weight(1f)) + IconButton(onClick = onSubmitClicked) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null + ) + } + } + + // category + Box { + val (expanded, setExpanded) = remember { mutableStateOf(false) } + OutlinedButton(onClick = { + setExpanded(true) + }) { + Text(text = taskCategories.find { it.id == state.categoryId }?.name ?: Strings.common_unknown) + Icon(Icons.Default.ArrowDropDown, "") + } + DropdownMenu(expanded = expanded, onDismissRequest = { setExpanded(false) }) { + taskCategories.filter { + // only the keep the normal category(skipping the ALL list and other smart lists) + (it.id ?: 0) > 0L && it.type == 0 + }.forEachIndexed { index, item -> + DropdownMenuItem(onClick = { + setExpanded(false) + onCategorySelected(item.id!!) + }) { + Text(text = item.name) + } + } + } + } + + + Spacer16dpH() + // text input + TextField(modifier = Modifier.fillMaxWidth(), value = state.todo, onValueChange = { + onInputToDo(it) + }, label = { Text(Strings.add_tasks_todo) }) + Spacer16dpH() + TextField(modifier = Modifier.fillMaxWidth(), value = state.notes, onValueChange = { + onInputNotes(it) + }, label = { Text(Strings.add_tasks_notes) }) + + Spacer16dpH() + + // frequency dropdown + val frequencyOptions = remember { + listOf( + Strings.add_tasks_frequency_none, + Strings.add_tasks_frequency_daily, + Strings.add_tasks_frequency_weekly, + Strings.add_tasks_frequency_monthly, + Strings.add_tasks_frequency_yearly, + Strings.add_tasks_frequency_unlimited + ) + } + val (expanded, setExpanded) = remember { mutableStateOf(false) } + + + + Box { + OutlinedButton(onClick = { + setExpanded(true) + }) { + Text( + text = Strings.add_tasks_frequency_desc.format(frequencyOptions[frequencyToIndex(state.frequency)]) + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { setExpanded(false) }) { + frequencyOptions.forEachIndexed { index, option -> + DropdownMenuItem(onClick = { + setExpanded(false) + onFrequencyChanged( + when (index) { + 0 -> 0 + 1 -> 1 + 2 -> 7 + 3 -> -4 + 4 -> -5 + 5 -> -1 + else -> 0 + } + ) + }) { + Text(text = option) + } + } + } + } + + Spacer24dpH() +} + +private fun frequencyToIndex(frequency: Int): Int { + return when (frequency) { + 0 -> 0 + 1 -> 1 + 7 -> 2 + -4 -> 3 + -5 -> 4 + -1 -> 5 + else -> 0 + } +} + + +@Preview +@Composable +fun AddTaskScreenPreview() { +// AddTaskContent(state = AddTasksStore.AddTaskState(0L, "Test"), onInputToDo = { +// +// }) +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/page/list/add/AddTasksStore.kt b/src/jvmMain/kotlin/ui/page/list/add/AddTasksStore.kt new file mode 100644 index 0000000..eab83b9 --- /dev/null +++ b/src/jvmMain/kotlin/ui/page/list/add/AddTasksStore.kt @@ -0,0 +1,142 @@ +package ui.page.list.add + +import datasource.ApiService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import logger +import ui.AppStoreImpl +import ui.Strings +import java.util.logging.Level + +internal class AddTasksStore( + private val coroutineScope: CoroutineScope, private val globalStore: AppStoreImpl, defaultCategoryId: Long +) { + + init { + logger.log(Level.INFO, "default category id: $defaultCategoryId") + } + + var state = MutableStateFlow(AddTaskState(defaultCategoryId, "")) + private set + + private val apiService = ApiService.instance + + private val addSuccessEvent = Channel() + val addSuccessEventFlow = addSuccessEvent.receiveAsFlow() + + private val addFailedEvent = Channel() + val addFailedEventFlow = addFailedEvent.receiveAsFlow() + + @Serializable + data class AddTaskState( + val categoryId: Long, + val todo: String, + val notes: String = "", + val exp: Int = 0, + val coinMin: Long = 0L, + val coinMax: Long = 0L, + val skills: List = emptyList(), + val frequency: Int = 0, + val itemId: Long = 0L, + val itemAmount: Int = 0, + val remoteBackgroundURL: String = "" + // deadline: Long, we need to impl a date time picker first + ) + + fun onSkillSelected(id: Long, selected: Boolean) { + updateState { + if (selected) { + if (skills.size < 3) { + copy(skills = skills + id) + } else { + this + } + } else { + copy(skills = skills - id) + } + } + } + + fun onInputCoin(coin: String) { + updateState { + copy(coinMin = coin.toLongOrNull() ?: 0L) + } + } + + fun onInputCoinVar(max: String) { + updateState { + copy(coinMax = max.toLongOrNull() ?: 0L) + } + } + + fun onFrequencyChanged(frequency: Int) { + updateState { + copy(frequency = frequency) + } + } + + fun onRemoteBackgroundUrlChanged(url: String) { + updateState { + copy(remoteBackgroundURL = url) + } + } + + fun updateState(block: AddTaskState.() -> AddTaskState) { + state.value = state.value.block() + } + + fun onItemSelected(id: Long?) { + updateState { + copy(itemId = id ?: 0L) + } + } + + fun onItemAmountChanged(amount: Int) { + updateState { + copy(itemAmount = amount) + } + } + + fun onCategorySelected(id: Long) { + updateState { + copy(categoryId = id) + } + } + + fun onSkillExpChanged(exp: Int) { + updateState { + copy(exp = exp) + } + } + + fun onSubmitClicked() { + coroutineScope.launch { + val state = state.value + val todo = state.todo + val notes = state.notes + val coin = state.coinMin + val coinVar = (state.coinMax - state.coinMin).takeIf { it > 0 } ?: 0 + val exp = state.exp + val skills = state.skills.joinToString("&skills=") + val categoryId = state.categoryId + val itemAmount = state.itemAmount + val itemId = state.itemId + val remoteBackgroundURL = state.remoteBackgroundURL + + val apiText = + "lifeup://api/add_task?todo=$todo¬es=$notes&coin=$coin&coin_var=$coinVar&exp=$exp&skills=$skills&category=$categoryId&item_amount=$itemAmount&item_id=$itemId&remote_background_url=$remoteBackgroundURL&frequency=${state.frequency}" + + try { + apiService.rawCall(apiText) + addSuccessEvent.send(Unit) + } catch (e: Exception) { + e.printStackTrace() + addFailedEvent.send(Strings.add_tasks_failed) + } + } + } +} diff --git a/src/jvmMain/kotlin/ui/page/status/StatusContent.kt b/src/jvmMain/kotlin/ui/page/status/StatusContent.kt index df6059c..9adce20 100644 --- a/src/jvmMain/kotlin/ui/page/status/StatusContent.kt +++ b/src/jvmMain/kotlin/ui/page/status/StatusContent.kt @@ -1,6 +1,7 @@ package ui.page.status import androidx.compose.foundation.Image +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -26,7 +27,6 @@ import logger import ui.Strings import ui.page.config.Spacer4dpH import ui.page.list.MARGIN_SCROLLBAR -import ui.page.list.VerticalScrollbar import ui.page.list.rememberScrollbarAdapter import ui.theme.unimportantText import ui.view.AsyncImage @@ -234,7 +234,7 @@ private fun Item( } } -private fun getLocalIconFilePathBySkillType(type: Int): String { +fun getLocalIconFilePathBySkillType(type: Int): String { return when (type) { Skill.SkillType.DEFAULT_CHARM.type -> "ic_attr_charm.xml" Skill.SkillType.DEFAULT_CREATIVE.type -> "ic_attr_creative.xml" diff --git a/src/jvmMain/kotlin/ui/page/status/StatusScreen.kt b/src/jvmMain/kotlin/ui/page/status/StatusScreen.kt index e705d85..439af6b 100644 --- a/src/jvmMain/kotlin/ui/page/status/StatusScreen.kt +++ b/src/jvmMain/kotlin/ui/page/status/StatusScreen.kt @@ -1,9 +1,11 @@ package ui.page.status import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers import ui.AppStore @Composable @@ -11,7 +13,8 @@ fun StatusScreen(modifier: Modifier = Modifier) { val coroutineScope = rememberCoroutineScope() val globalStore = AppStore.current val model = remember { StatusStore(coroutineScope, globalStore) } - val state = model.state + val state = model.state.collectAsState(Dispatchers.Main).value + StatusContent( modifier = modifier, diff --git a/src/jvmMain/kotlin/ui/page/status/StatusStore.kt b/src/jvmMain/kotlin/ui/page/status/StatusStore.kt index 13a1553..8744636 100644 --- a/src/jvmMain/kotlin/ui/page/status/StatusStore.kt +++ b/src/jvmMain/kotlin/ui/page/status/StatusStore.kt @@ -1,8 +1,5 @@ package ui.page.status -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import base.launchSafely import datasource.ApiServiceImpl import datasource.data.Skill @@ -10,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import logger import ui.AppStoreImpl import java.util.logging.Level @@ -20,8 +18,10 @@ internal class StatusStore( ) { private val apiService = ApiServiceImpl - var state: StatusState by mutableStateOf(StatusState(0, skills = emptyList(), coin = globalStore.coinValue ?: 0)) - private set +// var state: StatusState by mutableStateOf(StatusState(0, skills = emptyList(), coin = globalStore.coinValue ?: 0)) +// private set + + val state = MutableStateFlow(StatusState(0, skills = emptyList(), coin = globalStore.coinValue ?: 0)) data class StatusState( val state: Int, @@ -38,7 +38,7 @@ internal class StatusStore( } private inline fun setState(update: StatusState.() -> StatusState) { - state = state.update() + state.value = state.value.update() } data class RequestResult( diff --git a/src/jvmMain/kotlin/ui/text/Localization.kt b/src/jvmMain/kotlin/ui/text/Localization.kt index cb6d63c..1ac5fac 100644 --- a/src/jvmMain/kotlin/ui/text/Localization.kt +++ b/src/jvmMain/kotlin/ui/text/Localization.kt @@ -3,6 +3,7 @@ package ui.text import logger import ui.text.i18n.EnStrings import ui.text.i18n.ZhCNStrings +import ui.text.i18n.ZhHantStrings import java.text.SimpleDateFormat import java.util.* import java.util.logging.Level @@ -10,13 +11,17 @@ import java.util.logging.Level object Localization { private fun getAllLanguage(): List { - return listOf(EnStrings(), ZhCNStrings()) + return listOf(EnStrings(), ZhCNStrings(), ZhHantStrings()) } fun get(): StringText { val preferLanguage = Locale.getDefault().language - logger.log(Level.INFO, "prefer language: $preferLanguage") - return getAllLanguage().firstOrNull { it.language == preferLanguage } ?: EnStrings() + val preferCountry = Locale.getDefault().country.uppercase() + logger.log(Level.INFO, "prefer language: $preferLanguage, prefer country: $preferCountry") + + return getAllLanguage().firstOrNull { it.language == preferLanguage && it.perfer_country.contains(preferCountry) } + ?: getAllLanguage().firstOrNull { it.language == preferLanguage } + ?: EnStrings() } val dateTimeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") diff --git a/src/jvmMain/kotlin/ui/text/StringText.kt b/src/jvmMain/kotlin/ui/text/StringText.kt index a761e73..35d2087 100644 --- a/src/jvmMain/kotlin/ui/text/StringText.kt +++ b/src/jvmMain/kotlin/ui/text/StringText.kt @@ -4,6 +4,8 @@ interface StringText { val language: String + val perfer_country: Array + val appName: String val module_tasks: String @@ -69,4 +71,85 @@ interface StringText { val license: String val license_desc: String + + val auto_detect: String + + val auto_detect_dialog_title: String + + val auto_detect_dialog_empty_desc: String + + val feelings_export_group_by_day: String + + val feelings_export_group_by_month: String + + val feelings_export_group_by_year: String + + val feelings_export_dialog_title: String + + val feelings_export_dialog_desc: String + + val feelings_export_progress_dialog_title: String + + val feelings_export_progress_dialog_desc: String + + val common_dir_select_title: String + + val common_dir_select_button: String + + val common_dir_select_button_tooltip: String + + val about_update_button: String + + val about_check_updates_button: String + + val about_message_no_update: String + + val add_tasks_title_reward: String + + val add_tasks_reward_coin_min: String + + val add_tasks_reward_coin_max: String + + val add_tasks_reward_shop_items: String + + val common_unselected: String + + val common_search: String + + val common_unknown: String + + val add_tasks_reward_shop_items_quantity: String + + val add_tasks_title_skills: String + + val add_tasks_exp: String + + val add_tasks_title_base: String + + val add_tasks_todo: String + + val add_tasks_notes: String + + val add_tasks_frequency_none: String + + val add_tasks_frequency_daily: String + + + val add_tasks_frequency_weekly: String + + + val add_tasks_frequency_monthly: String + + + val add_tasks_frequency_yearly: String + + val add_tasks_frequency_unlimited: String + + val add_tasks_frequency_desc: String + + val add_tasks_success: String + + val add_tasks_failed: String + + val add_tasks_dialog_title: String } diff --git a/src/jvmMain/kotlin/ui/text/i18n/EnStrings.kt b/src/jvmMain/kotlin/ui/text/i18n/EnStrings.kt index 23e3333..86e8b69 100644 --- a/src/jvmMain/kotlin/ui/text/i18n/EnStrings.kt +++ b/src/jvmMain/kotlin/ui/text/i18n/EnStrings.kt @@ -6,6 +6,10 @@ class EnStrings : StringText { override val language: String get() = "en" + + override val perfer_country: Array + get() = arrayOf("") + override val appName: String get() = "LifeUp desktop" override val cancel: String @@ -101,4 +105,105 @@ class EnStrings : StringText { "icon designed by 下车君\n\n" + "using Honor HONOR Sans Font\n\n" + "powered by Kotlin, Jetpack Compose and Compose Desktop\n" + + override val auto_detect: String + get() = "Auto Detect" + + override val auto_detect_dialog_title: String + get() = "Auto Detect" + + override val auto_detect_dialog_empty_desc: String + get() = "The LifeUp Cloud service was not detected.\n" + + "\n" + + "Please make sure that your \"LifeUp Cloud\" is in the same local area network, and the service has been activated.\n" + + "\n" + + "You can try restarting the service, or try again later." + + override val feelings_export_group_by_day: String + get() = "Group by day" + + override val feelings_export_group_by_month: String + get() = "Group by month" + + override val feelings_export_group_by_year: String + get() = "Group by year" + + override val feelings_export_dialog_title: String + get() = "Export to Markdown format?" + override val feelings_export_dialog_desc: String + get() = "Export all feelings and pictures to markdown format.\n" + + "\n" + + "Through the markdown format, you can easily render your feelings into different document formats. For details, please refer to the online information.\n" + + "\n" + + "Please select the export destination, try not to select the system-related folder of the system disk, because LifeUp may have insufficient permissions." + override val feelings_export_progress_dialog_title: String + get() = "Exporting..." + override val feelings_export_progress_dialog_desc: String + get() = "Progress: %s%" + + override val common_dir_select_title: String + get() = "Select a folder" + + override val common_dir_select_button: String + get() = "Select" + + override val common_dir_select_button_tooltip: String + get() = "Select current directory as save destination" + + override val about_update_button: String + get() = "Update" + override val about_check_updates_button: String + get() = "Check for Updates" + override val about_message_no_update: String + get() = "No update available" + + override val add_tasks_title_reward: String + get() = "Reward" + override val add_tasks_reward_coin_min: String + get() = "Min" + override val add_tasks_reward_coin_max: String + get() = "Max" + override val add_tasks_reward_shop_items: String + get() = "Shop Items" + override val common_unselected: String + get() = "Unselected" + override val common_search: String + get() = "Search" + override val common_unknown: String + get() = "Unknown" + override val add_tasks_reward_shop_items_quantity: String + get() = "Quantity" + override val add_tasks_title_skills: String + get() = "Attributes" + override val add_tasks_exp: String + get() = "Exp" + override val add_tasks_title_base: String + get() = "Base" + override val add_tasks_todo: String + get() = "ToDo" + override val add_tasks_notes: String + get() = "Notes" + override val add_tasks_frequency_none: String + get() = "None" + override val add_tasks_frequency_daily: String + get() = "Daily" + override val add_tasks_frequency_weekly: String + get() = "Weekly" + override val add_tasks_frequency_monthly: String + get() = "Monthly" + override val add_tasks_frequency_yearly: String + get() = "Yearly" + override val add_tasks_frequency_unlimited: String + get() = "Unlimited" + override val add_tasks_frequency_desc: String + get() = "Frequency: %s" + + override val add_tasks_success: String + get() = "Added success" + + override val add_tasks_failed: String + get() = "Failed to add task, please try again later and check your LifeUp Cloud state." + + override val add_tasks_dialog_title: String + get() = "Add Tasks" } \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/text/i18n/ZhCNStrings.kt b/src/jvmMain/kotlin/ui/text/i18n/ZhCNStrings.kt index ed5284f..22e62b7 100644 --- a/src/jvmMain/kotlin/ui/text/i18n/ZhCNStrings.kt +++ b/src/jvmMain/kotlin/ui/text/i18n/ZhCNStrings.kt @@ -6,6 +6,9 @@ class ZhCNStrings : StringText { override val language: String get() = "zh" + + override val perfer_country: Array + get() = arrayOf("CN", "") override val appName: String get() = "LifeUp 桌面版" @@ -102,4 +105,109 @@ class ZhCNStrings : StringText { "icon designed by 下车君\n\n" + "using Honor HONOR Sans Font\n\n" + "powered by Kotlin, Jetpack Compose and Compose Desktop\n" + + override val auto_detect: String + get() = "自动检测" + + override val auto_detect_dialog_title: String + get() = "自动检测" + + override val auto_detect_dialog_empty_desc: String + get() = "未检测到《云人升》服务。\n" + + "\n" + + "请确认你的《云人升》处于同一局域网,并且启动了服务。\n" + + "\n" + + "你可以尝试重新开启服务,或稍后重试。" + + override val feelings_export_group_by_day: String + get() = "按天分组" + + override val feelings_export_group_by_month: String + get() = "按月分组" + + override val feelings_export_group_by_year: String + get() = "按年分组" + + override val feelings_export_dialog_title: String + get() = "导出为 Markdown 格式?" + + override val feelings_export_dialog_desc: String + get() = "将所有感想的内容和图片导出为 markdown 格式。\n" + + "\n" + + "通过 markdown 格式,你可以轻松地将感想渲染成不同样式的文档格式,详情可以查阅网上资料。\n" + + "\n" + + "请选择导出的目的地,尽量不要选择系统盘的系统相关文件夹,因为LifeUp可能权限不足。" + + override val feelings_export_progress_dialog_title: String + get() = "导出中..." + + override val feelings_export_progress_dialog_desc: String + get() = "进度: %d%" + + override val common_dir_select_title: String + get() = "选择文件夹" + + override val common_dir_select_button: String + get() = "选定" + + override val common_dir_select_button_tooltip: String + get() = "选择当前文件夹作为保存处" + + override val about_update_button: String + get() = "更新" + override val about_check_updates_button: String + get() = "检测更新" + override val about_message_no_update: String + get() = "你已经拥有最新版啦~" + + + override val add_tasks_title_reward: String + get() = "奖励" + override val add_tasks_reward_coin_min: String + get() = "最小值" + override val add_tasks_reward_coin_max: String + get() = "最大值" + override val add_tasks_reward_shop_items: String + get() = "物品" + override val common_unselected: String + get() = "未选择" + override val common_search: String + get() = "搜索" + override val common_unknown: String + get() = "未知" + override val add_tasks_reward_shop_items_quantity: String + get() = "数量" + override val add_tasks_title_skills: String + get() = "属性" + override val add_tasks_exp: String + get() = "经验值" + override val add_tasks_title_base: String + get() = "基础" + override val add_tasks_todo: String + get() = "待办" + override val add_tasks_notes: String + get() = "备注" + override val add_tasks_frequency_none: String + get() = "无" + override val add_tasks_frequency_daily: String + get() = "每日" + override val add_tasks_frequency_weekly: String + get() = "每周" + override val add_tasks_frequency_monthly: String + get() = "每月" + override val add_tasks_frequency_yearly: String + get() = "每年" + override val add_tasks_frequency_unlimited: String + get() = "无限" + override val add_tasks_frequency_desc: String + get() = "重复: %s" + + override val add_tasks_success: String + get() = "添加成功" + + override val add_tasks_failed: String + get() = "添加任务失败,请稍后再试并检查你的《云人升》状态。" + + override val add_tasks_dialog_title: String + get() = "添加任务" } \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/text/i18n/ZhHantStrings.kt b/src/jvmMain/kotlin/ui/text/i18n/ZhHantStrings.kt new file mode 100644 index 0000000..282c12b --- /dev/null +++ b/src/jvmMain/kotlin/ui/text/i18n/ZhHantStrings.kt @@ -0,0 +1,214 @@ +package ui.text.i18n + +import ui.text.StringText + +class ZhHantStrings : StringText { + + override val language: String + get() = "zh" + + override val perfer_country: Array + get() = arrayOf("TW", "HK", "MO") + + override val appName: String + get() = "LifeUp 桌面版" + + override val module_tasks: String + get() = "委託" + override val module_status: String + get() = "狀態" + override val module_shop: String + get() = "商店" + + override val module_achievements: String + get() = "成就" + override val module_achievements_short: String + get() = "成就" + + override val module_settings: String + get() = "設定" + + override val module_feelings: String + get() = "感想" + + override val cancel: String + get() = "取消" + override val yes: String + get() = "確定" + + override val base_config: String + get() = "基礎" + + override val base_config_desc: String + get() = "你需要輸入雲人升運行的 IP 地址以及端口,以便《人升-桌面版》可以讀取你的手機上的《人升》數據。IP 地址形如:192.168.1.1,你可以在《雲人升》app 上看到手機的 IP 地址和端口。端口一般為 13276。" + + override val base_version: String + get() = "版本" + + override val version_desc: String + get() = "桌面版本: %s\n\n適配雲人升版本: %s\n\n適配人升安卓版本: %s" + + override val ip_address: String + get() = "Ip 地址" + + override val server_port: String + get() = "服務端口" + + override val not_connected: String + get() = "未連接到手機服務" + + override val connected: String + get() = "成功連接!\n我們發現了你擁有 %d 個金幣!" + + override val test_connection: String + get() = "測試連接" + + override val status: String + get() = "狀態" + + override val level_display: String + get() = "Level %d" + + override val to_next_exp_display: String + get() = "下一級還需: %d" + + override val total_exp_display: String + get() = "總計獲得: %d" + + override val coin: String + get() = "金幣" + + override val oops_wip: String + get() = "糟糕,小開發還沒做這個功能!\n(歡迎前往 Github 貢獻代碼)" + + override val btn_purchase: String + get() = "購買" + + override val purchase_desc: String + get() = "從桌面版購買了 %s" + + override val item_own_number: String + get() = "擁有: %d" + + override val item_price: String + get() = "價格: %d" + + override val snackbar_complete_task: String + get() = "完成了任務!\n桌面版暫時不支持撤銷,如果需要請去手機上操作。" + + override val snackbar_purchase_item: String + get() = "購買成功!" + + override val license: String + get() = "許可" + + override val license_desc: String + get() = "developed by Kei (LifeUp Teams)\n\n" + + "icon designed by 下車君\n\n" + + "using Honor HONOR Sans Font\n\n" + + "powered by Kotlin, Jetpack Compose and Compose Desktop\n" + + override val auto_detect: String + get() = "自動檢測" + + override val auto_detect_dialog_title: String + get() = "自動檢測" + + override val auto_detect_dialog_empty_desc: String + get() = "未檢測到《雲人升》服務。\n" + + "\n" + + "請確認你的《雲人升》處於同一局域網,並且啟動了服務。\n" + + "\n" + + "你可以嘗試重新開啟服務,或稍後重試。" + + override val feelings_export_group_by_day: String + get() = "按天分組" + + override val feelings_export_group_by_month: String + get() = "按月分組" + + override val feelings_export_group_by_year: String + get() = "按年分組" + + override val feelings_export_dialog_title: String + get() = "導出為 Markdown 格式?" + + override val feelings_export_dialog_desc: String + get() = "將所有感想的內容和圖片導出為 markdown 格式。\n" + + "\n" + + "通過 markdown 格式,你可以輕鬆地將感想渲染成不同樣式的文檔格式,詳情可以查閱網上資料。\n" + + "\n" + + "請選擇導出的目的地,盡量不要選擇系統盤的系統相關文件夾,因為LifeUp可能權限不足。" + + override val feelings_export_progress_dialog_title: String + get() = "導出中..." + + override val feelings_export_progress_dialog_desc: String + get() = "進度: %d%" + + override val common_dir_select_title: String + get() = "選擇文件夾" + + override val common_dir_select_button: String + get() = "選定" + + override val common_dir_select_button_tooltip: String + get() = "選擇當前文件夾作為保存處" + + override val about_update_button: String + get() = "更新" + override val about_check_updates_button: String + get() = "檢測更新" + override val about_message_no_update: String + get() = "你已經擁有最新版啦~" + + override val add_tasks_title_reward: String + get() = "獎勵" + override val add_tasks_reward_coin_min: String + get() = "最小值" + override val add_tasks_reward_coin_max: String + get() = "最大值" + override val add_tasks_reward_shop_items: String + get() = "物品" + override val common_unselected: String + get() = "未選擇" + override val common_search: String + get() = "搜尋" + override val common_unknown: String + get() = "未知" + override val add_tasks_reward_shop_items_quantity: String + get() = "數量" + override val add_tasks_title_skills: String + get() = "屬性" + override val add_tasks_exp: String + get() = "經驗值" + override val add_tasks_title_base: String + get() = "基礎" + override val add_tasks_todo: String + get() = "待辦" + override val add_tasks_notes: String + get() = "備註" + override val add_tasks_frequency_none: String + get() = "無" + override val add_tasks_frequency_daily: String + get() = "每日" + override val add_tasks_frequency_weekly: String + get() = "每週" + override val add_tasks_frequency_monthly: String + get() = "每月" + override val add_tasks_frequency_yearly: String + get() = "每年" + override val add_tasks_frequency_unlimited: String + get() = "無限" + override val add_tasks_frequency_desc: String + get() = "重複: %s" + + override val add_tasks_success: String + get() = "添加成功" + + override val add_tasks_failed: String + get() = "添加任務失敗,請稍後再試並檢查你的《雲人升》狀態。" + + override val add_tasks_dialog_title: String + get() = "添加任務" +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/ui/view/AsyncImage.kt b/src/jvmMain/kotlin/ui/view/AsyncImage.kt index e07cd13..603453c 100644 --- a/src/jvmMain/kotlin/ui/view/AsyncImage.kt +++ b/src/jvmMain/kotlin/ui/view/AsyncImage.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -27,7 +28,8 @@ fun AsyncImage( contentDescription: String, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, - onError: @Composable () -> Unit + onError: @Composable () -> Unit, + colorFilter: ColorFilter? = null ) { if (!condition) { onError() @@ -51,7 +53,8 @@ fun AsyncImage( painter = painterFor(image!!), contentDescription = contentDescription, contentScale = contentScale, - modifier = modifier + modifier = modifier, + colorFilter = colorFilter ) } else { onError()