Config screen
This commit is contained in:
parent
ac88e62abe
commit
4b3c512a9d
28 changed files with 2015 additions and 59 deletions
|
|
@ -1,49 +1,60 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
import myapplication.composeapp.generated.resources.Res
|
||||
import myapplication.composeapp.generated.resources.compose_multiplatform
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rukira.wowbackup.ui.Screen
|
||||
import com.rukira.wowbackup.ui.config.ConfigScreen
|
||||
import com.rukira.wowbackup.ui.config.ConfigViewModel
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
fun App(
|
||||
currentScreen: Screen,
|
||||
onNavigate: (Screen) -> Unit,
|
||||
) {
|
||||
MaterialTheme {
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.safeContentPadding()
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Button(onClick = { showContent = !showContent }) {
|
||||
Text("Click me!")
|
||||
}
|
||||
AnimatedVisibility(showContent) {
|
||||
val greeting = remember { Greeting().greet() }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
||||
Text("Compose: $greeting")
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
when (currentScreen) {
|
||||
Screen.STATUS -> PlaceholderScreen("Status", "Backup status will appear here.")
|
||||
Screen.CONFIG -> {
|
||||
val viewModel = remember { ConfigViewModel() }
|
||||
ConfigScreen(
|
||||
viewModel = viewModel,
|
||||
onSaved = { onNavigate(Screen.STATUS) },
|
||||
onCancel = { onNavigate(Screen.STATUS) },
|
||||
)
|
||||
}
|
||||
Screen.RESTORE -> PlaceholderScreen("Restore", "Restore from backup will appear here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen(title: String, subtitle: String) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
class JVMPlatform {
|
||||
val name: String = "Java ${System.getProperty("java.version")}"
|
||||
}
|
||||
|
||||
fun getPlatform() = JVMPlatform()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.rukira.wowbackup.config
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
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<LocalTime> {
|
||||
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 backupHistoryCount: Int = 5,
|
||||
val forceBackupWhileRunning: Boolean = false,
|
||||
val backupWtf: Boolean = true,
|
||||
val backupInterface: Boolean = true,
|
||||
val compressionEnabled: Boolean = false,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val runAtStartup: Boolean = false,
|
||||
) {
|
||||
val isConfigured: Boolean
|
||||
get() = !wowInstallPath.isNullOrBlank() && !backupPath.isNullOrBlank()
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.rukira.wowbackup.config
|
||||
|
||||
import com.rukira.wowbackup.platform.AppDirectories
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
object ConfigManager {
|
||||
|
||||
private val _config = MutableStateFlow(AppConfig())
|
||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = _config.value.isConfigured
|
||||
|
||||
fun load() {
|
||||
val file = AppDirectories.configFile
|
||||
if (file.exists()) {
|
||||
try {
|
||||
val text = file.readText()
|
||||
_config.value = json.decodeFromString<AppConfig>(text)
|
||||
logger.info { "Config loaded from $file" }
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to load config from $file, using defaults" }
|
||||
_config.value = AppConfig()
|
||||
}
|
||||
} else {
|
||||
logger.info { "No config file found, using defaults" }
|
||||
_config.value = AppConfig()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(config: AppConfig = _config.value) {
|
||||
_config.value = config
|
||||
val file = AppDirectories.configFile
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(json.encodeToString(AppConfig.serializer(), config))
|
||||
logger.info { "Config saved to $file" }
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to save config to $file" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.rukira.wowbackup.logging
|
||||
|
||||
import ch.qos.logback.classic.LoggerContext
|
||||
import ch.qos.logback.classic.encoder.PatternLayoutEncoder
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||
import ch.qos.logback.core.rolling.RollingFileAppender
|
||||
import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy
|
||||
import ch.qos.logback.core.util.FileSize
|
||||
import com.rukira.wowbackup.platform.AppDirectories
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
|
||||
object LoggingSetup {
|
||||
|
||||
fun init() {
|
||||
val logDir = AppDirectories.logsDir
|
||||
val logFile = File(logDir, "wowbackup.log")
|
||||
|
||||
val context = LoggerFactory.getILoggerFactory() as LoggerContext
|
||||
|
||||
val encoder = PatternLayoutEncoder().apply {
|
||||
this.context = context
|
||||
pattern = "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
start()
|
||||
}
|
||||
|
||||
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
|
||||
this.context = context
|
||||
fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
||||
setMaxFileSize(FileSize.valueOf("10MB"))
|
||||
maxHistory = 30
|
||||
setTotalSizeCap(FileSize.valueOf("100MB"))
|
||||
}
|
||||
|
||||
val fileAppender = RollingFileAppender<ILoggingEvent>().apply {
|
||||
this.context = context
|
||||
name = "FILE"
|
||||
file = logFile.absolutePath
|
||||
this.encoder = encoder
|
||||
this.rollingPolicy = rollingPolicy
|
||||
}
|
||||
|
||||
rollingPolicy.setParent(fileAppender)
|
||||
rollingPolicy.start()
|
||||
fileAppender.start()
|
||||
|
||||
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
|
||||
rootLogger.addAppender(fileAppender)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,67 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import com.rukira.wowbackup.logging.LoggingSetup
|
||||
import com.rukira.wowbackup.platform.WoWLocations
|
||||
import com.rukira.wowbackup.ui.Screen
|
||||
import com.rukira.wowbackup.ui.trayIconPainter
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "MyApplication",
|
||||
) {
|
||||
App()
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
fun main() {
|
||||
LoggingSetup.init()
|
||||
logger.info { "WoW Backup starting" }
|
||||
|
||||
ConfigManager.load()
|
||||
logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" }
|
||||
|
||||
val detectedWoW = WoWLocations.findWoWInstall()
|
||||
if (detectedWoW != null) {
|
||||
logger.info { "Auto-detected WoW at: $detectedWoW" }
|
||||
}
|
||||
}
|
||||
|
||||
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
|
||||
|
||||
application {
|
||||
var isWindowVisible by remember { mutableStateOf(true) }
|
||||
var currentScreen by remember { mutableStateOf(startScreen) }
|
||||
|
||||
Tray(
|
||||
icon = trayIconPainter(),
|
||||
tooltip = "WoW Backup",
|
||||
onAction = { isWindowVisible = true },
|
||||
menu = {
|
||||
Item("Status", onClick = {
|
||||
currentScreen = Screen.STATUS
|
||||
isWindowVisible = true
|
||||
})
|
||||
Item("Settings", onClick = {
|
||||
currentScreen = Screen.CONFIG
|
||||
isWindowVisible = true
|
||||
})
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
},
|
||||
)
|
||||
|
||||
if (isWindowVisible) {
|
||||
Window(
|
||||
onCloseRequest = { isWindowVisible = false },
|
||||
title = "WoW Backup",
|
||||
) {
|
||||
App(
|
||||
currentScreen = currentScreen,
|
||||
onNavigate = { currentScreen = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.io.File
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object AppDirectories {
|
||||
|
||||
private const val APP_NAME = "WoWBackup"
|
||||
|
||||
val appDataDir: File by lazy {
|
||||
val dir = resolveAppDataDir()
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
logger.info { "Created app data directory: $dir" }
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
val logsDir: File by lazy {
|
||||
val dir = File(appDataDir, "logs")
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
val configFile: File
|
||||
get() = File(appDataDir, "config.json")
|
||||
|
||||
private fun resolveAppDataDir(): File {
|
||||
val home = System.getProperty("user.home")
|
||||
return when (Platform.current) {
|
||||
OS.Mac -> File(home, "Library/Application Support/$APP_NAME")
|
||||
OS.Windows -> {
|
||||
val appData = System.getenv("APPDATA") ?: "$home/AppData/Roaming"
|
||||
File(appData, APP_NAME)
|
||||
}
|
||||
OS.Linux -> {
|
||||
val xdgConfig = System.getenv("XDG_CONFIG_HOME") ?: "$home/.config"
|
||||
File(xdgConfig, APP_NAME)
|
||||
}
|
||||
OS.Other -> File(home, ".$APP_NAME")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
enum class OS { Windows, Mac, Linux, Other }
|
||||
|
||||
object Platform {
|
||||
val current: OS = System.getProperty("os.name").lowercase().let { name ->
|
||||
when {
|
||||
"mac" in name -> OS.Mac
|
||||
"win" in name -> OS.Windows
|
||||
"nux" in name || "nix" in name -> OS.Linux
|
||||
else -> OS.Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.io.File
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object WoWLocations {
|
||||
|
||||
fun findWoWInstall(): File? {
|
||||
val candidates = when (Platform.current) {
|
||||
OS.Mac -> listOf(
|
||||
File("/Applications/World of Warcraft"),
|
||||
File(System.getProperty("user.home"), "Applications/World of Warcraft"),
|
||||
)
|
||||
OS.Windows -> buildList {
|
||||
add(File("C:\\Program Files (x86)\\World of Warcraft"))
|
||||
add(File("C:\\Program Files\\World of Warcraft"))
|
||||
System.getenv("PROGRAMFILES(X86)")?.let {
|
||||
add(File(it, "World of Warcraft"))
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
for (candidate in candidates) {
|
||||
if (candidate.exists() && isValidWoWInstall(candidate)) {
|
||||
logger.info { "Found WoW installation at: $candidate" }
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "No WoW installation found in default locations" }
|
||||
return null
|
||||
}
|
||||
|
||||
fun isValidWoWInstall(dir: File): Boolean {
|
||||
return validateWoWInstall(dir).isValid
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed validation of a WoW install directory.
|
||||
*
|
||||
* WoW's directory structure is typically:
|
||||
* World of Warcraft/
|
||||
* _retail_/
|
||||
* WTF/
|
||||
* Interface/
|
||||
* _classic_/
|
||||
* WTF/
|
||||
* Interface/
|
||||
*
|
||||
* If the user selects the root "World of Warcraft" folder (containing _retail_ or _classic_),
|
||||
* we resolve down to _retail_ automatically. If they select _retail_ or _classic_ directly,
|
||||
* we validate that folder as-is.
|
||||
*
|
||||
* [resolvedDir] in the result contains the actual game data directory to use.
|
||||
*/
|
||||
fun validateWoWInstall(dir: File): WoWValidationResult {
|
||||
if (!dir.isDirectory) {
|
||||
return WoWValidationResult(isValid = false, error = "Not a directory.")
|
||||
}
|
||||
|
||||
val contents = dir.listFiles()
|
||||
if (contents == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Cannot read directory. Check folder permissions.",
|
||||
)
|
||||
}
|
||||
|
||||
// If selected dir contains _retail_ or _classic_, resolve into the game data subfolder
|
||||
val retailDir = contents.find { it.name.equals("_retail_", ignoreCase = true) && it.isDirectory }
|
||||
val classicDir = contents.find { it.name.equals("_classic_", ignoreCase = true) && it.isDirectory }
|
||||
|
||||
if (retailDir != null || classicDir != null) {
|
||||
// This is the root WoW install folder — resolve to _retail_ (preferred) or _classic_
|
||||
val gameDataDir = retailDir ?: classicDir!!
|
||||
logger.info { "Root WoW folder selected, resolving to: ${gameDataDir.name}" }
|
||||
return validateGameDataDir(gameDataDir)
|
||||
}
|
||||
|
||||
// Otherwise, check if this is itself a game data dir (has WTF/Interface/executable)
|
||||
return validateGameDataDir(dir)
|
||||
}
|
||||
|
||||
private fun validateGameDataDir(dir: File): WoWValidationResult {
|
||||
val contents = dir.listFiles()
|
||||
if (contents == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Cannot read directory. Check folder permissions.",
|
||||
)
|
||||
}
|
||||
|
||||
val hasWoWMarker = contents.any { file ->
|
||||
file.name.equals("WoW.exe", ignoreCase = true) ||
|
||||
file.name.equals("World of Warcraft.app", ignoreCase = true) ||
|
||||
file.name.equals("WoW.app", ignoreCase = true)
|
||||
}
|
||||
|
||||
val wtfDir = contents.find { it.name.equals("WTF", ignoreCase = true) && it.isDirectory }
|
||||
val interfaceDir = contents.find { it.name.equals("Interface", ignoreCase = true) && it.isDirectory }
|
||||
|
||||
if (!hasWoWMarker && wtfDir == null && interfaceDir == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Does not appear to be a WoW installation. No WoW executable, WTF, or Interface folder found.",
|
||||
)
|
||||
}
|
||||
|
||||
val warnings = mutableListOf<String>()
|
||||
|
||||
if (wtfDir == null) {
|
||||
warnings.add("WTF folder not found — it may be created after first login.")
|
||||
} else if (!wtfDir.canRead()) {
|
||||
warnings.add("WTF folder exists but is not readable. Check folder permissions.")
|
||||
}
|
||||
|
||||
if (interfaceDir == null) {
|
||||
warnings.add("Interface folder not found — it may be created after installing addons.")
|
||||
} else if (!interfaceDir.canRead()) {
|
||||
warnings.add("Interface folder exists but is not readable. Check folder permissions.")
|
||||
}
|
||||
|
||||
return WoWValidationResult(
|
||||
isValid = true,
|
||||
resolvedDir = dir,
|
||||
hasWtf = wtfDir != null,
|
||||
wtfReadable = wtfDir?.canRead() ?: false,
|
||||
hasInterface = interfaceDir != null,
|
||||
interfaceReadable = interfaceDir?.canRead() ?: false,
|
||||
warnings = warnings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class WoWValidationResult(
|
||||
val isValid: Boolean,
|
||||
val error: String? = null,
|
||||
/** The actual game data directory to use (may differ from user's selection if resolved from root). */
|
||||
val resolvedDir: File? = null,
|
||||
val hasWtf: Boolean = false,
|
||||
val wtfReadable: Boolean = false,
|
||||
val hasInterface: Boolean = false,
|
||||
val interfaceReadable: Boolean = false,
|
||||
val warnings: List<String> = emptyList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.rukira.wowbackup.ui
|
||||
|
||||
enum class Screen {
|
||||
STATUS,
|
||||
CONFIG,
|
||||
RESTORE,
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.rukira.wowbackup.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.geometry.Size
|
||||
|
||||
/**
|
||||
* A simple shield-shaped tray icon drawn in code.
|
||||
* Works with macOS template images (-Dapple.awt.enableTemplateImages=true)
|
||||
* which will automatically adapt to dark/light menu bar.
|
||||
*/
|
||||
fun trayIconPainter(): Painter = object : Painter() {
|
||||
override val intrinsicSize = Size(32f, 32f)
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
// Shield shape
|
||||
val shield = Path().apply {
|
||||
moveTo(w * 0.5f, h * 0.05f)
|
||||
lineTo(w * 0.9f, h * 0.2f)
|
||||
lineTo(w * 0.9f, h * 0.5f)
|
||||
cubicTo(w * 0.9f, h * 0.75f, w * 0.5f, h * 0.95f, w * 0.5f, h * 0.95f)
|
||||
cubicTo(w * 0.5f, h * 0.95f, w * 0.1f, h * 0.75f, w * 0.1f, h * 0.5f)
|
||||
lineTo(w * 0.1f, h * 0.2f)
|
||||
close()
|
||||
}
|
||||
drawPath(shield, Color.Black)
|
||||
|
||||
// Checkmark inside the shield
|
||||
val check = Path().apply {
|
||||
moveTo(w * 0.25f, h * 0.48f)
|
||||
lineTo(w * 0.42f, h * 0.65f)
|
||||
lineTo(w * 0.72f, h * 0.32f)
|
||||
}
|
||||
drawPath(
|
||||
check,
|
||||
Color.White,
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.08f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String = "Confirm",
|
||||
cancelLabel: String = "Cancel",
|
||||
destructive: Boolean = false,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
colors = if (destructive) {
|
||||
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
ButtonDefaults.textButtonColors()
|
||||
},
|
||||
) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(cancelLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import com.rukira.wowbackup.platform.OS
|
||||
import com.rukira.wowbackup.platform.Platform
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/**
|
||||
* Opens a native folder-selection dialog and returns the selected directory, or null if cancelled.
|
||||
* On macOS uses AWT FileDialog for native look; on Windows/Linux uses JFileChooser.
|
||||
*/
|
||||
fun pickFolder(title: String = "Select Folder", initialDir: File? = null): File? {
|
||||
var result: File? = null
|
||||
|
||||
val runnable = Runnable {
|
||||
result = when (Platform.current) {
|
||||
OS.Mac -> pickFolderMac(title, initialDir)
|
||||
else -> pickFolderSwing(title, initialDir)
|
||||
}
|
||||
}
|
||||
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
runnable.run()
|
||||
} else {
|
||||
SwingUtilities.invokeAndWait(runnable)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun pickFolderMac(title: String, initialDir: File?): File? {
|
||||
// Enable folder selection for AWT FileDialog on macOS
|
||||
val previous = System.getProperty("apple.awt.fileDialogForDirectories")
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||
|
||||
try {
|
||||
val dialog = FileDialog(null as Frame?, title, FileDialog.LOAD)
|
||||
if (initialDir != null) {
|
||||
dialog.directory = initialDir.absolutePath
|
||||
}
|
||||
dialog.isVisible = true
|
||||
|
||||
val dir = dialog.directory
|
||||
val file = dialog.file
|
||||
return if (dir != null && file != null) File(dir, file) else null
|
||||
} finally {
|
||||
if (previous != null) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", previous)
|
||||
} else {
|
||||
System.clearProperty("apple.awt.fileDialogForDirectories")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickFolderSwing(title: String, initialDir: File?): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
if (initialDir != null) {
|
||||
currentDirectory = initialDir
|
||||
}
|
||||
}
|
||||
|
||||
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
chooser.selectedFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimePicker(
|
||||
value: LocalTime,
|
||||
onValueChange: (LocalTime) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Hour dropdown
|
||||
TimeDropdown(
|
||||
label = "Hour",
|
||||
selectedValue = value.hour,
|
||||
options = (0..23).toList(),
|
||||
format = { "%02d".format(it) },
|
||||
onSelect = { onValueChange(value.withHour(it)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(":")
|
||||
|
||||
// Minute dropdown (15-min increments)
|
||||
TimeDropdown(
|
||||
label = "Minute",
|
||||
selectedValue = value.minute,
|
||||
options = listOf(0, 15, 30, 45),
|
||||
format = { "%02d".format(it) },
|
||||
onSelect = { onValueChange(value.withMinute(it)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TimeDropdown(
|
||||
label: String,
|
||||
selectedValue: Int,
|
||||
options: List<Int>,
|
||||
format: (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
TextField(
|
||||
value = format(selectedValue),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(format(option)) },
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
package com.rukira.wowbackup.ui.config
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rukira.wowbackup.ui.components.ConfirmationDialog
|
||||
import com.rukira.wowbackup.ui.components.TimePicker
|
||||
import com.rukira.wowbackup.ui.components.pickFolder
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
viewModel: ConfigViewModel,
|
||||
onSaved: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.state.collectAsState()
|
||||
val config = uiState.config
|
||||
val errors = uiState.errors
|
||||
var showForceBackupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// === WoW Installation ===
|
||||
SectionHeader("WoW Installation")
|
||||
|
||||
PathField(
|
||||
label = "WoW install location",
|
||||
value = config.wowInstallPath ?: "",
|
||||
error = errors["wowPath"],
|
||||
onBrowse = {
|
||||
val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
|
||||
if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
|
||||
},
|
||||
)
|
||||
|
||||
// WoW path warnings (e.g. missing WTF/Interface, permission issues)
|
||||
uiState.warnings["wowPath"]?.forEach { warning ->
|
||||
Text(
|
||||
warning,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
// WoW validation summary when valid
|
||||
uiState.wowValidation?.let { validation ->
|
||||
if (validation.isValid) {
|
||||
val wtfStatus = when {
|
||||
validation.wtfReadable -> "WTF folder: found"
|
||||
validation.hasWtf -> "WTF folder: found (not readable)"
|
||||
else -> "WTF folder: not found"
|
||||
}
|
||||
val interfaceStatus = when {
|
||||
validation.interfaceReadable -> "Interface folder: found"
|
||||
validation.hasInterface -> "Interface folder: found (not readable)"
|
||||
else -> "Interface folder: not found"
|
||||
}
|
||||
Text(
|
||||
"$wtfStatus | $interfaceStatus",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = { viewModel.detectWoWLocation() }) {
|
||||
Text("Auto-detect")
|
||||
}
|
||||
|
||||
// === Backup Settings ===
|
||||
SectionHeader("Backup Settings")
|
||||
|
||||
PathField(
|
||||
label = "Backup destination",
|
||||
value = config.backupPath ?: "",
|
||||
error = errors["backupPath"],
|
||||
onBrowse = {
|
||||
val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) })
|
||||
if (dir != null) viewModel.updateBackupPath(dir.absolutePath)
|
||||
},
|
||||
)
|
||||
|
||||
Text("Daily backup time", style = MaterialTheme.typography.bodyMedium)
|
||||
TimePicker(
|
||||
value = config.backupTimeOfDay,
|
||||
onValueChange = { viewModel.updateBackupTime(it) },
|
||||
modifier = Modifier.width(240.dp),
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HistoryCountSelector(
|
||||
value = config.backupHistoryCount,
|
||||
onValueChange = { viewModel.updateBackupHistoryCount(it) },
|
||||
)
|
||||
}
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.forceBackupWhileRunning,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
showForceBackupDialog = true
|
||||
} else {
|
||||
viewModel.updateForceBackup(false)
|
||||
}
|
||||
},
|
||||
label = "Allow backup while WoW is running",
|
||||
)
|
||||
|
||||
// === Folders to Backup ===
|
||||
SectionHeader("Folders to Backup")
|
||||
|
||||
if (errors.containsKey("folders")) {
|
||||
Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.backupWtf,
|
||||
onCheckedChange = { viewModel.updateBackupWtf(it) },
|
||||
label = "WTF",
|
||||
description = "Contains account settings, keybinds, macros, and addon saved variables.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.backupInterface,
|
||||
onCheckedChange = { viewModel.updateBackupInterface(it) },
|
||||
label = "Interface",
|
||||
description = "Contains installed addons and their configuration.",
|
||||
)
|
||||
|
||||
// === Options ===
|
||||
SectionHeader("Options")
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.compressionEnabled,
|
||||
onCheckedChange = { viewModel.updateCompression(it) },
|
||||
label = "Compression",
|
||||
description = "Compress backups to save disk space. Slightly slower backup/restore.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.notificationsEnabled,
|
||||
onCheckedChange = { viewModel.updateNotifications(it) },
|
||||
label = "Notifications",
|
||||
description = "Show system notifications for backup events.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.runAtStartup,
|
||||
onCheckedChange = { viewModel.updateRunAtStartup(it) },
|
||||
label = "Run at startup",
|
||||
description = "Launch WoW Backup automatically when you log in.",
|
||||
)
|
||||
|
||||
// === Footer ===
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (uiState.saveSuccess) {
|
||||
Text(
|
||||
"Saved!",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.resetToSaved()
|
||||
onCancel()
|
||||
}) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
if (viewModel.save()) {
|
||||
onSaved()
|
||||
}
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force backup confirmation dialog
|
||||
if (showForceBackupDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Allow backup while WoW is running?",
|
||||
message = "Backing up while WoW is running may capture an inconsistent state. " +
|
||||
"Saved variables and settings files may be partially written. " +
|
||||
"Only enable this if you understand the risk.",
|
||||
confirmLabel = "Enable",
|
||||
onConfirm = {
|
||||
viewModel.updateForceBackup(true)
|
||||
showForceBackupDialog = false
|
||||
},
|
||||
onDismiss = { showForceBackupDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Column {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PathField(
|
||||
label: String,
|
||||
value: String,
|
||||
error: String?,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
isError = error != null,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onBrowse) {
|
||||
Text("Browse")
|
||||
}
|
||||
}
|
||||
if (error != null) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckboxRow(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
if (description != null) {
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryCountSelector(
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { onValueChange(value - 1) },
|
||||
enabled = value > 1,
|
||||
) {
|
||||
Text("-")
|
||||
}
|
||||
Text(
|
||||
"$value",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.width(32.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onValueChange(value + 1) },
|
||||
enabled = value < 30,
|
||||
) {
|
||||
Text("+")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.rukira.wowbackup.ui.config
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.rukira.wowbackup.config.AppConfig
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import com.rukira.wowbackup.platform.WoWLocations
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import com.rukira.wowbackup.platform.WoWValidationResult
|
||||
import java.io.File
|
||||
import java.time.LocalTime
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
data class ConfigUiState(
|
||||
val config: AppConfig = AppConfig(),
|
||||
val errors: Map<String, String> = emptyMap(),
|
||||
val warnings: Map<String, List<String>> = emptyMap(),
|
||||
val saveSuccess: Boolean = false,
|
||||
val wowValidation: WoWValidationResult? = null,
|
||||
)
|
||||
|
||||
class ConfigViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ConfigUiState(config = ConfigManager.config.value))
|
||||
val state: StateFlow<ConfigUiState> = _state.asStateFlow()
|
||||
|
||||
fun updateWoWPath(path: String) {
|
||||
val dir = File(path)
|
||||
val validation = if (dir.exists()) WoWLocations.validateWoWInstall(dir) else null
|
||||
|
||||
// Use the resolved path (e.g. root WoW folder resolves to _retail_)
|
||||
val resolvedPath = validation?.resolvedDir?.absolutePath ?: path
|
||||
|
||||
_state.update {
|
||||
val newErrors = it.errors.toMutableMap()
|
||||
val newWarnings = it.warnings.toMutableMap()
|
||||
|
||||
if (validation == null) {
|
||||
newErrors["wowPath"] = "Directory does not exist."
|
||||
newWarnings.remove("wowPath")
|
||||
} else if (!validation.isValid) {
|
||||
newErrors["wowPath"] = validation.error ?: "Not a valid WoW installation."
|
||||
newWarnings.remove("wowPath")
|
||||
} else {
|
||||
newErrors.remove("wowPath")
|
||||
if (validation.warnings.isNotEmpty()) {
|
||||
newWarnings["wowPath"] = validation.warnings
|
||||
} else {
|
||||
newWarnings.remove("wowPath")
|
||||
}
|
||||
}
|
||||
|
||||
it.copy(
|
||||
config = it.config.copy(wowInstallPath = resolvedPath),
|
||||
errors = newErrors,
|
||||
warnings = newWarnings,
|
||||
wowValidation = validation,
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (resolvedPath != path) {
|
||||
logger.info { "WoW path resolved: $path -> $resolvedPath" }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupPath(path: String) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupPath = path),
|
||||
errors = it.errors - "backupPath",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupTime(time: LocalTime) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(backupTimeOfDay = time), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupHistoryCount(count: Int) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(backupHistoryCount = count.coerceIn(1, 30)), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateForceBackup(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(forceBackupWhileRunning = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupWtf(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupWtf = enabled),
|
||||
errors = it.errors - "folders",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupInterface(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupInterface = enabled),
|
||||
errors = it.errors - "folders",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCompression(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(compressionEnabled = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotifications(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(notificationsEnabled = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRunAtStartup(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(runAtStartup = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun detectWoWLocation() {
|
||||
val detected = WoWLocations.findWoWInstall()
|
||||
if (detected != null) {
|
||||
updateWoWPath(detected.absolutePath)
|
||||
logger.info { "Auto-detected WoW at: $detected" }
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(errors = it.errors + ("wowPath" to "Could not find WoW in default locations."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(): Boolean {
|
||||
val errors = validate()
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.update { it.copy(errors = errors, saveSuccess = false) }
|
||||
return false
|
||||
}
|
||||
|
||||
ConfigManager.save(_state.value.config)
|
||||
_state.update { it.copy(errors = emptyMap(), saveSuccess = true) }
|
||||
logger.info { "Configuration saved" }
|
||||
return true
|
||||
}
|
||||
|
||||
fun resetToSaved() {
|
||||
_state.value = ConfigUiState(config = ConfigManager.config.value)
|
||||
}
|
||||
|
||||
private fun validate(): Map<String, String> {
|
||||
val config = _state.value.config
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
// WoW path
|
||||
val wowPath = config.wowInstallPath
|
||||
if (wowPath.isNullOrBlank()) {
|
||||
errors["wowPath"] = "WoW install location is required."
|
||||
} else {
|
||||
val dir = File(wowPath)
|
||||
if (!dir.exists()) {
|
||||
errors["wowPath"] = "Directory does not exist."
|
||||
} else {
|
||||
val validation = WoWLocations.validateWoWInstall(dir)
|
||||
if (!validation.isValid) {
|
||||
errors["wowPath"] = validation.error ?: "Not a valid WoW installation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup path
|
||||
val backupPath = config.backupPath
|
||||
if (backupPath.isNullOrBlank()) {
|
||||
errors["backupPath"] = "Backup destination is required."
|
||||
} else {
|
||||
val dir = File(backupPath)
|
||||
if (!dir.exists()) {
|
||||
errors["backupPath"] = "Directory does not exist."
|
||||
} else if (!dir.canWrite()) {
|
||||
errors["backupPath"] = "Directory is not writable."
|
||||
}
|
||||
}
|
||||
|
||||
// At least one folder
|
||||
if (!config.backupWtf && !config.backupInterface) {
|
||||
errors["folders"] = "At least one folder must be selected for backup."
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
11
composeApp/src/jvmMain/resources/logback.xml
Normal file
11
composeApp/src/jvmMain/resources/logback.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
</configuration>
|
||||
Loading…
Add table
Add a link
Reference in a new issue