From c06ea331125c7577d8cd2b0a7101b1f7cd11854c Mon Sep 17 00:00:00 2001 From: Syer10 Date: Wed, 19 May 2021 17:27:48 -0400 Subject: [PATCH] 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 --- build.gradle.kts | 15 +- scripts/SetupUnix.sh | 11 +- scripts/SetupWindows.ps1 | 10 +- .../kotlin/ca/gosyer/data/models/About.kt | 15 ++ .../kotlin/ca/gosyer/data/models/Backup.kt | 55 ++++++++ .../kotlin/ca/gosyer/data/models/Chapter.kt | 15 +- .../kotlin/ca/gosyer/data/models/Extension.kt | 3 +- .../kotlin/ca/gosyer/data/models/Manga.kt | 17 +-- .../ca/gosyer/data/server/ServerService.kt | 33 ++--- .../interactions/BackupInteractionHandler.kt | 69 ++++++++++ .../interactions/ChapterInteractionHandler.kt | 90 ++++++++++-- .../ExtensionInteractionHandler.kt | 11 +- .../interactions/MangaInteractionHandler.kt | 13 +- .../ca/gosyer/data/server/requests/Backup.kt | 23 ++++ .../gosyer/data/server/requests/Chapters.kt | 4 + .../gosyer/data/server/requests/Extensions.kt | 12 +- .../ca/gosyer/data/server/requests/Meta.kt | 11 ++ .../base/components/CombinedMouseClickable.kt | 106 ++++++++++++++ .../ca/gosyer/ui/base/components/Manga.kt | 5 +- .../ui/base/prefs/PreferencesUiBuilder.kt | 5 +- .../ca/gosyer/ui/categories/CategoriesMenu.kt | 17 ++- .../ui/categories/CategoriesMenuViewModel.kt | 49 +++---- .../ca/gosyer/ui/extensions/ExtensionsMenu.kt | 28 ++-- .../ui/extensions/ExtensionsMenuViewModel.kt | 12 ++ .../ca/gosyer/ui/library/LibraryScreen.kt | 4 +- src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt | 106 +++++++------- src/main/kotlin/ca/gosyer/ui/main/main.kt | 4 +- .../kotlin/ca/gosyer/ui/manga/MangaMenu.kt | 74 ++++++++-- .../ca/gosyer/ui/manga/MangaMenuViewModel.kt | 29 +++- .../ui/settings/SettingsAppearanceScreen.kt | 3 +- .../ui/settings/SettingsBackupScreen.kt | 129 ++++++++++++++++-- .../ca/gosyer/ui/sources/SourcesMenu.kt | 17 +-- .../ca/gosyer/util/compose/ContextMenu.kt | 49 +++++++ src/main/kotlin/ca/gosyer/util/system/File.kt | 49 ++++++- 34 files changed, 885 insertions(+), 208 deletions(-) create mode 100644 src/main/kotlin/ca/gosyer/data/models/About.kt create mode 100644 src/main/kotlin/ca/gosyer/data/models/Backup.kt create mode 100644 src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt create mode 100644 src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt create mode 100644 src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt create mode 100644 src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt create mode 100644 src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt diff --git a/build.gradle.kts b/build.gradle.kts index ba6440eb..40a898ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { - 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 { diff --git a/scripts/SetupUnix.sh b/scripts/SetupUnix.sh index 90756db1..5f31be39 100755 --- a/scripts/SetupUnix.sh +++ b/scripts/SetupUnix.sh @@ -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 @@ -33,4 +34,4 @@ mv "$TACHIDESK_FOLDER/$TACHIDESK_JAR" src/main/resources/Tachidesk.jar echo "Cleaning up..." rm -rf "tmp" -echo "Done!" \ No newline at end of file +echo "Done!" diff --git a/scripts/SetupWindows.ps1 b/scripts/SetupWindows.ps1 index 4001359a..f740c70c 100644 --- a/scripts/SetupWindows.ps1 +++ b/scripts/SetupWindows.ps1 @@ -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 diff --git a/src/main/kotlin/ca/gosyer/data/models/About.kt b/src/main/kotlin/ca/gosyer/data/models/About.kt new file mode 100644 index 00000000..1a370c64 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/About.kt @@ -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, +) diff --git a/src/main/kotlin/ca/gosyer/data/models/Backup.kt b/src/main/kotlin/ca/gosyer/data/models/Backup.kt new file mode 100644 index 00000000..3720ce2b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/models/Backup.kt @@ -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> = emptyList(), + val mangas: List, + val version: Int = 1 +) { + @Serializable + data class Manga( + val manga: List<@Contextual Any?>, + val chapters: List = emptyList(), + val categories: List = emptyList(), + val history: List> = emptyList(), + val track: List = 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 + ) +} diff --git a/src/main/kotlin/ca/gosyer/data/models/Chapter.kt b/src/main/kotlin/ca/gosyer/data/models/Chapter.kt index 1bc919af..209341f7 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Chapter.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Chapter.kt @@ -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?, ) diff --git a/src/main/kotlin/ca/gosyer/data/models/Extension.kt b/src/main/kotlin/ca/gosyer/data/models/Extension.kt index bb386ad2..6b841432 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Extension.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Extension.kt @@ -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 diff --git a/src/main/kotlin/ca/gosyer/data/models/Manga.kt b/src/main/kotlin/ca/gosyer/data/models/Manga.kt index 782d9259..165d9e40 100644 --- a/src/main/kotlin/ca/gosyer/data/models/Manga.kt +++ b/src/main/kotlin/ca/gosyer/data/models/Manga.kt @@ -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 } } diff --git a/src/main/kotlin/ca/gosyer/data/server/ServerService.kt b/src/main/kotlin/ca/gosyer/data/server/ServerService.kt index f633a4f7..79f8ec91 100644 --- a/src/main/kotlin/ca/gosyer/data/server/ServerService.kt +++ b/src/main/kotlin/ca/gosyer/data/server/ServerService.kt @@ -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,19 +77,13 @@ 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" + jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION || + jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION ) { - if ( - jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION || - jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION - ) { - logger.info { "Updating server file from resources" } - copyJar(jarFile) - } + logger.info { "Updating server file from resources" } + copyJar(jarFile) } } catch (e: IOException) { logger.error(e) { @@ -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" } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt new file mode 100644 index 00000000..e8d11e7b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/BackupInteractionHandler.kt @@ -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( + 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( + serverUrl + backupImportRequest() + ) { + contentType(ContentType.Application.Json) + body = backup + } + } + + suspend fun exportBackupFile() = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + backupFileExportRequest() + ) + } + + suspend fun exportBackup() = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + backupExportRequest() + ) + } +} diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt index 5af3fc0f..5c712fc8 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt @@ -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>( serverUrl + getMangaChaptersQuery(mangaId) - ) + ) { + url { + if (refresh) { + parameter("onlineFetch", true) + } + } + } } - suspend fun getChapters(manga: Manga) = withContext(Dispatchers.IO) { - client.getRepeat( - 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( @@ -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( + 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) } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt index fd181dba..1815997e 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/ExtensionInteractionHandler.kt @@ -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( - serverUrl + apkInstallQuery(extension.apkName) + serverUrl + apkInstallQuery(extension.pkgName) + ) + } + + suspend fun updateExtension(extension: Extension) = withContext(Dispatchers.IO) { + client.getRepeat( + serverUrl + apkUpdateQuery(extension.pkgName) ) } suspend fun uninstallExtension(extension: Extension) = withContext(Dispatchers.IO) { client.getRepeat( - serverUrl + apkUninstallQuery(extension.apkName) + serverUrl + apkUninstallQuery(extension.pkgName) ) } diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt index f4c343b4..0f08e367 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/MangaInteractionHandler.kt @@ -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,12 +21,20 @@ 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( 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( client, diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt new file mode 100644 index 00000000..ab6b1cc3 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Backup.kt @@ -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" diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt index 9f5a2624..8dd36f54 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt @@ -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" diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt index 4eb41c4a..9ba5086a 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Extensions.kt @@ -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) = diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt new file mode 100644 index 00000000..7e83f521 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Meta.kt @@ -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/" diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt new file mode 100644 index 00000000..cbd7dd37 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt @@ -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(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 + } + } + } +} diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt index 0ea8dcd3..e4e09f99 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/Manga.kt @@ -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) ) { diff --git a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt index 84b066bf..90c1e12c 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/prefs/PreferencesUiBuilder.kt @@ -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 ) { diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt index 7bd2d502..8390ae93 100644 --- a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenu.kt @@ -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,8 +63,14 @@ fun CategoriesMenu(notifyFinished: (() -> Unit)? = null, windowEvents: WindowEve val categories by vm.categories.collectAsState() remember { windowEvents.onClose = { - vm.updateCategories() - notifyFinished?.invoke() + val logger = KotlinLogging.logger {} + val handler = CoroutineExceptionHandler { _, throwable -> + logger.debug { throwable } + } + GlobalScope.launch(handler) { + vm.updateRemoteCategories() + notifyFinished?.invoke() + } } } diff --git a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt index 69361d2b..81707c1f 100644 --- a/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/categories/CategoriesMenuViewModel.kt @@ -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,36 +48,31 @@ class CategoriesMenuViewModel @Inject constructor( } } - fun updateCategories(manualUpdate: Boolean = false) { - val handler = CoroutineExceptionHandler { _, throwable -> - logger.debug { throwable } + suspend fun updateRemoteCategories(manualUpdate: Boolean = false) { + val categories = _categories.value + val newCategories = categories.filter { it.id == null } + newCategories.forEach { + categoryHandler.createCategory(it.name) } - GlobalScope.launch(handler) { - val categories = _categories.value - val newCategories = categories.filter { it.id == null } - newCategories.forEach { - categoryHandler.createCategory(it.name) + originalCategories.forEach { originalCategory -> + val category = categories.find { it.id == originalCategory.id } + if (category == null) { + categoryHandler.deleteCategory(originalCategory) + } else if (category.name != originalCategory.name) { + categoryHandler.modifyCategory(originalCategory, category.name) } - originalCategories.forEach { originalCategory -> - val category = categories.find { it.id == originalCategory.id } - if (category == null) { - categoryHandler.deleteCategory(originalCategory) - } else if (category.name != originalCategory.name) { - categoryHandler.modifyCategory(originalCategory, category.name) - } - } - val updatedCategories = categoryHandler.getCategories() - updatedCategories.forEach { updatedCategory -> - val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach - if (category.order != updatedCategory.order) { - logger.debug { "${category.order} to ${updatedCategory.order}" } - categoryHandler.reorderCategory(updatedCategory, category.order, updatedCategory.order) - } + } + val updatedCategories = categoryHandler.getCategories() + updatedCategories.forEach { updatedCategory -> + val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach + if (category.order != updatedCategory.order) { + logger.debug { "${category.order} to ${updatedCategory.order}" } + categoryHandler.reorderCategory(updatedCategory, category.order, updatedCategory.order) } + } - if (manualUpdate) { - getCategories() - } + if (manualUpdate) { + getCategories() } } diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt index c2b758e2..1cae7d24 100644 --- a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenu.kt @@ -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" + } + ) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt index 65de8723..7f269e12 100644 --- a/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/extensions/ExtensionsMenuViewModel.kt @@ -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 { diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt index 99529e11..aa60611d 100644 --- a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreen.kt @@ -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( diff --git a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt index 76084b54..c5ae2568 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/MainMenu.kt @@ -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,78 +49,72 @@ 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() Surface { - Router("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()) { - when (val routing = backStack.last()) { - is Route.Library -> LibraryScreen { - backStack.push(Route.Manga(it)) - } - is Route.Sources -> SourcesMenu { - backStack.push(Route.Manga(it)) - } - is Route.Extensions -> ExtensionsMenu() - is Route.Manga -> MangaMenu(routing.mangaId, backStack) + BundleScope("K${backStack.lastIndex}", rootBundle, false) { + when (val routing = backStack.last()) { + is Route.Library -> LibraryScreen { + backStack.push(Route.Manga(it)) + } + is Route.Sources -> SourcesMenu { + backStack.push(Route.Manga(it)) + } + is Route.Extensions -> ExtensionsMenu() + is Route.Manga -> MangaMenu(routing.mangaId, backStack) - is Route.Settings -> SettingsScreen(backStack) - is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) - is Route.SettingsAppearance -> SettingsAppearance(backStack) - is Route.SettingsServer -> SettingsServerScreen(backStack) - is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) - is Route.SettingsReader -> SettingsReaderScreen(backStack) - /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) - is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ - is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) - is Route.SettingsBackup -> SettingsBackupScreen(backStack) - /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) - is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ - is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) + is Route.Settings -> SettingsScreen(backStack) + is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) + is Route.SettingsAppearance -> SettingsAppearance(backStack) + is Route.SettingsServer -> SettingsServerScreen(backStack) + is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) + is Route.SettingsReader -> SettingsReaderScreen(backStack) + /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) + is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ + is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) + is Route.SettingsBackup -> SettingsBackupScreen(backStack) + /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) + is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ + is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) + } } } } @@ -129,14 +125,16 @@ 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), diff --git a/src/main/kotlin/ca/gosyer/ui/main/main.kt b/src/main/kotlin/ca/gosyer/ui/main/main.kt index 4d04c2af..22c3ee22 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/main.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/main.kt @@ -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) { diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt index 4f3a17f7..c419b153 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenu.kt @@ -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? = 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() - 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) + } + ) } } } diff --git a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt index 54d36861..7a836d80 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/MangaMenuViewModel.kt @@ -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) } diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt index ac927ff0..f416e543 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsAppearanceScreen.kt @@ -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( diff --git a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt index 35726ead..e9ff1973 100644 --- a/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt +++ b/src/main/kotlin/ca/gosyer/ui/settings/SettingsBackupScreen.kt @@ -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?) { - if (file == null || !file.exists()) { - logger.info { "Invalid file ${file?.absolutePath}" } - } else { - logger.info { file.absolutePath } + 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 { + _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) { val vm = viewModel() + 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 ) } } diff --git a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt index 90fc6f24..627e4066 100644 --- a/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt +++ b/src/main/kotlin/ca/gosyer/ui/sources/SourcesMenu.kt @@ -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,12 +86,10 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) { } val selectedSource: Source? = selectedSourceTab - BundleScope("Sources") { - if (selectedSource != null) { - SourceScreen(selectedSource, onMangaClick) - } else { - SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab) - } + if (selectedSource != null) { + SourceScreen(selectedSource, onMangaClick) + } else { + SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab) } } } diff --git a/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt b/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt new file mode 100644 index 00000000..93a04105 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt @@ -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 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) +} diff --git a/src/main/kotlin/ca/gosyer/util/system/File.kt b/src/main/kotlin/ca/gosyer/util/system/File.kt index 754c7d9a..bfbd9176 100644 --- a/src/main/kotlin/ca/gosyer/util/system/File.kt +++ b/src/main/kotlin/ca/gosyer/util/system/File.kt @@ -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)