diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5a3dc78..ea1e38a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -24,7 +24,9 @@ kotlin { jvmMain.dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + implementation(libs.cardiologist) implementation(libs.kotlin.logging.jvm) implementation(libs.logback.classic) } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt new file mode 100644 index 0000000..12d50a4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt @@ -0,0 +1,171 @@ +package com.rukira.wowbackup.backup + +import com.rukira.wowbackup.config.AppConfig +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +private val logger = KotlinLogging.logger {} + +data class BackupProgress( + val totalFiles: Int, + val completedFiles: Int, + val currentFile: String, +) + +sealed class BackupResult { + data class Success(val backupPath: File, val fileCount: Int, val sizeBytes: Long, val durationMs: Long) : BackupResult() + data class Failure(val reason: String, val exception: Throwable? = null) : BackupResult() +} + +object BackupEngine { + + private val TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + + private val _progress = MutableStateFlow(null) + val progress: StateFlow = _progress.asStateFlow() + + suspend fun runBackup(config: AppConfig): BackupResult { + val wowPath = config.wowInstallPath ?: return BackupResult.Failure("WoW install path not set.") + val backupPath = config.backupPath ?: return BackupResult.Failure("Backup path not set.") + val wowDir = File(wowPath) + val backupDir = File(backupPath) + + if (!wowDir.exists()) return BackupResult.Failure("WoW directory does not exist: $wowPath") + if (!backupDir.exists()) return BackupResult.Failure("Backup directory does not exist: $backupPath") + + // Collect source folders + val sourceFolders = buildList { + if (config.backupWtf) { + val wtf = File(wowDir, "WTF") + if (wtf.exists()) add(wtf) else logger.warn { "WTF folder not found at $wtf" } + } + if (config.backupInterface) { + val iface = File(wowDir, "Interface") + if (iface.exists()) add(iface) else logger.warn { "Interface folder not found at $iface" } + } + } + + if (sourceFolders.isEmpty()) { + return BackupResult.Failure("No folders found to backup.") + } + + // Count total files + val allFiles = sourceFolders.flatMap { folder -> + folder.walkTopDown().filter { it.isFile }.toList() + } + val totalFiles = allFiles.size + logger.info { "Starting backup of $totalFiles files from ${sourceFolders.map { it.name }}" } + + val startTime = System.currentTimeMillis() + val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT) + + return try { + if (config.compressionEnabled) { + backupZip(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) + } else { + backupCopy(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) + } + } catch (e: kotlinx.coroutines.CancellationException) { + logger.info { "Backup cancelled" } + throw e + } catch (e: Exception) { + logger.error(e) { "Backup failed" } + BackupResult.Failure("Backup failed: ${e.message}", e) + } finally { + _progress.value = null + val elapsed = System.currentTimeMillis() - startTime + logger.info { "Backup operation finished in ${elapsed}ms" } + } + } + + private suspend fun backupCopy( + wowDir: File, + sourceFolders: List, + allFiles: List, + backupDir: File, + timestamp: String, + totalFiles: Int, + ): BackupResult { + val destDir = File(backupDir, timestamp) + destDir.mkdirs() + + var completed = 0 + var totalSize = 0L + + for (folder in sourceFolders) { + for (file in folder.walkTopDown().filter { it.isFile }) { + currentCoroutineContext().ensureActive() + + val relativePath = file.relativeTo(wowDir) + val destFile = File(destDir, relativePath.path) + destFile.parentFile?.mkdirs() + + _progress.value = BackupProgress(totalFiles, completed, relativePath.path) + + file.copyTo(destFile, overwrite = true) + totalSize += file.length() + completed++ + } + } + + val elapsed = System.currentTimeMillis() + logger.info { "Backup complete: $completed files copied to $destDir" } + + return BackupResult.Success( + backupPath = destDir, + fileCount = completed, + sizeBytes = totalSize, + durationMs = 0, // caller can measure + ) + } + + private suspend fun backupZip( + wowDir: File, + sourceFolders: List, + allFiles: List, + backupDir: File, + timestamp: String, + totalFiles: Int, + ): BackupResult { + val zipFile = File(backupDir, "$timestamp.zip") + + var completed = 0 + + ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos -> + for (folder in sourceFolders) { + for (file in folder.walkTopDown().filter { it.isFile }) { + currentCoroutineContext().ensureActive() + + val relativePath = file.relativeTo(wowDir).path + _progress.value = BackupProgress(totalFiles, completed, relativePath) + + val entry = ZipEntry(relativePath) + entry.time = file.lastModified() + zos.putNextEntry(entry) + file.inputStream().buffered().use { it.copyTo(zos) } + zos.closeEntry() + completed++ + } + } + } + + logger.info { "Backup complete: $completed files compressed to $zipFile (${zipFile.length()} bytes)" } + + return BackupResult.Success( + backupPath = zipFile, + fileCount = completed, + sizeBytes = zipFile.length(), + durationMs = 0, + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt new file mode 100644 index 0000000..77fcb26 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt @@ -0,0 +1,91 @@ +package com.rukira.wowbackup.backup + +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.datetime.LocalDateTime +import java.io.File +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +private val logger = KotlinLogging.logger {} + +data class BackupEntry( + val path: File, + val timestamp: LocalDateTime, + val isCompressed: Boolean, + val sizeBytes: Long, +) + +object BackupHistory { + + // Used internally for parsing — java.time formatter since kotlinx.datetime + // doesn't have custom format patterns built in + private val JAVA_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + + /** + * Lists all existing backups in the backup directory, sorted newest first. + */ + fun listBackups(backupDir: File): List { + if (!backupDir.exists() || !backupDir.isDirectory) return emptyList() + + val entries = backupDir.listFiles()?.mapNotNull { file -> + parseBackupEntry(file) + } ?: emptyList() + + return entries.sortedByDescending { it.timestamp } + } + + /** + * Deletes old backups that exceed the given max count. + */ + fun pruneOldBackups(backupDir: File, maxCount: Int) { + val backups = listBackups(backupDir) + if (backups.size <= maxCount) return + + val toDelete = backups.drop(maxCount) + for (entry in toDelete) { + try { + if (entry.isCompressed) { + entry.path.delete() + } else { + entry.path.deleteRecursively() + } + logger.info { "Pruned old backup: ${entry.path.name}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete old backup: ${entry.path.name}" } + } + } + } + + private fun parseBackupEntry(file: File): BackupEntry? { + val name = file.nameWithoutExtension + val isZip = file.extension.equals("zip", ignoreCase = true) && file.isFile + val isDir = file.isDirectory + + if (!isZip && !isDir) return null + + // Parse with java.time, then convert to kotlinx.datetime + val javaTimestamp = try { + java.time.LocalDateTime.parse(name, JAVA_TIMESTAMP_FORMAT) + } catch (e: DateTimeParseException) { + return null + } + + val timestamp = LocalDateTime( + javaTimestamp.year, javaTimestamp.monthValue, javaTimestamp.dayOfMonth, + javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second, + ) + + val size = if (isZip) { + file.length() + } else { + file.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + + return BackupEntry( + path = file, + timestamp = timestamp, + isCompressed = isZip, + sizeBytes = size, + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupNotifier.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupNotifier.kt new file mode 100644 index 0000000..4f2c9ab --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupNotifier.kt @@ -0,0 +1,45 @@ +package com.rukira.wowbackup.backup + +import androidx.compose.ui.window.Notification +import androidx.compose.ui.window.TrayState +import com.rukira.wowbackup.config.ConfigManager +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger {} + +object BackupNotifier { + + var trayState: TrayState? = null + + fun notifyBackupStarted() { + send("Backup in progress...", Notification.Type.None) + } + + fun notifyBackupComplete(fileCount: Int, sizeMb: String) { + send("Backup complete ($fileCount files, $sizeMb)", Notification.Type.None) + } + + fun notifyBackupFailed(reason: String) { + send("Backup failed: $reason", Notification.Type.Error) + } + + fun notifyBackupSkipped(reason: String) { + send("Backup skipped — $reason", Notification.Type.Warning) + } + + private fun send(message: String, type: Notification.Type) { + if (!ConfigManager.config.value.notificationsEnabled) { + logger.debug { "Notification suppressed (disabled): $message" } + return + } + + val tray = trayState + if (tray == null) { + logger.warn { "Cannot send notification, TrayState not set: $message" } + return + } + + logger.info { "Notification: $message" } + tray.sendNotification(Notification("WoW Backup", message, type)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt new file mode 100644 index 0000000..e4999ee --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt @@ -0,0 +1,182 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package com.rukira.wowbackup.backup + +import com.rukira.wowbackup.config.ConfigManager +import io.github.kevincianfarini.cardiologist.schedulePulse +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import java.io.File +import kotlin.time.Clock + +private val logger = KotlinLogging.logger {} + +data class SchedulerState( + val lastBackupTime: LocalDateTime? = null, + val lastBackupResult: BackupResult? = null, + val nextBackupTime: LocalDateTime? = null, + val isRunning: Boolean = false, + val currentProgress: BackupProgress? = null, + val isWoWRunning: Boolean = false, +) + +object BackupScheduler { + + private val scope = CoroutineScope(Dispatchers.Default) + private var schedulerJob: Job? = null + private var pulseJob: Job? = null + private var backupJob: Job? = null + + private val _state = MutableStateFlow(SchedulerState()) + val state: StateFlow = _state.asStateFlow() + + fun start() { + if (schedulerJob?.isActive == true) return + logger.info { "Backup scheduler started" } + + schedulerJob = scope.launch { + // Collect progress from BackupEngine + launch { + BackupEngine.progress.collect { progress -> + _state.update { it.copy(currentProgress = progress) } + } + } + + // React to config changes and reschedule the pulse + ConfigManager.config.collect { config -> + // Only cancel the pulse (the waiting), not a running backup + pulseJob?.cancel() + + if (!config.isConfigured) { + _state.update { it.copy(nextBackupTime = null) } + return@collect + } + + val tz = TimeZone.currentSystemDefault() + val targetTime = config.backupTimeOfDay + logger.info { "Scheduling daily backup at $targetTime ($tz)" } + + updateNextBackupTime(tz) + + pulseJob = launch { + Clock.System.schedulePulse( + atHour = targetTime.hour, + atMinute = targetTime.minute, + atSecond = 0, + timeZone = tz, + ).beat { _ -> + // Read fresh config at execution time, not the stale closure value + val currentConfig = ConfigManager.config.value + val wowRunning = WoWProcessDetector.isWoWRunning() + _state.update { it.copy(isWoWRunning = wowRunning) } + + if (wowRunning && !currentConfig.forceBackupWhileRunning) { + logger.info { "Backup time reached but WoW is running (force disabled), skipping" } + BackupNotifier.notifyBackupSkipped("WoW is running") + } else { + // Launch backup in a separate job so it survives pulse cancellation + // (e.g. if user changes config while backup is running) + backupJob = scope.launch { executeBackup() } + backupJob?.join() + } + + updateNextBackupTime(tz) + } + } + } + } + } + + fun stop() { + schedulerJob?.cancel() + pulseJob?.cancel() + backupJob?.cancel() + schedulerJob = null + pulseJob = null + backupJob = null + logger.info { "Backup scheduler stopped" } + } + + fun triggerBackupNow() { + if (_state.value.isRunning) { + logger.info { "Backup already running, ignoring manual trigger" } + return + } + logger.info { "Manual backup triggered" } + backupJob = scope.launch { + executeBackup() + updateNextBackupTime(TimeZone.currentSystemDefault()) + } + } + + private suspend fun executeBackup() { + val config = ConfigManager.config.value + if (!config.isConfigured) return + + _state.update { it.copy(isRunning = true) } + BackupNotifier.notifyBackupStarted() + + val startTime = System.currentTimeMillis() + val result = BackupEngine.runBackup(config) + val duration = System.currentTimeMillis() - startTime + + val tz = TimeZone.currentSystemDefault() + val now = now(tz) + + _state.update { + it.copy( + isRunning = false, + currentProgress = null, + lastBackupTime = now, + lastBackupResult = when (result) { + is BackupResult.Success -> result.copy(durationMs = duration) + is BackupResult.Failure -> result + }, + ) + } + + when (result) { + is BackupResult.Success -> { + val sizeMb = "%.1f MB".format(result.sizeBytes / (1024.0 * 1024.0)) + BackupNotifier.notifyBackupComplete(result.fileCount, sizeMb) + + val backupDir = File(config.backupPath!!) + BackupHistory.pruneOldBackups(backupDir, config.backupHistoryCount) + } + is BackupResult.Failure -> { + BackupNotifier.notifyBackupFailed(result.reason) + } + } + } + + private fun updateNextBackupTime(tz: TimeZone) { + val config = ConfigManager.config.value + val targetTime = config.backupTimeOfDay + val now = now(tz) + val today = now.date + val todayTarget = LocalDateTime(today, targetTime) + + val next = if (now < todayTarget) { + todayTarget + } else { + val tomorrow = today.plus(1, DateTimeUnit.DAY) + LocalDateTime(tomorrow, targetTime) + } + + _state.update { it.copy(nextBackupTime = next) } + } + + private fun now(tz: TimeZone): LocalDateTime = Clock.System.now().toLocalDateTime(tz) +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/WoWProcessDetector.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/WoWProcessDetector.kt new file mode 100644 index 0000000..ca1e9b0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/WoWProcessDetector.kt @@ -0,0 +1,33 @@ +package com.rukira.wowbackup.backup + +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger {} + +object WoWProcessDetector { + + private val WOW_PROCESS_NAMES = listOf( + "world of warcraft", + "wow.exe", + "wow-64.exe", + "wow.app", + ) + + fun isWoWRunning(): Boolean { + return try { + val running = ProcessHandle.allProcesses() + .filter { it.isAlive } + .anyMatch { handle -> + val command = handle.info().command().orElse("").lowercase() + WOW_PROCESS_NAMES.any { name -> command.contains(name) } + } + if (running) { + logger.debug { "WoW process detected as running" } + } + running + } catch (e: Exception) { + logger.warn(e) { "Failed to check WoW process status" } + false + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt index 7e2d0ca..b68591a 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt @@ -1,25 +1,13 @@ package com.rukira.wowbackup.config -import kotlinx.serialization.KSerializer +import kotlinx.datetime.LocalTime import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import java.time.LocalTime - -object LocalTimeSerializer : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString()) - override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString()) -} @Serializable data class AppConfig( val wowInstallPath: String? = null, val backupPath: String? = null, - @Serializable(with = LocalTimeSerializer::class) - val backupTimeOfDay: LocalTime = LocalTime.of(3, 0), + val backupTimeOfDay: LocalTime = LocalTime(hour = 3, minute = 0), val backupHistoryCount: Int = 5, val forceBackupWhileRunning: Boolean = false, val backupWtf: Boolean = true, diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index d5b0e7f..8432651 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -4,15 +4,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application +import androidx.compose.ui.window.TrayState +import androidx.compose.ui.window.rememberTrayState +import com.rukira.wowbackup.backup.BackupNotifier +import com.rukira.wowbackup.backup.BackupScheduler import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.logging.LoggingSetup +import com.rukira.wowbackup.platform.OS +import com.rukira.wowbackup.platform.Platform import com.rukira.wowbackup.platform.WoWLocations import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.trayIconPainter import io.github.oshai.kotlinlogging.KotlinLogging +import java.awt.GraphicsEnvironment private val logger = KotlinLogging.logger {} @@ -30,11 +41,19 @@ fun main() { val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG + // Start backup scheduler + BackupScheduler.start() + application { var isWindowVisible by remember { mutableStateOf(true) } var currentScreen by remember { mutableStateOf(startScreen) } + val trayState = rememberTrayState() + + // Wire up notifications + BackupNotifier.trayState = trayState Tray( + state = trayState, icon = trayIconPainter(), tooltip = "WoW Backup", onAction = { isWindowVisible = true }, @@ -48,14 +67,30 @@ fun main() { isWindowVisible = true }) Separator() - Item("Quit", onClick = ::exitApplication) + Item("Backup Now", onClick = { + BackupScheduler.triggerBackupNow() + }) + Separator() + Item("Quit", onClick = { + BackupScheduler.stop() + exitApplication() + }) }, ) if (isWindowVisible) { + val windowState = remember(isWindowVisible) { + WindowState( + size = DpSize(480.dp, 620.dp), + position = trayAlignedPosition(), + ) + } + Window( onCloseRequest = { isWindowVisible = false }, title = "WoW Backup", + state = windowState, + resizable = true, ) { App( currentScreen = currentScreen, @@ -65,3 +100,29 @@ fun main() { } } } + +/** + * Positions the window near the system tray area: + * - macOS: top-right (menu bar is at the top, tray icons on the right) + * - Windows: bottom-right (taskbar tray is at the bottom-right) + * - Other: top-right as fallback + */ +private fun trayAlignedPosition(): WindowPosition { + val ge = GraphicsEnvironment.getLocalGraphicsEnvironment() + val screenBounds = ge.defaultScreenDevice.defaultConfiguration.bounds + val insets = java.awt.Toolkit.getDefaultToolkit().getScreenInsets(ge.defaultScreenDevice.defaultConfiguration) + + val windowWidth = 480 + val windowHeight = 620 + val padding = 8 + + val x = screenBounds.width - windowWidth - insets.right - padding + + val y = when (Platform.current) { + OS.Mac -> insets.top + padding // just below the menu bar + OS.Windows -> screenBounds.height - windowHeight - insets.bottom - padding // above the taskbar + else -> insets.top + padding + } + + return WindowPosition.Absolute(x.dp, y.dp) +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/TimePicker.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/TimePicker.kt index 2b2dc66..47910ec 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/TimePicker.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/TimePicker.kt @@ -17,7 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import java.time.LocalTime +import kotlinx.datetime.LocalTime @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,7 +37,7 @@ fun TimePicker( selectedValue = value.hour, options = (0..23).toList(), format = { "%02d".format(it) }, - onSelect = { onValueChange(value.withHour(it)) }, + onSelect = { onValueChange(LocalTime(it, value.minute)) }, modifier = Modifier.weight(1f), ) @@ -49,7 +49,7 @@ fun TimePicker( selectedValue = value.minute, options = listOf(0, 15, 30, 45), format = { "%02d".format(it) }, - onSelect = { onValueChange(value.withMinute(it)) }, + onSelect = { onValueChange(LocalTime(value.hour, it)) }, modifier = Modifier.weight(1f), ) } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt index c7afa9f..345ca6d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import com.rukira.wowbackup.platform.WoWValidationResult import java.io.File -import java.time.LocalTime +import kotlinx.datetime.LocalTime private val logger = KotlinLogging.logger {} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd3f60f..e0c7f93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,9 @@ androidx-lifecycle = "2.10.0-alpha08" composeMultiplatform = "1.11.0-alpha03" kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" kotlinx-serialization = "1.10.0" +cardiologist = "0.8.0" kotlin-logging = "7.0.3" logback = "1.5.18" @@ -11,7 +13,9 @@ logback = "1.5.18" androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" } kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }