Compare commits
5 commits
5cdcc9a490
...
17ba07deef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ba07deef | ||
|
|
d738b133d0 | ||
|
|
4036febb70 | ||
|
|
44656c81d5 | ||
|
|
923835203a |
14 changed files with 492 additions and 35 deletions
94
.gitea/issue_template/bug_report.yaml
Normal file
94
.gitea/issue_template/bug_report.yaml
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
name: Bug Report
|
||||||
|
about: Something not working right? Let us know so we can fix it!
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report this! The more detail you can give, the faster we can track it down.
|
||||||
|
Don't worry about being too technical — just describe what happened in your own words.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: Tell us what went wrong. What did you see or experience?
|
||||||
|
placeholder: "e.g. The app froze after I clicked Save, and my backup never ran."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: What did you expect to happen?
|
||||||
|
placeholder: "e.g. I expected my settings to save and the backup to start at the scheduled time."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: |
|
||||||
|
Walk us through exactly what you did before the problem showed up.
|
||||||
|
Try to be specific — even small details can help! Number each step.
|
||||||
|
placeholder: |
|
||||||
|
1. Opened WoW Backup
|
||||||
|
2. Went to Settings
|
||||||
|
3. Changed the backup time to 5:00 PM
|
||||||
|
4. Clicked Save
|
||||||
|
5. The app froze and stopped responding
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: frequency
|
||||||
|
attributes:
|
||||||
|
label: How often does this happen?
|
||||||
|
options:
|
||||||
|
- Every time
|
||||||
|
- Sometimes
|
||||||
|
- It only happened once
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
options:
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: You can find this in the app's title bar or About screen.
|
||||||
|
placeholder: "e.g. 1.0.0"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Log file
|
||||||
|
description: |
|
||||||
|
Logs help us understand what went wrong behind the scenes. Here's how to find them:
|
||||||
|
|
||||||
|
**macOS:** Open Finder, press **Cmd+Shift+G**, and paste: `~/Library/Application Support/WoWBackup/logs`
|
||||||
|
**Windows:** Press **Win+R**, and paste: `%APPDATA%\WoWBackup\logs`
|
||||||
|
|
||||||
|
You can also open this folder from the app: go to **Settings** and click the **Open Logs** button at the bottom.
|
||||||
|
|
||||||
|
Attach the most recent `wowbackup.log` file by dragging it into this text box.
|
||||||
|
placeholder: Drag your log file here, or paste any relevant log lines.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: Screenshots, screen recordings, or any other context that might help.
|
||||||
|
|
@ -29,10 +29,38 @@ kotlin {
|
||||||
implementation(libs.cardiologist)
|
implementation(libs.cardiologist)
|
||||||
implementation(libs.kotlin.logging.jvm)
|
implementation(libs.kotlin.logging.jvm)
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
|
implementation(libs.material.kolor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val appVersion = "0.1.0"
|
||||||
|
|
||||||
|
val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin")
|
||||||
|
|
||||||
|
val generateBuildConfig by tasks.registering {
|
||||||
|
val outputDir = generatedSrcDir
|
||||||
|
val version = appVersion
|
||||||
|
outputs.dir(outputDir)
|
||||||
|
doLast {
|
||||||
|
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
|
||||||
|
dir.mkdirs()
|
||||||
|
dir.resolve("BuildConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.rukira.wowbackup
|
||||||
|
|
|
||||||
|
|object BuildConfig {
|
||||||
|
| const val VERSION = "$version"
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin.sourceSets.named("jvmMain") {
|
||||||
|
kotlin.srcDir(generateBuildConfig)
|
||||||
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "com.rukira.wowbackup.MainKt"
|
mainClass = "com.rukira.wowbackup.MainKt"
|
||||||
|
|
@ -42,7 +70,7 @@ compose.desktop {
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
packageName = "com.rukira.wowbackup"
|
packageName = "com.rukira.wowbackup"
|
||||||
packageVersion = "1.0.0"
|
packageVersion = appVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,28 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.rukira.wowbackup.config.ConfigManager
|
||||||
import com.rukira.wowbackup.ui.Screen
|
import com.rukira.wowbackup.ui.Screen
|
||||||
import com.rukira.wowbackup.ui.config.ConfigScreen
|
import com.rukira.wowbackup.ui.config.ConfigScreen
|
||||||
import com.rukira.wowbackup.ui.config.ConfigViewModel
|
import com.rukira.wowbackup.ui.config.ConfigViewModel
|
||||||
import com.rukira.wowbackup.ui.status.StatusScreen
|
import com.rukira.wowbackup.ui.status.StatusScreen
|
||||||
import com.rukira.wowbackup.ui.status.StatusViewModel
|
import com.rukira.wowbackup.ui.status.StatusViewModel
|
||||||
|
import com.rukira.wowbackup.ui.theme.WoWBackupTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
currentScreen: Screen,
|
currentScreen: Screen,
|
||||||
onNavigate: (Screen) -> Unit,
|
onNavigate: (Screen) -> Unit,
|
||||||
) {
|
) {
|
||||||
MaterialTheme {
|
val config by ConfigManager.config.collectAsState()
|
||||||
|
|
||||||
|
WoWBackupTheme(themeMode = config.themeMode, accentColor = config.accentColor) {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
Screen.STATUS -> {
|
Screen.STATUS -> {
|
||||||
|
|
@ -48,6 +54,7 @@ fun App(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderScreen(title: String, subtitle: String) {
|
private fun PlaceholderScreen(title: String, subtitle: String) {
|
||||||
Column(
|
Column(
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,29 @@ class BackupEntry(
|
||||||
val sizeBytes: Long by lazy { sizeComputer() }
|
val sizeBytes: Long by lazy { sizeComputer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class BackupMetrics(val fileCount: Int, val sizeBytes: Long)
|
||||||
|
|
||||||
object BackupHistory {
|
object BackupHistory {
|
||||||
|
|
||||||
// Used internally for parsing — java.time formatter since kotlinx.datetime
|
// Used internally for parsing — java.time formatter since kotlinx.datetime
|
||||||
// doesn't have custom format patterns built in
|
// doesn't have custom format patterns built in
|
||||||
private val JAVA_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
|
private val JAVA_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts backups without parsing timestamps or sorting — just matches the naming pattern.
|
||||||
|
*/
|
||||||
|
fun countBackups(backupDir: File): Int {
|
||||||
|
if (!backupDir.exists() || !backupDir.isDirectory) return 0
|
||||||
|
return backupDir.listFiles()?.count { file ->
|
||||||
|
val name = file.nameWithoutExtension
|
||||||
|
val isZip = file.extension.equals("zip", ignoreCase = true) && file.isFile
|
||||||
|
val isDir = file.isDirectory
|
||||||
|
if (!isZip && !isDir) return@count false
|
||||||
|
try { java.time.LocalDateTime.parse(name, JAVA_TIMESTAMP_FORMAT); true }
|
||||||
|
catch (_: DateTimeParseException) { false }
|
||||||
|
} ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all existing backups in the backup directory, sorted newest first.
|
* Lists all existing backups in the backup directory, sorted newest first.
|
||||||
*/
|
*/
|
||||||
|
|
@ -37,13 +54,18 @@ object BackupHistory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the file count for a backup entry without computing its full size.
|
* Computes file count and size in a single pass over the backup tree.
|
||||||
*/
|
*/
|
||||||
fun fileCount(entry: BackupEntry): Int {
|
fun computeMetrics(entry: BackupEntry): BackupMetrics {
|
||||||
return if (entry.isCompressed) {
|
return if (entry.isCompressed) {
|
||||||
java.util.zip.ZipFile(entry.path).use { it.size() }
|
java.util.zip.ZipFile(entry.path).use { zip ->
|
||||||
|
BackupMetrics(zip.size(), entry.path.length())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
entry.path.walkTopDown().count { it.isFile }
|
var count = 0
|
||||||
|
var size = 0L
|
||||||
|
entry.path.walkTopDown().filter { it.isFile }.forEach { count++; size += it.length() }
|
||||||
|
BackupMetrics(count, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,19 @@ object BackupScheduler {
|
||||||
if (schedulerJob?.isActive == true) return
|
if (schedulerJob?.isActive == true) return
|
||||||
logger.info { "Backup scheduler started" }
|
logger.info { "Backup scheduler started" }
|
||||||
|
|
||||||
|
// Eagerly set timestamps so StatusScreen has data immediately.
|
||||||
|
// Only set lastBackupTime and nextBackupTime here (cheap).
|
||||||
|
// The full lastBackupResult (fileCount, sizeBytes) is filled by the async path
|
||||||
|
// since fileCount() walks the directory tree and would block the main thread.
|
||||||
|
val config = ConfigManager.config.value
|
||||||
|
if (config.isConfigured && config.backupPath != null) {
|
||||||
|
val backups = BackupHistory.listBackups(File(config.backupPath))
|
||||||
|
if (backups.isNotEmpty()) {
|
||||||
|
_state.update { it.copy(lastBackupTime = backups.first().timestamp) }
|
||||||
|
}
|
||||||
|
updateNextBackupTime(TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
|
||||||
schedulerJob = scope.launch {
|
schedulerJob = scope.launch {
|
||||||
// Collect progress from BackupEngine
|
// Collect progress from BackupEngine
|
||||||
launch {
|
launch {
|
||||||
|
|
@ -65,23 +78,23 @@ object BackupScheduler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// On first config emission, restore last backup info from disk
|
// On first config emission, restore last backup info from disk
|
||||||
if (_state.value.lastBackupTime == null && config.backupPath != null) {
|
if (_state.value.lastBackupResult == null && config.backupPath != null) {
|
||||||
val backups = BackupHistory.listBackups(File(config.backupPath))
|
val backups = BackupHistory.listBackups(File(config.backupPath))
|
||||||
if (backups.isNotEmpty()) {
|
if (backups.isNotEmpty()) {
|
||||||
val latest = backups.first()
|
val latest = backups.first()
|
||||||
val fileCount = BackupHistory.fileCount(latest)
|
val metrics = BackupHistory.computeMetrics(latest)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
lastBackupTime = latest.timestamp,
|
lastBackupTime = latest.timestamp,
|
||||||
lastBackupResult = BackupResult.Success(
|
lastBackupResult = BackupResult.Success(
|
||||||
backupPath = latest.path,
|
backupPath = latest.path,
|
||||||
fileCount = fileCount,
|
fileCount = metrics.fileCount,
|
||||||
sizeBytes = latest.sizeBytes,
|
sizeBytes = metrics.sizeBytes,
|
||||||
durationMs = 0,
|
durationMs = 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
logger.info { "Restored last backup info from disk: ${latest.path.name} ($fileCount files)" }
|
logger.info { "Restored last backup info from disk: ${latest.path.name} (${metrics.fileCount} files)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,26 @@
|
||||||
package com.rukira.wowbackup.config
|
package com.rukira.wowbackup.config
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class AccentColor(val displayName: String, private val colorValue: Long) {
|
||||||
|
PURPLE("Purple", 0xFF7C4DFF),
|
||||||
|
BLUE("Blue", 0xFF448AFF),
|
||||||
|
TEAL("Teal", 0xFF1DE9B6),
|
||||||
|
GREEN("Green", 0xFF69F0AE),
|
||||||
|
ORANGE("Orange", 0xFFFF9100),
|
||||||
|
RED("Red", 0xFFFF5252),
|
||||||
|
PINK("Pink", 0xFFFF4081),
|
||||||
|
INDIGO("Indigo", 0xFF536DFE);
|
||||||
|
|
||||||
|
val seedColor: Color get() = Color(colorValue)
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppConfig(
|
data class AppConfig(
|
||||||
val wowInstallPath: String? = null,
|
val wowInstallPath: String? = null,
|
||||||
|
|
@ -15,6 +33,8 @@ data class AppConfig(
|
||||||
val compressionEnabled: Boolean = false,
|
val compressionEnabled: Boolean = false,
|
||||||
val notificationsEnabled: Boolean = true,
|
val notificationsEnabled: Boolean = true,
|
||||||
val runAtStartup: Boolean = false,
|
val runAtStartup: Boolean = false,
|
||||||
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
|
val accentColor: AccentColor = AccentColor.PURPLE,
|
||||||
) {
|
) {
|
||||||
val isConfigured: Boolean
|
val isConfigured: Boolean
|
||||||
get() = !wowInstallPath.isNullOrBlank() && !backupPath.isNullOrBlank()
|
get() = !wowInstallPath.isNullOrBlank() && !backupPath.isNullOrBlank()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.WindowPosition
|
import androidx.compose.ui.window.WindowPosition
|
||||||
import androidx.compose.ui.window.WindowState
|
import androidx.compose.ui.window.WindowState
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.TrayState
|
|
||||||
import androidx.compose.ui.window.rememberTrayState
|
import androidx.compose.ui.window.rememberTrayState
|
||||||
import com.rukira.wowbackup.backup.BackupNotifier
|
import com.rukira.wowbackup.backup.BackupNotifier
|
||||||
import com.rukira.wowbackup.backup.BackupScheduler
|
import com.rukira.wowbackup.backup.BackupScheduler
|
||||||
|
|
@ -19,6 +18,7 @@ import com.rukira.wowbackup.config.ConfigManager
|
||||||
import com.rukira.wowbackup.logging.LoggingSetup
|
import com.rukira.wowbackup.logging.LoggingSetup
|
||||||
import com.rukira.wowbackup.platform.OS
|
import com.rukira.wowbackup.platform.OS
|
||||||
import com.rukira.wowbackup.platform.Platform
|
import com.rukira.wowbackup.platform.Platform
|
||||||
|
import com.rukira.wowbackup.platform.StartupManager
|
||||||
import com.rukira.wowbackup.platform.WoWLocations
|
import com.rukira.wowbackup.platform.WoWLocations
|
||||||
import com.rukira.wowbackup.ui.Screen
|
import com.rukira.wowbackup.ui.Screen
|
||||||
import com.rukira.wowbackup.ui.trayIconPainter
|
import com.rukira.wowbackup.ui.trayIconPainter
|
||||||
|
|
@ -34,19 +34,21 @@ fun main() {
|
||||||
ConfigManager.load()
|
ConfigManager.load()
|
||||||
logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" }
|
logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" }
|
||||||
|
|
||||||
|
// Sync OS startup registration with persisted config
|
||||||
|
StartupManager.setEnabled(ConfigManager.config.value.runAtStartup)
|
||||||
|
|
||||||
val detectedWoW = WoWLocations.findWoWInstall()
|
val detectedWoW = WoWLocations.findWoWInstall()
|
||||||
if (detectedWoW != null) {
|
if (detectedWoW != null) {
|
||||||
logger.info { "Auto-detected WoW at: $detectedWoW" }
|
logger.info { "Auto-detected WoW at: $detectedWoW" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
|
val isConfigured = ConfigManager.isConfigured
|
||||||
|
|
||||||
// Start backup scheduler
|
// Start backup scheduler
|
||||||
BackupScheduler.start()
|
BackupScheduler.start()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
var isWindowVisible by remember { mutableStateOf(true) }
|
var currentScreen by remember { mutableStateOf(if (isConfigured) null else Screen.CONFIG) }
|
||||||
var currentScreen by remember { mutableStateOf(startScreen) }
|
|
||||||
val trayState = rememberTrayState()
|
val trayState = rememberTrayState()
|
||||||
|
|
||||||
// Wire up notifications
|
// Wire up notifications
|
||||||
|
|
@ -56,20 +58,10 @@ fun main() {
|
||||||
state = trayState,
|
state = trayState,
|
||||||
icon = trayIconPainter(),
|
icon = trayIconPainter(),
|
||||||
tooltip = "WoW Backup",
|
tooltip = "WoW Backup",
|
||||||
onAction = { isWindowVisible = true },
|
onAction = { currentScreen = Screen.STATUS },
|
||||||
menu = {
|
menu = {
|
||||||
Item("Status", onClick = {
|
Item("Status", onClick = { currentScreen = Screen.STATUS })
|
||||||
currentScreen = Screen.STATUS
|
Item("Settings", onClick = { currentScreen = Screen.CONFIG })
|
||||||
isWindowVisible = true
|
|
||||||
})
|
|
||||||
Item("Settings", onClick = {
|
|
||||||
currentScreen = Screen.CONFIG
|
|
||||||
isWindowVisible = true
|
|
||||||
})
|
|
||||||
Separator()
|
|
||||||
Item("Backup Now", onClick = {
|
|
||||||
BackupScheduler.triggerBackupNow()
|
|
||||||
})
|
|
||||||
Separator()
|
Separator()
|
||||||
Item("Quit", onClick = {
|
Item("Quit", onClick = {
|
||||||
BackupScheduler.stop()
|
BackupScheduler.stop()
|
||||||
|
|
@ -78,8 +70,9 @@ fun main() {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isWindowVisible) {
|
val activeScreen = currentScreen
|
||||||
val windowState = remember(isWindowVisible) {
|
if (activeScreen != null) {
|
||||||
|
val windowState = remember(activeScreen) {
|
||||||
WindowState(
|
WindowState(
|
||||||
size = DpSize(480.dp, 620.dp),
|
size = DpSize(480.dp, 620.dp),
|
||||||
position = trayAlignedPosition(),
|
position = trayAlignedPosition(),
|
||||||
|
|
@ -87,13 +80,13 @@ fun main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = { isWindowVisible = false },
|
onCloseRequest = { currentScreen = null },
|
||||||
title = "WoW Backup",
|
title = "WoW Backup",
|
||||||
state = windowState,
|
state = windowState,
|
||||||
resizable = true,
|
resizable = true,
|
||||||
) {
|
) {
|
||||||
App(
|
App(
|
||||||
currentScreen = currentScreen,
|
currentScreen = activeScreen,
|
||||||
onNavigate = { currentScreen = it },
|
onNavigate = { currentScreen = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
package com.rukira.wowbackup.platform
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
object StartupManager {
|
||||||
|
|
||||||
|
private const val APP_ID = "com.rukira.wowbackup"
|
||||||
|
|
||||||
|
fun setEnabled(enabled: Boolean) {
|
||||||
|
val appPath = getAppPath()
|
||||||
|
if (appPath == null) {
|
||||||
|
logger.warn { "Cannot register startup: not running from a packaged app (dev mode?)" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info { "Setting launch-at-startup to $enabled (appPath=$appPath)" }
|
||||||
|
|
||||||
|
when (Platform.current) {
|
||||||
|
OS.Mac -> if (enabled) enableMac(appPath) else disableMac()
|
||||||
|
OS.Windows -> if (enabled) enableWindows(appPath) else disableWindows()
|
||||||
|
else -> logger.warn { "Launch at startup is not supported on ${Platform.current}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAppPath(): String? = when (Platform.current) {
|
||||||
|
OS.Mac -> {
|
||||||
|
// java.home is e.g. /Applications/com.rukira.wowbackup.app/Contents/runtime/Contents/Home
|
||||||
|
val javaHome = System.getProperty("java.home") ?: return null
|
||||||
|
val marker = ".app/Contents"
|
||||||
|
val idx = javaHome.indexOf(marker)
|
||||||
|
if (idx < 0) null else javaHome.substring(0, idx + ".app".length)
|
||||||
|
}
|
||||||
|
OS.Windows -> {
|
||||||
|
ProcessHandle.current().info().command().orElse(null)?.takeIf { it.endsWith(".exe", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- macOS: LaunchAgent plist ---
|
||||||
|
|
||||||
|
private val plistFile: File
|
||||||
|
get() = File(System.getProperty("user.home"), "Library/LaunchAgents/$APP_ID.plist")
|
||||||
|
|
||||||
|
private fun enableMac(appPath: String) {
|
||||||
|
val plist = """
|
||||||
|
|<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
| "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
|<plist version="1.0">
|
||||||
|
|<dict>
|
||||||
|
| <key>Label</key>
|
||||||
|
| <string>$APP_ID</string>
|
||||||
|
| <key>ProgramArguments</key>
|
||||||
|
| <array>
|
||||||
|
| <string>open</string>
|
||||||
|
| <string>-a</string>
|
||||||
|
| <string>$appPath</string>
|
||||||
|
| </array>
|
||||||
|
| <key>RunAtLoad</key>
|
||||||
|
| <true/>
|
||||||
|
|</dict>
|
||||||
|
|</plist>
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
try {
|
||||||
|
plistFile.parentFile.mkdirs()
|
||||||
|
plistFile.writeText(plist)
|
||||||
|
logger.info { "LaunchAgent plist written to ${plistFile.absolutePath}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to write LaunchAgent plist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableMac() {
|
||||||
|
try {
|
||||||
|
if (plistFile.exists()) {
|
||||||
|
plistFile.delete()
|
||||||
|
logger.info { "LaunchAgent plist removed" }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to remove LaunchAgent plist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Windows: Registry ---
|
||||||
|
|
||||||
|
private const val REG_KEY = """HKCU\Software\Microsoft\Windows\CurrentVersion\Run"""
|
||||||
|
private const val REG_VALUE = "WoWBackup"
|
||||||
|
|
||||||
|
private fun enableWindows(appPath: String) {
|
||||||
|
try {
|
||||||
|
val process = ProcessBuilder(
|
||||||
|
"reg", "add", REG_KEY, "/v", REG_VALUE, "/d", appPath, "/f"
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
if (exitCode == 0) {
|
||||||
|
logger.info { "Registry startup entry added" }
|
||||||
|
} else {
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
logger.error { "reg add failed (exit $exitCode): $output" }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to add registry startup entry" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableWindows() {
|
||||||
|
try {
|
||||||
|
val process = ProcessBuilder(
|
||||||
|
"reg", "delete", REG_KEY, "/v", REG_VALUE, "/f"
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
if (exitCode == 0) {
|
||||||
|
logger.info { "Registry startup entry removed" }
|
||||||
|
} else {
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
// Exit code 1 with "not found" is expected when disabling and key doesn't exist
|
||||||
|
logger.warn { "reg delete exited $exitCode: $output" }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to remove registry startup entry" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
package com.rukira.wowbackup.ui.config
|
package com.rukira.wowbackup.ui.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ScrollbarStyle
|
||||||
|
import androidx.compose.foundation.VerticalScrollbar
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
|
@ -17,6 +28,9 @@ import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -26,7 +40,12 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.rukira.wowbackup.config.AccentColor
|
||||||
|
import com.rukira.wowbackup.config.ThemeMode
|
||||||
|
import com.rukira.wowbackup.platform.AppDirectories
|
||||||
|
import com.rukira.wowbackup.platform.DesktopActions
|
||||||
import com.rukira.wowbackup.ui.components.ConfirmationDialog
|
import com.rukira.wowbackup.ui.components.ConfirmationDialog
|
||||||
import com.rukira.wowbackup.ui.components.TimePicker
|
import com.rukira.wowbackup.ui.components.TimePicker
|
||||||
import com.rukira.wowbackup.ui.components.pickFolder
|
import com.rukira.wowbackup.ui.components.pickFolder
|
||||||
|
|
@ -42,11 +61,13 @@ fun ConfigScreen(
|
||||||
val config = uiState.config
|
val config = uiState.config
|
||||||
val errors = uiState.errors
|
val errors = uiState.errors
|
||||||
var showForceBackupDialog by remember { mutableStateOf(false) }
|
var showForceBackupDialog by remember { mutableStateOf(false) }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(scrollState)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
|
@ -185,6 +206,64 @@ fun ConfigScreen(
|
||||||
description = "Launch WoW Backup automatically when you log in.",
|
description = "Launch WoW Backup automatically when you log in.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// === Appearance ===
|
||||||
|
SectionHeader("Appearance")
|
||||||
|
|
||||||
|
Text("Theme", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
ThemeMode.entries.forEachIndexed { index, mode ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = config.themeMode == mode,
|
||||||
|
onClick = { viewModel.updateThemeMode(mode) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when (mode) {
|
||||||
|
ThemeMode.SYSTEM -> "System"
|
||||||
|
ThemeMode.LIGHT -> "Light"
|
||||||
|
ThemeMode.DARK -> "Dark"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Accent color", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
AccentColor.entries.forEach { color ->
|
||||||
|
val isSelected = config.accentColor == color
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color.seedColor, CircleShape)
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable { viewModel.updateAccentColor(color) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Text(
|
||||||
|
"\u2713",
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Theme changes apply immediately.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
// === Footer ===
|
// === Footer ===
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
@ -195,6 +274,10 @@ fun ConfigScreen(
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
OutlinedButton(onClick = { DesktopActions.openFolder(AppDirectories.logsDir) }) {
|
||||||
|
Text("Open Logs")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
if (uiState.saveSuccess) {
|
if (uiState.saveSuccess) {
|
||||||
Text(
|
Text(
|
||||||
"Saved!",
|
"Saved!",
|
||||||
|
|
@ -220,6 +303,20 @@ fun ConfigScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VerticalScrollbar(
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||||
|
adapter = rememberScrollbarAdapter(scrollState),
|
||||||
|
style = ScrollbarStyle(
|
||||||
|
minimalHeight = 48.dp,
|
||||||
|
thickness = 8.dp,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
hoverDurationMillis = 300,
|
||||||
|
unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||||
|
hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Force backup confirmation dialog
|
// Force backup confirmation dialog
|
||||||
if (showForceBackupDialog) {
|
if (showForceBackupDialog) {
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package com.rukira.wowbackup.ui.config
|
package com.rukira.wowbackup.ui.config
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.rukira.wowbackup.config.AccentColor
|
||||||
import com.rukira.wowbackup.config.AppConfig
|
import com.rukira.wowbackup.config.AppConfig
|
||||||
import com.rukira.wowbackup.config.ConfigManager
|
import com.rukira.wowbackup.config.ConfigManager
|
||||||
|
import com.rukira.wowbackup.config.ThemeMode
|
||||||
|
import com.rukira.wowbackup.platform.StartupManager
|
||||||
import com.rukira.wowbackup.platform.WoWLocations
|
import com.rukira.wowbackup.platform.WoWLocations
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -134,6 +137,20 @@ class ConfigViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateThemeMode(mode: ThemeMode) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(config = it.config.copy(themeMode = mode))
|
||||||
|
}
|
||||||
|
ConfigManager.save(_state.value.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAccentColor(color: AccentColor) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(config = it.config.copy(accentColor = color))
|
||||||
|
}
|
||||||
|
ConfigManager.save(_state.value.config)
|
||||||
|
}
|
||||||
|
|
||||||
fun detectWoWLocation() {
|
fun detectWoWLocation() {
|
||||||
val detected = WoWLocations.findWoWInstall()
|
val detected = WoWLocations.findWoWInstall()
|
||||||
if (detected != null) {
|
if (detected != null) {
|
||||||
|
|
@ -154,6 +171,7 @@ class ConfigViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigManager.save(_state.value.config)
|
ConfigManager.save(_state.value.config)
|
||||||
|
StartupManager.setEnabled(_state.value.config.runAtStartup)
|
||||||
_state.update { it.copy(errors = emptyMap(), saveSuccess = true) }
|
_state.update { it.copy(errors = emptyMap(), saveSuccess = true) }
|
||||||
logger.info { "Configuration saved" }
|
logger.info { "Configuration saved" }
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.rukira.wowbackup.BuildConfig
|
||||||
import com.rukira.wowbackup.backup.BackupScheduler
|
import com.rukira.wowbackup.backup.BackupScheduler
|
||||||
import com.rukira.wowbackup.platform.DesktopActions
|
import com.rukira.wowbackup.platform.DesktopActions
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -205,11 +206,20 @@ fun StatusScreen(
|
||||||
},
|
},
|
||||||
enabled = uiState.backupPath != null,
|
enabled = uiState.backupPath != null,
|
||||||
) {
|
) {
|
||||||
Text("Open Folder")
|
Text("Open Backups Folder")
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = onNavigateToRestore, enabled = false) {
|
OutlinedButton(onClick = onNavigateToRestore, enabled = false) {
|
||||||
Text("Restore (Coming Soon)")
|
Text("Restore (Coming Soon)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"WoW Backup v${BuildConfig.VERSION}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class StatusViewModel : ViewModel() {
|
||||||
BackupScheduler.state.map { it.lastBackupResult }.distinctUntilChanged(),
|
BackupScheduler.state.map { it.lastBackupResult }.distinctUntilChanged(),
|
||||||
) { backupPath, _ ->
|
) { backupPath, _ ->
|
||||||
if (backupPath != null) {
|
if (backupPath != null) {
|
||||||
BackupHistory.listBackups(File(backupPath)).size
|
BackupHistory.countBackups(File(backupPath))
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.rukira.wowbackup.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.materialkolor.DynamicMaterialTheme
|
||||||
|
import com.rukira.wowbackup.config.AccentColor
|
||||||
|
import com.rukira.wowbackup.config.ThemeMode
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WoWBackupTheme(
|
||||||
|
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
|
accentColor: AccentColor = AccentColor.PURPLE,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val useDarkTheme = when (themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicMaterialTheme(
|
||||||
|
seedColor = accentColor.seedColor,
|
||||||
|
useDarkTheme = useDarkTheme,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ kotlinx-serialization = "1.10.0"
|
||||||
cardiologist = "0.8.0"
|
cardiologist = "0.8.0"
|
||||||
kotlin-logging = "7.0.3"
|
kotlin-logging = "7.0.3"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
|
materialKolor = "2.0.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
|
|
@ -18,6 +19,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
||||||
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
||||||
kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
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" }
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue