Config screen

This commit is contained in:
Rukira 2026-03-04 14:19:19 +00:00
parent ac88e62abe
commit 4b3c512a9d
28 changed files with 2015 additions and 59 deletions

View file

@ -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,
)
}
}

View file

@ -1,9 +0,0 @@
package com.rukira.wowbackup
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View file

@ -1,7 +0,0 @@
package com.rukira.wowbackup
class JVMPlatform {
val name: String = "Java ${System.getProperty("java.version")}"
}
fun getPlatform() = JVMPlatform()

View file

@ -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()
}

View file

@ -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" }
}
}
}

View 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)
}
}

View file

@ -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 },
)
}
}
}
}

View file

@ -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")
}
}
}

View file

@ -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
}
}
}

View file

@ -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(),
)

View file

@ -0,0 +1,7 @@
package com.rukira.wowbackup.ui
enum class Screen {
STATUS,
CONFIG,
RESTORE,
}

View file

@ -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),
)
}
}

View file

@ -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)
}
},
)
}

View file

@ -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
}
}

View file

@ -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
},
)
}
}
}
}

View file

@ -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("+")
}
}
}

View file

@ -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
}
}