diff --git a/.run/vvm.desktop.run.run.xml b/.run/wwm.desktop.run.run.xml similarity index 86% rename from .run/vvm.desktop.run.run.xml rename to .run/wwm.desktop.run.run.xml index 3178ba5..a1a40e4 100644 --- a/.run/vvm.desktop.run.run.xml +++ b/.run/wwm.desktop.run.run.xml @@ -1,6 +1,11 @@ + - + \ No newline at end of file diff --git a/wwm/core/data/build.gradle.kts b/wwm/core/data/build.gradle.kts index c14dbf7..b53693c 100644 --- a/wwm/core/data/build.gradle.kts +++ b/wwm/core/data/build.gradle.kts @@ -9,6 +9,9 @@ kotlin { implementation(project(":wwm-core-foundation")) implementation(project(":wwm-core-resources")) } + dependencies { + implementation(kotlinx.serialization.json) + } } } } diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/AchievementsRepository.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/AchievementsRepository.kt index 47f5cd7..7c46793 100644 --- a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/AchievementsRepository.kt +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/AchievementsRepository.kt @@ -13,4 +13,6 @@ interface AchievementsRepository { suspend fun reloadAchievementCategories() suspend fun reloadAchievementGroups() suspend fun reloadMultiText(locale: WwLocale) + suspend fun markAchievement(achievementId: Int) + suspend fun unmarkAchievement(achievementId: Int) } diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmAchievements.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmAchievements.kt index 83a71eb..d4ffe71 100644 --- a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmAchievements.kt +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmAchievements.kt @@ -15,6 +15,7 @@ data class WwmAchievements( val achievementGroups: WwAchievementGroups = emptyList(), val multiText: WwMultiText = emptyList(), val locale: WwLocale = WwLocale.ZH_HANS, + val markedAchievementIds: WwmMarkedAchievementIds = emptySet(), ) { companion object { val Empty: WwmAchievements = WwmAchievements() diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmDataComponent.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmDataComponent.kt index a012133..bd6bd5c 100644 --- a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmDataComponent.kt +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmDataComponent.kt @@ -4,8 +4,12 @@ package dev.omico.wwm.data import dev.omico.wwm.data.internal.AchievementsRepositoryImpl +import dev.omico.wwm.data.internal.MarkedAchievementsDataStore interface WwmDataComponent { val achievementsRepository: AchievementsRepository - fun provideAchievementsRepository(): AchievementsRepository = AchievementsRepositoryImpl() + fun provideAchievementsRepository(): AchievementsRepository = + AchievementsRepositoryImpl( + markedAchievementsDataStore = MarkedAchievementsDataStore(), + ) } diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmMarkedAchievementIds.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmMarkedAchievementIds.kt new file mode 100644 index 0000000..34ccaf6 --- /dev/null +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/WwmMarkedAchievementIds.kt @@ -0,0 +1,6 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data + +typealias WwmMarkedAchievementIds = Set diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/AchievementsRepositoryImpl.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/AchievementsRepositoryImpl.kt index b71c95e..6b07d85 100644 --- a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/AchievementsRepositoryImpl.kt +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/AchievementsRepositoryImpl.kt @@ -5,6 +5,7 @@ package dev.omico.wwm.data.internal import dev.omico.wwm.data.AchievementsRepository import dev.omico.wwm.data.WwmAchievements +import dev.omico.wwm.data.WwmMarkedAchievementIds import dev.omico.wwm.resources.WwmResources import dev.omico.wwm.resources.model.game.WwAchievementCategories import dev.omico.wwm.resources.model.game.WwAchievementGroups @@ -14,14 +15,20 @@ import dev.omico.wwm.resources.model.game.WwMultiText import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock -internal class AchievementsRepositoryImpl : AchievementsRepository { +internal class AchievementsRepositoryImpl( + private val markedAchievementsDataStore: MarkedAchievementsDataStore, +) : AchievementsRepository { private val wwAchievements: MutableStateFlow = MutableStateFlow(emptyList()) private val wwAchievementCategories: MutableStateFlow = MutableStateFlow(emptyList()) private val wwAchievementGroups: MutableStateFlow = MutableStateFlow(emptyList()) private val wwMultiText: MutableStateFlow = MutableStateFlow(emptyList()) private val wwLocale: MutableStateFlow = MutableStateFlow(WwLocale.ZH_HANS) + private val markedAchievementIds: MutableStateFlow = MutableStateFlow(emptySet()) + @Suppress("UNCHECKED_CAST") override val achievements: Flow = combine( wwAchievements, @@ -29,8 +36,17 @@ internal class AchievementsRepositoryImpl : AchievementsRepository { wwAchievementGroups, wwMultiText, wwLocale, - ::WwmAchievements, - ) + markedAchievementIds, + ) { arguments -> + WwmAchievements( + achievements = arguments[0] as WwAchievements, + achievementCategories = arguments[1] as WwAchievementCategories, + achievementGroups = arguments[2] as WwAchievementGroups, + multiText = arguments[3] as WwMultiText, + locale = arguments[4] as WwLocale, + markedAchievementIds = arguments[5] as WwmMarkedAchievementIds, + ) + } override suspend fun load() { reloadAchievements() @@ -38,7 +54,10 @@ internal class AchievementsRepositoryImpl : AchievementsRepository { reloadAchievementGroups() } - override suspend fun reloadAchievements(): Unit = wwAchievements.emit(WwmResources.loadAchievements()) + override suspend fun reloadAchievements() { + wwAchievements.emit(WwmResources.loadAchievements()) + markedAchievementIds.emit(markedAchievementsDataStore.load()) + } override suspend fun reloadAchievementCategories(): Unit = wwAchievementCategories.emit(WwmResources.loadAchievementCategories()) @@ -50,4 +69,18 @@ internal class AchievementsRepositoryImpl : AchievementsRepository { wwMultiText.emit(WwmResources.loadMultiText(locale)) wwLocale.emit(locale) } + + override suspend fun markAchievement(achievementId: Int): Unit = + saveMarkedAchievementIds(markedAchievementIds.value + achievementId) + + override suspend fun unmarkAchievement(achievementId: Int): Unit = + saveMarkedAchievementIds(markedAchievementIds.value - achievementId) + + private val markedAchievementIdsMutex: Mutex = Mutex() + + private suspend fun saveMarkedAchievementIds(ids: WwmMarkedAchievementIds): Unit = + markedAchievementIdsMutex.withLock { + markedAchievementsDataStore.save(ids) + markedAchievementIds.emit(ids) + } } diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/Json.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/Json.kt new file mode 100644 index 0000000..688454f --- /dev/null +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/Json.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data.internal + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@OptIn(ExperimentalSerializationApi::class) +internal val json: Json = + Json { + prettyPrint = true + prettyPrintIndent = " " + } + +internal inline fun T.toJson(): String = json.encodeToString(this) + +internal inline fun String.fromJson(): T = json.decodeFromString(this) diff --git a/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.kt b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.kt new file mode 100644 index 0000000..84cd104 --- /dev/null +++ b/wwm/core/data/src/commonMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data.internal + +import dev.omico.wwm.data.WwmMarkedAchievementIds + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") // TODO KT-61573 +internal expect class MarkedAchievementsDataStore() { + suspend fun load(): WwmMarkedAchievementIds + suspend fun save(ids: WwmMarkedAchievementIds) +} diff --git a/wwm/core/data/src/desktopMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.desktop.kt b/wwm/core/data/src/desktopMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.desktop.kt new file mode 100644 index 0000000..99cb0ad --- /dev/null +++ b/wwm/core/data/src/desktopMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.desktop.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data.internal + +import dev.omico.wwm.data.WwmMarkedAchievementIds +import dev.omico.wwm.foundation.wwmMarkedAchievementsFile +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") // TODO KT-61573 +internal actual class MarkedAchievementsDataStore { + actual suspend fun load(): WwmMarkedAchievementIds = + wwmMarkedAchievementsFile.takeIf(Path::exists)?.readText()?.fromJson() ?: emptySet() + + actual suspend fun save(ids: WwmMarkedAchievementIds) { + wwmMarkedAchievementsFile.writeText(ids.toJson()) + } +} diff --git a/wwm/core/data/src/wasmJsMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.wasmJs.kt b/wwm/core/data/src/wasmJsMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.wasmJs.kt new file mode 100644 index 0000000..310ad70 --- /dev/null +++ b/wwm/core/data/src/wasmJsMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.wasmJs.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data.internal + +import dev.omico.wwm.data.WwmMarkedAchievementIds +import kotlinx.browser.localStorage + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") // TODO KT-61573 +internal actual class MarkedAchievementsDataStore { + actual suspend fun load(): WwmMarkedAchievementIds { + val achievements = localStorage.getItem(ACHIEVEMENTS_KEY) ?: return emptySet() + return achievements.split(",").map(String::toInt).toSet() + } + + actual suspend fun save(ids: WwmMarkedAchievementIds): Unit = + localStorage.setItem(ACHIEVEMENTS_KEY, ids.joinToString(",")) +} + +private const val ACHIEVEMENTS_KEY = "achievements" diff --git a/wwm/core/data/src/webMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.web.kt b/wwm/core/data/src/webMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.web.kt new file mode 100644 index 0000000..310ad70 --- /dev/null +++ b/wwm/core/data/src/webMain/kotlin/dev/omico/wwm/data/internal/MarkedAchievementsDataStore.web.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.data.internal + +import dev.omico.wwm.data.WwmMarkedAchievementIds +import kotlinx.browser.localStorage + +@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") // TODO KT-61573 +internal actual class MarkedAchievementsDataStore { + actual suspend fun load(): WwmMarkedAchievementIds { + val achievements = localStorage.getItem(ACHIEVEMENTS_KEY) ?: return emptySet() + return achievements.split(",").map(String::toInt).toSet() + } + + actual suspend fun save(ids: WwmMarkedAchievementIds): Unit = + localStorage.setItem(ACHIEVEMENTS_KEY, ids.joinToString(",")) +} + +private const val ACHIEVEMENTS_KEY = "achievements" diff --git a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsPresenter.kt b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsPresenter.kt index 36632de..e89450e 100644 --- a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsPresenter.kt +++ b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsPresenter.kt @@ -8,14 +8,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.slack.circuit.retained.collectAsRetainedState import dev.omico.wwm.data.WwmAchievements import dev.omico.wwm.ui.WwmUiComponent +import kotlinx.coroutines.launch context(WwmUiComponent) @Composable internal fun produceAchievementsUiState(): AchievementsUiState { + val scope = rememberCoroutineScope() val achievements by achievementsRepository.achievements.collectAsRetainedState(initial = WwmAchievements.Empty) var locale by remember { mutableStateOf(achievements.locale) } LaunchedEffect(Unit) { achievementsRepository.load() } @@ -25,6 +28,13 @@ internal fun produceAchievementsUiState(): AchievementsUiState { eventSink = { event -> when (event) { is AchievementsUiEvent.ChangeLocale -> locale = event.locale + is AchievementsUiEvent.ChangeAchievementMark -> + scope.launch { + when { + event.marked -> achievementsRepository.markAchievement(event.achievementId) + else -> achievementsRepository.unmarkAchievement(event.achievementId) + } + } } }, ) diff --git a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiEvent.kt b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiEvent.kt index be8224a..3473475 100644 --- a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiEvent.kt +++ b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiEvent.kt @@ -8,4 +8,9 @@ import dev.omico.wwm.resources.model.game.WwLocale sealed interface AchievementsUiEvent : CircuitUiEvent { data class ChangeLocale(val locale: WwLocale) : AchievementsUiEvent + + data class ChangeAchievementMark( + val marked: Boolean, + val achievementId: Int, + ) : AchievementsUiEvent } diff --git a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiState.kt b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiState.kt index 90e9044..de9ed63 100644 --- a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiState.kt +++ b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/AchievementsUiState.kt @@ -5,8 +5,12 @@ package dev.omico.wwm.feature.achievements import com.slack.circuit.runtime.CircuitUiState import dev.omico.wwm.data.WwmAchievements +import dev.omico.wwm.resources.model.game.WwAchievement data class AchievementsUiState( val achievements: WwmAchievements, val eventSink: (AchievementsUiEvent) -> Unit, ) : CircuitUiState + +internal fun AchievementsUiState.isAchievementMarked(achievement: WwAchievement): Boolean = + achievement.id in achievements.markedAchievementIds diff --git a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/component/AchievementsDetailContent.kt b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/component/AchievementsDetailContent.kt index 001d069..2968173 100644 --- a/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/component/AchievementsDetailContent.kt +++ b/wwm/core/feature/achievements/src/commonMain/kotlin/dev/omico/wwm/feature/achievements/component/AchievementsDetailContent.kt @@ -12,7 +12,9 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import dev.omico.wwm.feature.achievements.AchievementsUiEvent import dev.omico.wwm.feature.achievements.AchievementsUiState +import dev.omico.wwm.feature.achievements.isAchievementMarked import dev.omico.wwm.resources.model.game.WwAchievement import dev.omico.wwm.resources.model.game.WwAchievementGroup import dev.omico.wwm.resources.rememberWwText @@ -33,12 +35,19 @@ internal fun AchievementsDetailContent( contentPadding = contentPadding, content = { items( - items = achievements, + items = achievements.sortedBy(state::isAchievementMarked), key = WwAchievement::id, itemContent = { achievement -> AchievementsDetailItem( - marked = false, - onMarkedChange = {}, + marked = state.isAchievementMarked(achievement), + onMarkedChange = { marked -> + state.eventSink( + AchievementsUiEvent.ChangeAchievementMark( + marked = marked, + achievementId = achievement.id, + ), + ) + }, name = rememberWwText( multiText = state.achievements.multiText, name = achievement.name, diff --git a/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationEnvironments.kt b/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationEnvironments.kt new file mode 100644 index 0000000..a0ac351 --- /dev/null +++ b/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationEnvironments.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.foundation + +object DesktopApplicationEnvironments { + private val environments: Map = System.getenv() + + val WWM_LOCAL_DIRECTORY: String? = environments["WWM_LOCAL_DIRECTORY"] +} diff --git a/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationPaths.kt b/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationPaths.kt new file mode 100644 index 0000000..8186e8d --- /dev/null +++ b/wwm/core/foundation/src/desktopMain/kotlin/dev/omico/wwm/foundation/DesktopApplicationPaths.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Omico. All Rights Reserved. + */ +package dev.omico.wwm.foundation + +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.createParentDirectories + +private val userHomeDirectory: Path = Path(System.getProperty("user.home")) + +private val wwmLocalDirectory: Path = + DesktopApplicationEnvironments.WWM_LOCAL_DIRECTORY?.let(::Path) + ?: userHomeDirectory.resolve(".wwm") + +val wwmMarkedAchievementsFile: Path = wwmLocalDirectory.resolve("MarkedAchievements.json").createParentDirectories()