Many Many updates and fixes!

Update to Tachidesk 0.3.7
Update to compose build198
State persistence for the sources menu!
Update setup scripts for using a specific version of Tachidesk
Backup support
Context menu now half works
Server service fixes
Chapter context menu to mark as read and other features
Start screen customization
Extension update and obsolete support
This commit is contained in:
Syer10
2021-05-19 17:27:48 -04:00
parent f92e7baeb8
commit c06ea33112
34 changed files with 885 additions and 208 deletions

View File

@@ -8,7 +8,7 @@ plugins {
kotlin("jvm") version "1.4.32"
kotlin("kapt") version "1.4.32"
kotlin("plugin.serialization") version "1.4.32"
id("org.jetbrains.compose") version "0.4.0-build185"
id("org.jetbrains.compose") version "0.4.0-build198"
id("de.fuerstenau.buildconfig") version "1.1.8"
id("org.jmailen.kotlinter") version "3.4.0"
}
@@ -27,8 +27,8 @@ dependencies {
// UI (Compose)
implementation(compose.desktop.currentOs)
implementation("br.com.devsrsouza.compose.icons.jetbrains:font-awesome:0.2.0")
implementation("com.github.Syer10:compose-router:45a8c4fe83")
implementation("ca.gosyer:accompanist-pager:0.8.1")
implementation("ca.gosyer:compose-router:0.24.2-jetbrains-2")
implementation("ca.gosyer:accompanist-pager:0.9.1")
// UI (Swing)
implementation("com.github.weisj:darklaf-core:2.5.5")
@@ -76,7 +76,7 @@ dependencies {
tasks {
withType<KotlinCompile> {
dependsOn(formatKotlin)
dependsOn(formatKotlinMain)
kotlinOptions {
jvmTarget = "15"
freeCompilerArgs = listOf(
@@ -88,7 +88,8 @@ tasks {
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsApi",
"-Xopt-in=com.russhwolf.settings.ExperimentalSettingsImplementation",
"-Xopt-in=com.google.accompanist.pager.ExperimentalPagerApi",
"-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi"
"-Xopt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-Xopt-in=androidx.compose.material.ExperimentalMaterialApi"
)
}
}
@@ -157,8 +158,8 @@ buildConfig {
packageName = project.group.toString()
buildConfigField("boolean", "DEBUG", project.hasProperty("debugApp").toString())
buildConfigField("String", "TACHIDESK_SP_VERSION", "0.2.7")
buildConfigField("String", "TACHIDESK_IM_VERSION", "0")
buildConfigField("String", "TACHIDESK_SP_VERSION", "v0.3.7")
buildConfigField("String", "TACHIDESK_IM_VERSION", "r66")
}
kotlinter {

View File

@@ -7,13 +7,14 @@ fi
mkdir -p "tmp"
echo "Getting latest Tachidesk build files"
TARBALL_LINK="$(curl -s "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" | grep -o "https.*tarball\/[a-zA-Z0-9.]*")"
#TARBALL_LINK="$(curl -s "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" | grep -o "https.*tarball\/[a-zA-Z0-9.]*")"
curl -L "$TARBALL_LINK" -o tmp/Tachidesk.tar
#curl -L "$TARBALL_LINK" -o tmp/Tachidesk.tar
curl -L "https://github.com/Suwayomi/Tachidesk/archive/refs/tags/v0.3.7.tar.gz" -o tmp/Tachidesk.tar.gz
tar -xvf tmp/Tachidesk.tar -C tmp
tar -xvf tmp/Tachidesk.tar.gz -C tmp
TACHIDESK_FOLDER=$(find tmp -type d -regex ".*Tachidesk-[a-z0-9]*")
TACHIDESK_FOLDER=$(find tmp -type d -regex ".*Tachidesk-[a-z0-9\.]*")
pushd "$TACHIDESK_FOLDER" || exit

View File

@@ -6,13 +6,15 @@ Remove-Item -Recurse -Force "tmp" -ErrorAction SilentlyContinue | Out-Null
New-Item -ItemType Directory -Force -Path "tmp"
Write-Output "Getting latest Tachidesk build files"
$zipball = (Invoke-WebRequest -Uri "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" -UseBasicParsing).content | Select-String -Pattern 'https[\.:\/A-Za-z0-9]*zipball\/[a-zA-Z0-9.]*' -CaseSensitive
#$zipball = (Invoke-WebRequest -Uri "https://api.github.com/repos/Suwayomi/Tachidesk/releases/latest" -UseBasicParsing).content | Select-String -Pattern 'https[\.:\/A-Za-z0-9]*zipball\/[a-zA-Z0-9.]*' -CaseSensitive
Invoke-WebRequest -Uri $zipball.Matches.Value -OutFile tmp/Tachidesk.zip -UseBasicParsing
#Invoke-WebRequest -Uri $zipball.Matches.Value -OutFile tmp/Tachidesk.zip -UseBasicParsing
Invoke-WebRequest -Uri "https://github.com/Suwayomi/Tachidesk/archive/refs/tags/v0.3.7.zip" -OutFile tmp/Tachidesk.zip -UseBasicParsing
Expand-Archive -Path "tmp/Tachidesk.zip" -DestinationPath "tmp"
$tachidesk_folder = Get-ChildItem -Path "tmp" | Where-Object {$_.Name -match ".*Tachidesk-[a-z0-9]*"} | Select-Object FullName
$tachidesk_folder = Get-ChildItem -Path "tmp" | Where-Object {$_.Name -match ".*Tachidesk-[a-z0-9\.]*"} | Select-Object FullName
Push-Location $tachidesk_folder.FullName
@@ -27,7 +29,7 @@ $tachidesk_jar = $(Get-ChildItem "server/build" | Where-Object { $_.Name -match
Pop-Location
Write-Output "Copying Tachidesk.jar to resources folder..."
Move-Item -Force $tachidesk_jar "src/main/resources/Tachidesk.jar"
Move-Item -Force $tachidesk_jar "src/main/resources/Tachidesk.jar" -ErrorAction SilentlyContinue
Write-Output "Cleaning up..."
Remove-Item -Recurse -Force "tmp" | Out-Null

View File

@@ -0,0 +1,15 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.data.models
import kotlinx.serialization.Serializable
@Serializable
data class About(
val version: String,
val revision: String,
)

View File

@@ -0,0 +1,55 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.data.models
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Backup(
val categories: List<List<@Contextual Any?>> = emptyList(),
val mangas: List<Manga>,
val version: Int = 1
) {
@Serializable
data class Manga(
val manga: List<@Contextual Any?>,
val chapters: List<Chapter> = emptyList(),
val categories: List<String> = emptyList(),
val history: List<List<@Contextual Any?>> = emptyList(),
val track: List<Track> = emptyList()
)
@Serializable
data class Chapter(
@SerialName("u")
val url: String,
@SerialName("r")
val read: Int = 0,
@SerialName("b")
val bookmarked: Int = 0,
@SerialName("l")
val lastRead: Int = 0
)
@Serializable
data class Track(
@SerialName("l")
val lastRead: Int,
@SerialName("ml")
val libraryId: Int,
@SerialName("r")
val mediaId: Int,
@SerialName("s")
val syncId: Int,
@SerialName("t")
val title: String,
@SerialName("u")
val url: String
)
}

View File

@@ -6,21 +6,20 @@
package ca.gosyer.data.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Chapter(
val id: Long,
val url: String,
val name: String,
@SerialName("date_upload")
val dateUpload: Long,
@SerialName("chapter_number")
val uploadDate: Long,
val chapterNumber: Float,
val scanlator: String?,
val mangaId: Long,
val pageCount: Int? = null,
val chapterIndex: Int,
val chapterCount: Int
val read: Boolean,
val bookmarked: Boolean,
val lastPageRead: Int,
val index: Int,
val chapterCount: Int?,
val pageCount: Int?,
)

View File

@@ -18,7 +18,8 @@ data class Extension(
val apkName: String,
val iconUrl: String,
val installed: Boolean,
val classFQName: String,
val hasUpdate: Boolean,
val obsolete: Boolean,
val nsfw: Boolean
) {
fun iconUrl(serverUrl: String) = serverUrl + iconUrl

View File

@@ -14,15 +14,16 @@ data class Manga(
val sourceId: Long,
val url: String,
val title: String,
val thumbnailUrl: String? = null,
val initialized: Boolean = false,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: String? = null,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: String?,
val status: String,
val inLibrary: Boolean = false,
val source: Source?
val inLibrary: Boolean,
val source: Source?,
val freshData: Boolean
) {
fun cover(serverUrl: String) = thumbnailUrl?.let { serverUrl + it }
}

View File

@@ -34,6 +34,7 @@ class ServerService @Inject constructor(
ServerResult.UNUSED
}
)
private val runtime = Runtime.getRuntime()
var process: Process? = null
private fun copyJar(jarFile: File) {
@@ -45,6 +46,12 @@ class ServerService @Inject constructor(
}
init {
runtime.addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
host.onEach {
process?.destroy()
initialized.value = if (host.value) {
@@ -55,7 +62,6 @@ class ServerService @Inject constructor(
}
GlobalScope.launch {
val logger = KotlinLogging.logger("Server")
val runtime = Runtime.getRuntime()
val jarFile = File(userDataDir, "Tachidesk.jar")
if (!jarFile.exists()) {
@@ -71,12 +77,7 @@ class ServerService @Inject constructor(
)
}
}
// TODO remove 1.0 version check when we move onto 0.3.0 of Tachidesk
if (
jarVersion.specification != null &&
jarVersion.implementation != null &&
jarVersion.implementation != "1.0"
) {
if (
jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION ||
jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION
@@ -84,7 +85,6 @@ class ServerService @Inject constructor(
logger.info { "Updating server file from resources" }
copyJar(jarFile)
}
}
} catch (e: IOException) {
logger.error(e) {
"Error accessing server jar, cannot update server, ${BuildConfig.NAME} may not work properly"
@@ -106,12 +106,6 @@ class ServerService @Inject constructor(
process = runtime.exec("""$javaExePath -jar "${jarFile.absolutePath}"""").also {
reader = it.inputStream.bufferedReader()
}
runtime.addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
logger.info { "Server started successfully" }
var line: String?
while (reader.readLine().also { line = it } != null) {
@@ -124,6 +118,9 @@ class ServerService @Inject constructor(
}
logger.info { line }
}
if (initialized.value == ServerResult.STARTING) {
initialized.value = ServerResult.FAILED
}
logger.info { "Server closed" }
val exitVal = process?.waitFor()
logger.info { "Process exitValue: $exitVal" }

View File

@@ -0,0 +1,69 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.data.server.interactions
import ca.gosyer.data.models.Backup
import ca.gosyer.data.server.Http
import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.backupExportRequest
import ca.gosyer.data.server.requests.backupFileExportRequest
import ca.gosyer.data.server.requests.backupFileImportRequest
import ca.gosyer.data.server.requests.backupImportRequest
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.content.MultiPartData
import io.ktor.http.contentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
class BackupInteractionHandler @Inject constructor(
client: Http,
serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, serverPreferences) {
suspend fun importBackupFile(file: File) = withContext(Dispatchers.IO) {
client.submitFormWithBinaryData<HttpResponse>(
serverUrl + backupFileImportRequest(),
formData = formData {
append(
"backup.json", file.readBytes(),
Headers.build {
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
append(HttpHeaders.ContentDisposition, "filename=backup.json")
}
)
}
)
}
suspend fun importBackup(backup: Backup) = withContext(Dispatchers.IO) {
client.postRepeat<HttpResponse>(
serverUrl + backupImportRequest()
) {
contentType(ContentType.Application.Json)
body = backup
}
}
suspend fun exportBackupFile() = withContext(Dispatchers.IO) {
client.getRepeat<MultiPartData>(
serverUrl + backupFileExportRequest()
)
}
suspend fun exportBackup() = withContext(Dispatchers.IO) {
client.getRepeat<Backup>(
serverUrl + backupExportRequest()
)
}
}

View File

@@ -13,6 +13,11 @@ import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.getChapterQuery
import ca.gosyer.data.server.requests.getMangaChaptersQuery
import ca.gosyer.data.server.requests.getPageQuery
import ca.gosyer.data.server.requests.updateChapterRequest
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpMethod
import io.ktor.http.Parameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -22,17 +27,19 @@ class ChapterInteractionHandler @Inject constructor(
serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, serverPreferences) {
suspend fun getChapters(mangaId: Long) = withContext(Dispatchers.IO) {
suspend fun getChapters(mangaId: Long, refresh: Boolean = false) = withContext(Dispatchers.IO) {
client.getRepeat<List<Chapter>>(
serverUrl + getMangaChaptersQuery(mangaId)
)
) {
url {
if (refresh) {
parameter("onlineFetch", true)
}
}
}
}
suspend fun getChapters(manga: Manga) = withContext(Dispatchers.IO) {
client.getRepeat<Chapter>(
serverUrl + getMangaChaptersQuery(manga.id)
)
}
suspend fun getChapters(manga: Manga, refresh: Boolean = false) = getChapters(manga.id, refresh)
suspend fun getChapter(mangaId: Long, chapterIndex: Int) = withContext(Dispatchers.IO) {
client.getRepeat<Chapter>(
@@ -40,11 +47,72 @@ class ChapterInteractionHandler @Inject constructor(
)
}
suspend fun getChapter(chapter: Chapter) = getChapter(chapter.mangaId, chapter.chapterIndex)
suspend fun getChapter(chapter: Chapter) = getChapter(chapter.mangaId, chapter.index)
suspend fun getChapter(manga: Manga, chapterIndex: Int) = getChapter(manga.id, chapterIndex)
suspend fun getChapter(manga: Manga, chapter: Chapter) = getChapter(manga.id, chapter.chapterIndex)
suspend fun getChapter(manga: Manga, chapter: Chapter) = getChapter(manga.id, chapter.index)
suspend fun updateChapter(
mangaId: Long,
chapterIndex: Int,
read: Boolean? = null,
bookmarked: Boolean? = null,
lastPageRead: Int? = null,
markPreviousRead: Boolean? = null
) = withContext(Dispatchers.IO) {
client.submitFormRepeat<HttpResponse>(
serverUrl + updateChapterRequest(mangaId, chapterIndex),
formParameters = Parameters.build {
if (read != null) {
append("read", read.toString())
}
if (bookmarked != null) {
append("bookmarked", bookmarked.toString())
}
if (lastPageRead != null) {
append("lastPageRead", lastPageRead.toString())
}
if (markPreviousRead != null) {
append("markPrevRead", markPreviousRead.toString())
}
}
) {
method = HttpMethod.Patch
}
}
suspend fun updateChapter(
manga: Manga,
chapterIndex: Int,
read: Boolean? = null,
bookmarked: Boolean? = null,
lastPageRead: Int? = null,
markPreviousRead: Boolean? = null
) = updateChapter(
manga.id,
chapterIndex,
read,
bookmarked,
lastPageRead,
markPreviousRead
)
suspend fun updateChapter(
manga: Manga,
chapter: Chapter,
read: Boolean? = null,
bookmarked: Boolean? = null,
lastPageRead: Int? = null,
markPreviousRead: Boolean? = null
) = updateChapter(
manga.id,
chapter.index,
read,
bookmarked,
lastPageRead,
markPreviousRead
)
suspend fun getPage(mangaId: Long, chapterIndex: Int, pageNum: Int) = withContext(Dispatchers.IO) {
imageFromUrl(
@@ -53,9 +121,9 @@ class ChapterInteractionHandler @Inject constructor(
)
}
suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.chapterIndex, pageNum)
suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.index, pageNum)
suspend fun getPage(manga: Manga, chapterIndex: Int, pageNum: Int) = getPage(manga.id, chapterIndex, pageNum)
suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.chapterIndex, pageNum)
suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.index, pageNum)
}

View File

@@ -12,6 +12,7 @@ import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.apkIconQuery
import ca.gosyer.data.server.requests.apkInstallQuery
import ca.gosyer.data.server.requests.apkUninstallQuery
import ca.gosyer.data.server.requests.apkUpdateQuery
import ca.gosyer.data.server.requests.extensionListQuery
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Dispatchers
@@ -31,13 +32,19 @@ class ExtensionInteractionHandler @Inject constructor(
suspend fun installExtension(extension: Extension) = withContext(Dispatchers.IO) {
client.getRepeat<HttpResponse>(
serverUrl + apkInstallQuery(extension.apkName)
serverUrl + apkInstallQuery(extension.pkgName)
)
}
suspend fun updateExtension(extension: Extension) = withContext(Dispatchers.IO) {
client.getRepeat<HttpResponse>(
serverUrl + apkUpdateQuery(extension.pkgName)
)
}
suspend fun uninstallExtension(extension: Extension) = withContext(Dispatchers.IO) {
client.getRepeat<HttpResponse>(
serverUrl + apkUninstallQuery(extension.apkName)
serverUrl + apkUninstallQuery(extension.pkgName)
)
}

View File

@@ -11,6 +11,7 @@ import ca.gosyer.data.server.Http
import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.mangaQuery
import ca.gosyer.data.server.requests.mangaThumbnailQuery
import io.ktor.client.request.parameter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -20,11 +21,19 @@ class MangaInteractionHandler @Inject constructor(
serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, serverPreferences) {
suspend fun getManga(mangaId: Long) = withContext(Dispatchers.IO) {
suspend fun getManga(mangaId: Long, refresh: Boolean = false) = withContext(Dispatchers.IO) {
client.getRepeat<Manga>(
serverUrl + mangaQuery(mangaId)
)
) {
url {
if (refresh) {
parameter("onlineFetch", true)
}
}
}
}
suspend fun getManga(manga: Manga, refresh: Boolean = false) = getManga(manga.id, refresh)
suspend fun getMangaThumbnail(mangaId: Long) = withContext(Dispatchers.IO) {
imageFromUrl(

View File

@@ -0,0 +1,23 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.data.server.requests
@Post
fun backupImportRequest() =
"/api/v1/backup/legacy/import"
@Post
fun backupFileImportRequest() =
"/api/v1/backup/legacy/import/file"
@Post
fun backupExportRequest() =
"/api/v1/backup/legacy/export"
@Post
fun backupFileExportRequest() =
"/api/v1/backup/legacy/export/file"

View File

@@ -14,6 +14,10 @@ fun getMangaChaptersQuery(mangaId: Long) =
fun getChapterQuery(mangaId: Long, chapterIndex: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex"
@Patch
fun updateChapterRequest(mangaId: Long, chapterIndex: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex"
@Get
fun getPageQuery(mangaId: Long, chapterIndex: Int, index: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$index"

View File

@@ -11,12 +11,16 @@ fun extensionListQuery() =
"/api/v1/extension/list"
@Get
fun apkInstallQuery(apkName: String) =
"/api/v1/extension/install/$apkName"
fun apkInstallQuery(pkgName: String) =
"/api/v1/extension/install/$pkgName"
@Get
fun apkUninstallQuery(apkName: String) =
"/api/v1/extension/uninstall/$apkName"
fun apkUpdateQuery(pkgName: String) =
"/api/v1/extension/update/$pkgName"
@Get
fun apkUninstallQuery(pkgName: String) =
"/api/v1/extension/uninstall/$pkgName"
@Get
fun apkIconQuery(apkName: String) =

View File

@@ -0,0 +1,11 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.data.server.requests
@Get
fun aboutQuery() =
"/api/v1/about/"

View File

@@ -0,0 +1,106 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.ui.base.components
import androidx.compose.foundation.Indication
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.IntOffset
import java.awt.event.MouseEvent
private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent {
var event: PointerEvent
do {
event = awaitPointerEvent()
} while (
!event.changes.all { it.changedToDown() }
)
return event
}
fun Modifier.combinedMouseClickable(
rightClickIsContextMenu: Boolean = true,
onClick: (IntOffset) -> Unit = {},
onMiddleClick: (IntOffset) -> Unit = {},
onRightClick: (IntOffset) -> Unit = {}
) = composed(
inspectorInfo = debugInspectorInfo {
name = "combinedMouseClickable"
properties["rightClickIsContextMenu"] = rightClickIsContextMenu
properties["onClick"] = onClick
properties["onMiddleClick"] = onMiddleClick
properties["onRightClick"] = onRightClick
}
) {
Modifier.combinedMouseClickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current,
rightClickIsContextMenu = rightClickIsContextMenu,
onClick = onClick,
onMiddleClick = onMiddleClick,
onRightClick = onRightClick
)
}
fun Modifier.combinedMouseClickable(
interactionSource: MutableInteractionSource,
indication: Indication?,
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
rightClickIsContextMenu: Boolean = true,
onClick: (IntOffset) -> Unit,
onMiddleClick: (IntOffset) -> Unit,
onRightClick: (IntOffset) -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "combinedMouseClickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["rightClickIsContextMenu"] = rightClickIsContextMenu
properties["onClick"] = onClick
properties["onMiddleClick"] = onMiddleClick
properties["onRightClick"] = onRightClick
properties["indication"] = indication
properties["interactionSource"] = interactionSource
}
) {
var lastEvent by remember { mutableStateOf<MouseEvent?>(null) }
Modifier
.clickable(interactionSource, indication, enabled, onClickLabel, role) {
val savedLastEvent = lastEvent ?: return@clickable
val offset = savedLastEvent.let { IntOffset(it.xOnScreen, it.yOnScreen) }
when {
rightClickIsContextMenu && savedLastEvent.isPopupTrigger -> onRightClick(offset)
savedLastEvent.button == MouseEvent.BUTTON1 -> onClick(offset)
savedLastEvent.button == MouseEvent.BUTTON2 -> onMiddleClick(offset)
savedLastEvent.button == MouseEvent.BUTTON3 -> onRightClick(offset)
}
}
.pointerInput(interactionSource) {
forEachGesture {
awaitPointerEventScope {
lastEvent = awaitEventFirstDown().mouseEvent
}
}
}
}

View File

@@ -6,7 +6,6 @@
package ca.gosyer.ui.base.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,11 +41,11 @@ fun MangaGridItem(
)
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(mangaAspectRatio)
.padding(8.dp)
.clickable(onClick = onClick),
.padding(8.dp),
elevation = 4.dp,
shape = RoundedCornerShape(4.dp)
) {

View File

@@ -378,8 +378,9 @@ private fun ExpandableContent(
shrinkVertically(animationSpec = tween(COLLAPSE_ANIMATION_DURATION))
}
AnimatedVisibility(
visible = visible,
initiallyVisible = initiallyVisible,
remember { MutableTransitionState(initialState = initiallyVisible) }
.apply { targetState = visible },
modifier = Modifier,
enter = enterExpand + enterFadeIn,
exit = exitCollapse + exitFadeOut
) {

View File

@@ -42,12 +42,17 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ca.gosyer.BuildConfig
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.util.compose.ThemedWindow
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import mu.KotlinLogging
fun openCategoriesMenu(notifyFinished: (() -> Unit)? = null) {
val windowEvents = WindowEvents()
ThemedWindow("TachideskJUI - Categories", events = windowEvents) {
ThemedWindow("${BuildConfig.NAME} - Categories", events = windowEvents) {
CategoriesMenu(notifyFinished, windowEvents)
}
}
@@ -58,10 +63,16 @@ fun CategoriesMenu(notifyFinished: (() -> Unit)? = null, windowEvents: WindowEve
val categories by vm.categories.collectAsState()
remember {
windowEvents.onClose = {
vm.updateCategories()
val logger = KotlinLogging.logger {}
val handler = CoroutineExceptionHandler { _, throwable ->
logger.debug { throwable }
}
GlobalScope.launch(handler) {
vm.updateRemoteCategories()
notifyFinished?.invoke()
}
}
}
Surface {
Box {

View File

@@ -10,8 +10,6 @@ import ca.gosyer.data.models.Category
import ca.gosyer.data.server.interactions.CategoryInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@@ -50,11 +48,7 @@ class CategoriesMenuViewModel @Inject constructor(
}
}
fun updateCategories(manualUpdate: Boolean = false) {
val handler = CoroutineExceptionHandler { _, throwable ->
logger.debug { throwable }
}
GlobalScope.launch(handler) {
suspend fun updateRemoteCategories(manualUpdate: Boolean = false) {
val categories = _categories.value
val newCategories = categories.filter { it.id == null }
newCategories.forEach {
@@ -81,7 +75,6 @@ class CategoriesMenuViewModel @Inject constructor(
getCategories()
}
}
}
fun renameCategory(category: MenuCategory, newName: String) {
_categories.value = (_categories.value - category + category.copy(name = newName)).sortedBy { it.order }

View File

@@ -85,12 +85,9 @@ fun ExtensionsMenu() {
ExtensionItem(
extension,
serverUrl,
onInstallClicked = {
vm.install(it)
},
onUninstallClicked = {
vm.uninstall(it)
}
onInstallClicked = vm::install,
onUpdateClicked = vm::update,
onUninstallClicked = vm::uninstall
)
Spacer(Modifier.height(8.dp))
}
@@ -113,6 +110,7 @@ fun ExtensionItem(
extension: Extension,
serverUrl: String,
onInstallClicked: (Extension) -> Unit,
onUpdateClicked: (Extension) -> Unit,
onUninstallClicked: (Extension) -> Unit
) {
Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(MaterialTheme.colors.background)) {
@@ -133,16 +131,30 @@ fun ExtensionItem(
Spacer(Modifier.width(4.dp))
Text("18+", fontSize = 14.sp, color = Color.Red)
}
if (extension.obsolete) {
Spacer(Modifier.width(4.dp))
Text("Obsolete", fontSize = 14.sp, color = Color.Red)
}
}
}
}
Button(
{
if (extension.installed) onUninstallClicked(extension) else onInstallClicked(extension)
when {
extension.hasUpdate -> onUpdateClicked(extension)
extension.installed -> onUninstallClicked(extension)
else -> onInstallClicked(extension)
}
},
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(if (extension.installed) "Uninstall" else "Install")
Text(
when {
extension.hasUpdate -> "Update"
extension.installed -> "Uninstall"
else -> "Install"
}
)
}
}
}

View File

@@ -70,6 +70,18 @@ class ExtensionsMenuViewModel @Inject constructor(
}
}
fun update(extension: Extension) {
logger.info { "Update clicked" }
scope.launch {
try {
extensionHandler.updateExtension(extension)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
getExtensions()
}
}
fun uninstall(extension: Extension) {
logger.info { "Uninstall clicked" }
scope.launch {

View File

@@ -138,7 +138,7 @@ private fun LibraryPager(
val state = remember(categories.size, selectedPage) {
PagerState(
currentPage = selectedPage,
pageCount = categories.lastIndex
pageCount = categories.size
)
}
LaunchedEffect(state.currentPage) {
@@ -146,7 +146,7 @@ private fun LibraryPager(
onPageChanged(state.currentPage)
}
}
HorizontalPager(state = state, offscreenLimit = 1) {
HorizontalPager(state = state) {
val library by getLibraryForPage(it)
when (displayMode) {
DisplayMode.CompactGrid -> LibraryMangaCompactGrid(

View File

@@ -7,7 +7,6 @@
package ca.gosyer.ui.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,11 +17,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -32,6 +33,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ca.gosyer.BuildConfig
import ca.gosyer.data.ui.model.StartScreen
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.extensions.ExtensionsMenu
import ca.gosyer.ui.library.LibraryScreen
@@ -47,55 +49,48 @@ import ca.gosyer.ui.settings.SettingsScreen
import ca.gosyer.ui.settings.SettingsServerScreen
import ca.gosyer.ui.sources.SourcesMenu
import com.github.zsoltk.compose.router.Router
import com.github.zsoltk.compose.savedinstancestate.Bundle
import com.github.zsoltk.compose.savedinstancestate.BundleScope
import compose.icons.FontAwesomeIcons
import compose.icons.fontawesomeicons.Regular
import compose.icons.fontawesomeicons.regular.Bookmark
import compose.icons.fontawesomeicons.regular.Compass
import compose.icons.fontawesomeicons.regular.Edit
import compose.icons.fontawesomeicons.regular.Map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mu.KotlinLogging
import kotlin.time.seconds
@Composable
fun MainMenu() {
fun MainMenu(rootBundle: Bundle) {
val vm = viewModel<MainViewModel>()
Surface {
Router<Route>("TopLevel", Route.Library) { backStack ->
Router("TopLevel", vm.startScreen.toRoute()) { backStack ->
Row {
Surface(elevation = 2.dp) {
Column(Modifier.width(200.dp).fillMaxHeight(),) {
Box(Modifier.fillMaxWidth().height(60.dp)) {
Text(BuildConfig.NAME, fontSize = 30.sp, modifier = Modifier.align(Alignment.Center))
Text(
BuildConfig.NAME,
fontSize = 30.sp,
modifier = Modifier.align(Alignment.Center)
)
}
Spacer(Modifier.height(20.dp))
remember { TopLevelMenus.values() }.forEach { topLevelMenu ->
MainMenuItem(topLevelMenu, backStack.elements.first() == topLevelMenu.menu) {
MainMenuItem(
topLevelMenu,
backStack.elements.first() == topLevelMenu.menu
) {
backStack.newRoot(it)
}
}
/*Button(
onClick = ::openExtensionsMenu
) {
Text("Extensions")
}
Button(
onClick = ::openSourcesMenu
) {
Text("Sources")
}
Button(
onClick = ::openLibraryMenu
) {
Text("Library")
}
Button(
onClick = ::openCategoriesMenu
) {
Text("Categories")
}*/
}
}
Column(Modifier.fillMaxSize()) {
BundleScope("K${backStack.lastIndex}", rootBundle, false) {
when (val routing = backStack.last()) {
is Route.Library -> LibraryScreen {
backStack.push(Route.Manga(it))
@@ -124,19 +119,22 @@ fun MainMenu() {
}
}
}
}
}
@Composable
fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Unit) {
Card(
Modifier.clickable { onClick(menu.menu) }.fillMaxWidth().height(40.dp),
{ onClick(menu.menu) },
Modifier.fillMaxWidth().height(40.dp),
backgroundColor = if (!selected) {
Color.Transparent
} else {
MaterialTheme.colors.primary.copy(0.30F)
},
contentColor = Color.Transparent,
elevation = 0.dp
elevation = 0.dp,
shape = RoundedCornerShape(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.width(16.dp))
@@ -147,6 +145,12 @@ fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Uni
}
}
fun StartScreen.toRoute() = when (this) {
StartScreen.Library -> Route.Library
StartScreen.Sources -> Route.Sources
StartScreen.Extensions -> Route.Extensions
}
enum class TopLevelMenus(val text: String, val icon: ImageVector, val menu: Route) {
Library("Library", FontAwesomeIcons.Regular.Bookmark, Route.Library),
Sources("Sources", FontAwesomeIcons.Regular.Compass, Route.Sources),

View File

@@ -28,6 +28,7 @@ import com.github.weisj.darklaf.theme.DarculaTheme
import com.github.weisj.darklaf.theme.IntelliJTheme
import com.github.zsoltk.compose.backpress.BackPressHandler
import com.github.zsoltk.compose.backpress.LocalBackPressHandler
import com.github.zsoltk.compose.savedinstancestate.Bundle
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import org.apache.logging.log4j.core.config.Configurator
@@ -112,6 +113,7 @@ fun main() {
)
}
val rootBundle = Bundle()
window.show {
AppTheme {
CompositionLocalProvider(
@@ -119,7 +121,7 @@ fun main() {
) {
val initialized by serverService.initialized.collectAsState()
if (initialized == ServerResult.STARTED || initialized == ServerResult.UNUSED) {
MainMenu()
MainMenu(rootBundle)
} else if (initialized == ServerResult.STARTING) {
LoadingScreen()
} else if (initialized == ServerResult.FAILED) {

View File

@@ -8,7 +8,6 @@ package ca.gosyer.ui.manga
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -28,6 +27,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
@@ -39,21 +40,24 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ca.gosyer.BuildConfig
import ca.gosyer.data.models.Chapter
import ca.gosyer.data.models.Manga
import ca.gosyer.ui.base.components.KtorImage
import ca.gosyer.ui.base.components.LoadingScreen
import ca.gosyer.ui.base.components.Toolbar
import ca.gosyer.ui.base.components.combinedMouseClickable
import ca.gosyer.ui.base.components.mangaAspectRatio
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route
import ca.gosyer.ui.reader.openReaderMenu
import ca.gosyer.util.compose.ThemedWindow
import ca.gosyer.util.compose.contextMenu
import com.github.zsoltk.compose.router.BackStack
import java.util.Date
fun openMangaMenu(mangaId: Long) {
ThemedWindow("TachideskJUI") {
ThemedWindow(BuildConfig.NAME) {
MangaMenu(mangaId)
}
}
@@ -87,9 +91,14 @@ fun MangaMenu(mangaId: Long, backStack: BackStack<Route>? = null) {
MangaItem(manga, serverUrl)
}
items(chapters) { chapter ->
ChapterItem(chapter, dateFormat::format) {
openReaderMenu(it, manga.id)
}
ChapterItem(
chapter,
dateFormat::format,
onClick = { openReaderMenu(it, manga.id) },
toggleRead = vm::toggleRead,
toggleBookmarked = vm::toggleBookmarked,
markPreviousAsRead = vm::markPreviousRead
)
}
}
VerticalScrollbar(
@@ -178,20 +187,61 @@ private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) {
}
@Composable
fun ChapterItem(chapter: Chapter, format: (Date) -> String, onClick: (Int) -> Unit) {
Surface(modifier = Modifier.fillMaxWidth().clickable { onClick(chapter.chapterIndex) }.height(70.dp).padding(4.dp), elevation = 1.dp) {
Column(Modifier.padding(4.dp)) {
Text(chapter.name, fontSize = 20.sp, maxLines = 1)
fun ChapterItem(
chapter: Chapter,
format: (Date) -> String,
onClick: (Int) -> Unit,
toggleRead: (Int) -> Unit,
toggleBookmarked: (Int) -> Unit,
markPreviousAsRead: (Int) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth().height(70.dp).padding(4.dp),
elevation = 1.dp
) {
Column(
Modifier.padding(4.dp)
.combinedMouseClickable(
onClick = {
onClick(chapter.index)
},
onRightClick = {
contextMenu(
it
) {
menuItem("Toggle read") { toggleRead(chapter.index) }
menuItem("Mark previous as read") { markPreviousAsRead(chapter.index) }
separator()
menuItem("Toggle bookmarked") { toggleBookmarked(chapter.index) }
}
}
)
) {
Text(
chapter.name, fontSize = 20.sp, maxLines = 1,
color = if (!chapter.read) {
LocalContentColor.current
} else {
LocalContentColor.current.copy(alpha = ContentAlpha.disabled)
}
)
val description = mutableListOf<String>()
if (chapter.dateUpload != 0L) {
description += format(Date(chapter.dateUpload))
if (chapter.uploadDate != 0L) {
description += format(Date(chapter.uploadDate))
}
if (!chapter.scanlator.isNullOrEmpty()) {
description += chapter.scanlator
}
if (description.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(description.joinToString(" - "), maxLines = 1)
Text(
description.joinToString(" - "), maxLines = 1,
color = if (!chapter.read) {
LocalContentColor.current
} else {
LocalContentColor.current.copy(alpha = ContentAlpha.disabled)
}
)
}
}
}

View File

@@ -69,7 +69,7 @@ class MangaMenuViewModel @Inject constructor(
}
}
suspend fun refreshChaptersAsync(mangaId: Long) = withContext(Dispatchers.IO) {
private suspend fun refreshChaptersAsync(mangaId: Long) = withContext(Dispatchers.IO) {
async {
try {
_chapters.value = chapterHandler.getChapters(mangaId)
@@ -98,5 +98,32 @@ class MangaMenuViewModel @Inject constructor(
else -> SimpleDateFormat(format, Locale.getDefault())
}
fun toggleRead(index: Int) {
scope.launch {
manga.value?.let { manga ->
chapterHandler.updateChapter(manga, index, read = !_chapters.value.first { it.index == index }.read)
_chapters.value = chapterHandler.getChapters(manga)
}
}
}
fun toggleBookmarked(index: Int) {
scope.launch {
manga.value?.let { manga ->
chapterHandler.updateChapter(manga, index, bookmarked = !_chapters.value.first { it.index == index }.bookmarked)
_chapters.value = chapterHandler.getChapters(manga)
}
}
}
fun markPreviousRead(index: Int) {
scope.launch {
manga.value?.let { manga ->
chapterHandler.updateChapter(manga, index, markPreviousRead = true)
_chapters.value = chapterHandler.getChapters(manga)
}
}
}
data class Params(val mangaId: Long)
}

View File

@@ -7,7 +7,6 @@
package ca.gosyer.ui.settings
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -137,6 +136,7 @@ private fun ThemeItem(
Color.White.copy(alpha = 0.15f)
}
Surface(
onClick = { onClick(theme) },
elevation = 4.dp,
color = theme.colors.background,
shape = borders,
@@ -144,7 +144,6 @@ private fun ThemeItem(
.size(100.dp, 160.dp)
.padding(8.dp)
.border(1.dp, borderColor, borders)
.clickable(onClick = { onClick(theme) })
) {
Column {
Box(

View File

@@ -6,28 +6,89 @@
package ca.gosyer.ui.settings
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.min
import ca.gosyer.data.server.interactions.BackupInteractionHandler
import ca.gosyer.ui.base.components.Toolbar
import ca.gosyer.ui.base.prefs.PreferenceRow
import ca.gosyer.ui.base.vm.ViewModel
import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route
import ca.gosyer.util.system.filePicker
import ca.gosyer.util.system.fileSaver
import com.github.zsoltk.compose.router.BackStack
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import mu.KotlinLogging
import java.io.File
import javax.inject.Inject
class SettingsBackupViewModel @Inject constructor() : ViewModel() {
class SettingsBackupViewModel @Inject constructor(
private val backupHandler: BackupInteractionHandler
) : ViewModel() {
private val logger = KotlinLogging.logger {}
fun setFile(file: File?) {
private val _restoring = MutableStateFlow(false)
val restoring = _restoring.asStateFlow()
private val _restoreError = MutableStateFlow(false)
val restoreError = _restoreError.asStateFlow()
private val _creating = MutableStateFlow(false)
val creating = _creating.asStateFlow()
private val _creatingError = MutableStateFlow(false)
val creatingError = _creatingError.asStateFlow()
fun restoreFile(file: File?) {
scope.launch {
if (file == null || !file.exists()) {
logger.info { "Invalid file ${file?.absolutePath}" }
} else {
logger.info { file.absolutePath }
_restoreError.value = false
_restoring.value = true
try {
backupHandler.importBackupFile(file)
} catch (e: Exception) {
logger.info(e) { "Error importing backup" }
_restoreError.value = true
} finally {
_restoring.value = false
}
}
}
}
fun createFile(file: File?) {
scope.launch {
if (file == null) {
logger.info { "Invalid file ${file?.absolutePath}" }
} else {
if (file.exists()) file.delete()
_creatingError.value = false
_creating.value = true
try {
val backup = backupHandler.exportBackupFile()
} catch (e: Exception) {
logger.info(e) { "Error importing backup" }
_creatingError.value = true
} finally {
_creating.value = false
}
}
}
}
}
@@ -35,17 +96,63 @@ class SettingsBackupViewModel @Inject constructor() : ViewModel() {
@Composable
fun SettingsBackupScreen(navController: BackStack<Route>) {
val vm = viewModel<SettingsBackupViewModel>()
val restoring by vm.restoring.collectAsState()
val restoreError by vm.restoreError.collectAsState()
val creating by vm.creating.collectAsState()
val creatingError by vm.creatingError.collectAsState()
Column {
Toolbar("Backup Settings", navController, true)
LazyColumn {
item {
PreferenceRow(
PreferenceFile(
"Restore Backup",
onClick = {
filePicker {
vm.setFile(it.selectedFile)
"Restore a backup into Tachidesk",
restoring,
restoreError
) {
filePicker("json") {
vm.restoreFile(it.selectedFile)
}
}
PreferenceFile(
"Create Backup",
"Create a backup from Tachidesk",
creating,
creatingError
) {
fileSaver("test.json", "json") {
vm.createFile(it.selectedFile)
}
}
}
}
}
}
@Composable
fun PreferenceFile(title: String, subtitle: String, working: Boolean, error: Boolean, onClick: () -> Unit) {
PreferenceRow(
title = title,
onClick = onClick,
enabled = !working,
subtitle = subtitle
) {
BoxWithConstraints {
val size = remember(maxHeight, maxWidth) {
min(maxHeight, maxWidth) / 2
}
val modifier = Modifier.align(Alignment.Center)
.size(size)
if (working) {
CircularProgressIndicator(
modifier
)
} else if (error) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = modifier,
tint = Color.Red
)
}
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ca.gosyer.data.models.Source
@@ -33,7 +34,7 @@ import ca.gosyer.ui.sources.components.SourceHomeScreen
import ca.gosyer.ui.sources.components.SourceScreen
import ca.gosyer.util.compose.ThemedWindow
import com.github.zsoltk.compose.savedinstancestate.Bundle
import com.github.zsoltk.compose.savedinstancestate.BundleScope
import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState
fun openSourcesMenu() {
ThemedWindow(title = "TachideskJUI - Sources") {
@@ -47,9 +48,7 @@ private const val SOURCE_MENU_KEY = "source_menu"
@Composable
fun SourcesMenu(onMangaClick: (Long) -> Unit) {
BundleScope(SOURCE_MENU_KEY, autoDispose = false) {
SourcesMenu(it, onMangaClick)
}
SourcesMenu(LocalSavedInstanceState.current, onMangaClick)
}
@Composable
@@ -87,7 +86,6 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
}
val selectedSource: Source? = selectedSourceTab
BundleScope("Sources") {
if (selectedSource != null) {
SourceScreen(selectedSource, onMangaClick)
} else {
@@ -96,5 +94,4 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.util.compose
import androidx.compose.ui.unit.IntOffset
import mu.KotlinLogging
import javax.swing.Icon
import javax.swing.JMenuItem
import javax.swing.JPopupMenu
import javax.swing.JSeparator
class ContextMenu internal constructor() {
val logger = KotlinLogging.logger {}
internal val list = mutableListOf<Pair<Any, (() -> Unit)?>>()
fun popupMenu() = JPopupMenu().apply {
fun (() -> Unit)?.andClose() {
isVisible = false
this?.invoke()
}
list.forEach { (item, block) ->
when (item) {
is JMenuItem -> add(item).apply {
addActionListener {
logger.info { it.actionCommand }
logger.info { it.modifiers }
block.andClose()
}
}
is JSeparator -> add(item)
}
}
}
fun menuItem(name: String, icon: Icon? = null, builder: JMenuItem.() -> Unit = {}, action: () -> Unit) {
list += JMenuItem(name, icon).apply(builder) to action
}
fun separator() {
list += JSeparator() to null
}
}
fun contextMenu(offset: IntOffset, contextMenu: ContextMenu.() -> Unit) {
ContextMenu().apply(contextMenu).popupMenu().show(null, offset.x, offset.y)
}

View File

@@ -12,6 +12,7 @@ import net.harawata.appdirs.AppDirsFactory
import java.io.File
import javax.swing.JFileChooser
import javax.swing.SwingUtilities
import javax.swing.filechooser.FileNameExtensionFilter
val appDirs: AppDirs by lazy {
AppDirsFactory.getInstance()
@@ -23,29 +24,69 @@ val userDataDir: File by lazy {
}
}
fun filePicker(
extension: String? = null,
builder: JFileChooser.() -> Unit = {},
onCancel: (JFileChooser) -> Unit = {},
onError: (JFileChooser) -> Unit = {},
onApprove: (JFileChooser) -> Unit
) = fileChooser(false, builder, onCancel, onError, onApprove, extension)
fun fileSaver(
defaultFileName: String,
extension: String,
builder: JFileChooser.() -> Unit = {},
onCancel: (JFileChooser) -> Unit = {},
onError: (JFileChooser) -> Unit = {},
onApprove: (JFileChooser) -> Unit
) = fileChooser(
true,
builder,
onCancel,
onError,
onApprove,
extension,
defaultFileName
)
/**
* Opens a swing file picker, in the details view by default
*
* @param saving true if the dialog is going to save a file, false if its going to open a file
* @param builder invokes this builder before launching the file picker, such as adding a action listener
* @param onCancel the listener that is called when picking a file is canceled
* @param onError the listener that is called when picking a file exited with a error
* @param onApprove the listener that is called when picking a file is completed
*/
fun filePicker(
private fun fileChooser(
saving: Boolean = false,
builder: JFileChooser.() -> Unit = {},
onCancel: (JFileChooser) -> Unit = {},
onError: (JFileChooser) -> Unit = {},
onApprove: (JFileChooser) -> Unit
onApprove: (JFileChooser) -> Unit,
extension: String? = null,
defaultFileName: String = ""
) = SwingUtilities.invokeLater {
val fileChooser = JFileChooser()
.apply {
val details = actionMap.get("viewTypeDetails")
details?.actionPerformed(null)
if (extension != null) {
fileFilter = FileNameExtensionFilter("$extension file", extension)
}
if (saving) {
selectedFile = File(defaultFileName)
}
}
.apply(builder)
val result = fileChooser
.showOpenDialog(null)
val result = fileChooser.let {
if (saving) {
it.showSaveDialog(null)
} else {
it.showOpenDialog(null)
}
}
when (result) {
JFileChooser.APPROVE_OPTION -> onApprove(fileChooser)