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

View File

@@ -7,13 +7,14 @@ fi
mkdir -p "tmp" mkdir -p "tmp"
echo "Getting latest Tachidesk build files" 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 pushd "$TACHIDESK_FOLDER" || exit
@@ -33,4 +34,4 @@ mv "$TACHIDESK_FOLDER/$TACHIDESK_JAR" src/main/resources/Tachidesk.jar
echo "Cleaning up..." echo "Cleaning up..."
rm -rf "tmp" rm -rf "tmp"
echo "Done!" echo "Done!"

View File

@@ -6,13 +6,15 @@ Remove-Item -Recurse -Force "tmp" -ErrorAction SilentlyContinue | Out-Null
New-Item -ItemType Directory -Force -Path "tmp" New-Item -ItemType Directory -Force -Path "tmp"
Write-Output "Getting latest Tachidesk build files" 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" 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 Push-Location $tachidesk_folder.FullName
@@ -27,7 +29,7 @@ $tachidesk_jar = $(Get-ChildItem "server/build" | Where-Object { $_.Name -match
Pop-Location Pop-Location
Write-Output "Copying Tachidesk.jar to resources folder..." 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..." Write-Output "Cleaning up..."
Remove-Item -Recurse -Force "tmp" | Out-Null 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 package ca.gosyer.data.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Chapter( data class Chapter(
val id: Long,
val url: String, val url: String,
val name: String, val name: String,
@SerialName("date_upload") val uploadDate: Long,
val dateUpload: Long,
@SerialName("chapter_number")
val chapterNumber: Float, val chapterNumber: Float,
val scanlator: String?, val scanlator: String?,
val mangaId: Long, val mangaId: Long,
val pageCount: Int? = null, val read: Boolean,
val chapterIndex: Int, val bookmarked: Boolean,
val chapterCount: Int 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 apkName: String,
val iconUrl: String, val iconUrl: String,
val installed: Boolean, val installed: Boolean,
val classFQName: String, val hasUpdate: Boolean,
val obsolete: Boolean,
val nsfw: Boolean val nsfw: Boolean
) { ) {
fun iconUrl(serverUrl: String) = serverUrl + iconUrl fun iconUrl(serverUrl: String) = serverUrl + iconUrl

View File

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

View File

@@ -34,6 +34,7 @@ class ServerService @Inject constructor(
ServerResult.UNUSED ServerResult.UNUSED
} }
) )
private val runtime = Runtime.getRuntime()
var process: Process? = null var process: Process? = null
private fun copyJar(jarFile: File) { private fun copyJar(jarFile: File) {
@@ -45,6 +46,12 @@ class ServerService @Inject constructor(
} }
init { init {
runtime.addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
host.onEach { host.onEach {
process?.destroy() process?.destroy()
initialized.value = if (host.value) { initialized.value = if (host.value) {
@@ -55,7 +62,6 @@ class ServerService @Inject constructor(
} }
GlobalScope.launch { GlobalScope.launch {
val logger = KotlinLogging.logger("Server") val logger = KotlinLogging.logger("Server")
val runtime = Runtime.getRuntime()
val jarFile = File(userDataDir, "Tachidesk.jar") val jarFile = File(userDataDir, "Tachidesk.jar")
if (!jarFile.exists()) { 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 ( if (
jarVersion.specification != null && jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION ||
jarVersion.implementation != null && jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION
jarVersion.implementation != "1.0"
) { ) {
if ( logger.info { "Updating server file from resources" }
jarVersion.specification != BuildConfig.TACHIDESK_SP_VERSION || copyJar(jarFile)
jarVersion.implementation != BuildConfig.TACHIDESK_IM_VERSION
) {
logger.info { "Updating server file from resources" }
copyJar(jarFile)
}
} }
} catch (e: IOException) { } catch (e: IOException) {
logger.error(e) { logger.error(e) {
@@ -106,12 +106,6 @@ class ServerService @Inject constructor(
process = runtime.exec("""$javaExePath -jar "${jarFile.absolutePath}"""").also { process = runtime.exec("""$javaExePath -jar "${jarFile.absolutePath}"""").also {
reader = it.inputStream.bufferedReader() reader = it.inputStream.bufferedReader()
} }
runtime.addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
logger.info { "Server started successfully" } logger.info { "Server started successfully" }
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
@@ -124,6 +118,9 @@ class ServerService @Inject constructor(
} }
logger.info { line } logger.info { line }
} }
if (initialized.value == ServerResult.STARTING) {
initialized.value = ServerResult.FAILED
}
logger.info { "Server closed" } logger.info { "Server closed" }
val exitVal = process?.waitFor() val exitVal = process?.waitFor()
logger.info { "Process exitValue: $exitVal" } 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.getChapterQuery
import ca.gosyer.data.server.requests.getMangaChaptersQuery import ca.gosyer.data.server.requests.getMangaChaptersQuery
import ca.gosyer.data.server.requests.getPageQuery 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@@ -22,17 +27,19 @@ class ChapterInteractionHandler @Inject constructor(
serverPreferences: ServerPreferences serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, 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>>( client.getRepeat<List<Chapter>>(
serverUrl + getMangaChaptersQuery(mangaId) serverUrl + getMangaChaptersQuery(mangaId)
) ) {
url {
if (refresh) {
parameter("onlineFetch", true)
}
}
}
} }
suspend fun getChapters(manga: Manga) = withContext(Dispatchers.IO) { suspend fun getChapters(manga: Manga, refresh: Boolean = false) = getChapters(manga.id, refresh)
client.getRepeat<Chapter>(
serverUrl + getMangaChaptersQuery(manga.id)
)
}
suspend fun getChapter(mangaId: Long, chapterIndex: Int) = withContext(Dispatchers.IO) { suspend fun getChapter(mangaId: Long, chapterIndex: Int) = withContext(Dispatchers.IO) {
client.getRepeat<Chapter>( 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, 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) { suspend fun getPage(mangaId: Long, chapterIndex: Int, pageNum: Int) = withContext(Dispatchers.IO) {
imageFromUrl( 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, 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.apkIconQuery
import ca.gosyer.data.server.requests.apkInstallQuery import ca.gosyer.data.server.requests.apkInstallQuery
import ca.gosyer.data.server.requests.apkUninstallQuery import ca.gosyer.data.server.requests.apkUninstallQuery
import ca.gosyer.data.server.requests.apkUpdateQuery
import ca.gosyer.data.server.requests.extensionListQuery import ca.gosyer.data.server.requests.extensionListQuery
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -31,13 +32,19 @@ class ExtensionInteractionHandler @Inject constructor(
suspend fun installExtension(extension: Extension) = withContext(Dispatchers.IO) { suspend fun installExtension(extension: Extension) = withContext(Dispatchers.IO) {
client.getRepeat<HttpResponse>( 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) { suspend fun uninstallExtension(extension: Extension) = withContext(Dispatchers.IO) {
client.getRepeat<HttpResponse>( 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.ServerPreferences
import ca.gosyer.data.server.requests.mangaQuery import ca.gosyer.data.server.requests.mangaQuery
import ca.gosyer.data.server.requests.mangaThumbnailQuery import ca.gosyer.data.server.requests.mangaThumbnailQuery
import io.ktor.client.request.parameter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@@ -20,12 +21,20 @@ class MangaInteractionHandler @Inject constructor(
serverPreferences: ServerPreferences serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, 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>( client.getRepeat<Manga>(
serverUrl + mangaQuery(mangaId) 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) { suspend fun getMangaThumbnail(mangaId: Long) = withContext(Dispatchers.IO) {
imageFromUrl( imageFromUrl(
client, client,

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) = fun getChapterQuery(mangaId: Long, chapterIndex: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex" "/api/v1/manga/$mangaId/chapter/$chapterIndex"
@Patch
fun updateChapterRequest(mangaId: Long, chapterIndex: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex"
@Get @Get
fun getPageQuery(mangaId: Long, chapterIndex: Int, index: Int) = fun getPageQuery(mangaId: Long, chapterIndex: Int, index: Int) =
"/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$index" "/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$index"

View File

@@ -11,12 +11,16 @@ fun extensionListQuery() =
"/api/v1/extension/list" "/api/v1/extension/list"
@Get @Get
fun apkInstallQuery(apkName: String) = fun apkInstallQuery(pkgName: String) =
"/api/v1/extension/install/$apkName" "/api/v1/extension/install/$pkgName"
@Get @Get
fun apkUninstallQuery(apkName: String) = fun apkUpdateQuery(pkgName: String) =
"/api/v1/extension/uninstall/$apkName" "/api/v1/extension/update/$pkgName"
@Get
fun apkUninstallQuery(pkgName: String) =
"/api/v1/extension/uninstall/$pkgName"
@Get @Get
fun apkIconQuery(apkName: String) = 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 package ca.gosyer.ui.base.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -42,11 +41,11 @@ fun MangaGridItem(
) )
Surface( Surface(
onClick = onClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(mangaAspectRatio) .aspectRatio(mangaAspectRatio)
.padding(8.dp) .padding(8.dp),
.clickable(onClick = onClick),
elevation = 4.dp, elevation = 4.dp,
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) { ) {

View File

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

View File

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

View File

@@ -10,8 +10,6 @@ import ca.gosyer.data.models.Category
import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.data.server.interactions.CategoryInteractionHandler
import ca.gosyer.ui.base.vm.ViewModel import ca.gosyer.ui.base.vm.ViewModel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -50,36 +48,31 @@ class CategoriesMenuViewModel @Inject constructor(
} }
} }
fun updateCategories(manualUpdate: Boolean = false) { suspend fun updateRemoteCategories(manualUpdate: Boolean = false) {
val handler = CoroutineExceptionHandler { _, throwable -> val categories = _categories.value
logger.debug { throwable } val newCategories = categories.filter { it.id == null }
newCategories.forEach {
categoryHandler.createCategory(it.name)
} }
GlobalScope.launch(handler) { originalCategories.forEach { originalCategory ->
val categories = _categories.value val category = categories.find { it.id == originalCategory.id }
val newCategories = categories.filter { it.id == null } if (category == null) {
newCategories.forEach { categoryHandler.deleteCategory(originalCategory)
categoryHandler.createCategory(it.name) } else if (category.name != originalCategory.name) {
categoryHandler.modifyCategory(originalCategory, category.name)
} }
originalCategories.forEach { originalCategory -> }
val category = categories.find { it.id == originalCategory.id } val updatedCategories = categoryHandler.getCategories()
if (category == null) { updatedCategories.forEach { updatedCategory ->
categoryHandler.deleteCategory(originalCategory) val category = categories.find { it.id == updatedCategory.id || it.name == updatedCategory.name } ?: return@forEach
} else if (category.name != originalCategory.name) { if (category.order != updatedCategory.order) {
categoryHandler.modifyCategory(originalCategory, category.name) 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) { if (manualUpdate) {
getCategories() getCategories()
}
} }
} }

View File

@@ -85,12 +85,9 @@ fun ExtensionsMenu() {
ExtensionItem( ExtensionItem(
extension, extension,
serverUrl, serverUrl,
onInstallClicked = { onInstallClicked = vm::install,
vm.install(it) onUpdateClicked = vm::update,
}, onUninstallClicked = vm::uninstall
onUninstallClicked = {
vm.uninstall(it)
}
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
@@ -113,6 +110,7 @@ fun ExtensionItem(
extension: Extension, extension: Extension,
serverUrl: String, serverUrl: String,
onInstallClicked: (Extension) -> Unit, onInstallClicked: (Extension) -> Unit,
onUpdateClicked: (Extension) -> Unit,
onUninstallClicked: (Extension) -> Unit onUninstallClicked: (Extension) -> Unit
) { ) {
Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(MaterialTheme.colors.background)) { Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(MaterialTheme.colors.background)) {
@@ -133,16 +131,30 @@ fun ExtensionItem(
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("18+", fontSize = 14.sp, color = Color.Red) 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( 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) 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) { fun uninstall(extension: Extension) {
logger.info { "Uninstall clicked" } logger.info { "Uninstall clicked" }
scope.launch { scope.launch {

View File

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

View File

@@ -7,7 +7,6 @@
package ca.gosyer.ui.main package ca.gosyer.ui.main
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,11 +17,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -32,6 +33,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ca.gosyer.BuildConfig import ca.gosyer.BuildConfig
import ca.gosyer.data.ui.model.StartScreen
import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.extensions.ExtensionsMenu import ca.gosyer.ui.extensions.ExtensionsMenu
import ca.gosyer.ui.library.LibraryScreen 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.settings.SettingsServerScreen
import ca.gosyer.ui.sources.SourcesMenu import ca.gosyer.ui.sources.SourcesMenu
import com.github.zsoltk.compose.router.Router 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
import compose.icons.fontawesomeicons.Regular import compose.icons.fontawesomeicons.Regular
import compose.icons.fontawesomeicons.regular.Bookmark import compose.icons.fontawesomeicons.regular.Bookmark
import compose.icons.fontawesomeicons.regular.Compass import compose.icons.fontawesomeicons.regular.Compass
import compose.icons.fontawesomeicons.regular.Edit import compose.icons.fontawesomeicons.regular.Edit
import compose.icons.fontawesomeicons.regular.Map 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 @Composable
fun MainMenu() { fun MainMenu(rootBundle: Bundle) {
val vm = viewModel<MainViewModel>() val vm = viewModel<MainViewModel>()
Surface { Surface {
Router<Route>("TopLevel", Route.Library) { backStack -> Router("TopLevel", vm.startScreen.toRoute()) { backStack ->
Row { Row {
Surface(elevation = 2.dp) { Surface(elevation = 2.dp) {
Column(Modifier.width(200.dp).fillMaxHeight(),) { Column(Modifier.width(200.dp).fillMaxHeight(),) {
Box(Modifier.fillMaxWidth().height(60.dp)) { 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)) Spacer(Modifier.height(20.dp))
remember { TopLevelMenus.values() }.forEach { topLevelMenu -> remember { TopLevelMenus.values() }.forEach { topLevelMenu ->
MainMenuItem(topLevelMenu, backStack.elements.first() == topLevelMenu.menu) { MainMenuItem(
topLevelMenu,
backStack.elements.first() == topLevelMenu.menu
) {
backStack.newRoot(it) 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()) { Column(Modifier.fillMaxSize()) {
when (val routing = backStack.last()) { BundleScope("K${backStack.lastIndex}", rootBundle, false) {
is Route.Library -> LibraryScreen { when (val routing = backStack.last()) {
backStack.push(Route.Manga(it)) is Route.Library -> LibraryScreen {
} backStack.push(Route.Manga(it))
is Route.Sources -> SourcesMenu { }
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.Extensions -> ExtensionsMenu()
is Route.Manga -> MangaMenu(routing.mangaId, backStack)
is Route.Settings -> SettingsScreen(backStack) is Route.Settings -> SettingsScreen(backStack)
is Route.SettingsGeneral -> SettingsGeneralScreen(backStack) is Route.SettingsGeneral -> SettingsGeneralScreen(backStack)
is Route.SettingsAppearance -> SettingsAppearance(backStack) is Route.SettingsAppearance -> SettingsAppearance(backStack)
is Route.SettingsServer -> SettingsServerScreen(backStack) is Route.SettingsServer -> SettingsServerScreen(backStack)
is Route.SettingsLibrary -> SettingsLibraryScreen(backStack) is Route.SettingsLibrary -> SettingsLibraryScreen(backStack)
is Route.SettingsReader -> SettingsReaderScreen(backStack) is Route.SettingsReader -> SettingsReaderScreen(backStack)
/*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack) /*is Route.SettingsDownloads -> SettingsDownloadsScreen(backStack)
is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/ is Route.SettingsTracking -> SettingsTrackingScreen(backStack)*/
is Route.SettingsBrowse -> SettingsBrowseScreen(backStack) is Route.SettingsBrowse -> SettingsBrowseScreen(backStack)
is Route.SettingsBackup -> SettingsBackupScreen(backStack) is Route.SettingsBackup -> SettingsBackupScreen(backStack)
/*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack) /*is Route.SettingsSecurity -> SettingsSecurityScreen(backStack)
is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/ is Route.SettingsParentalControls -> SettingsParentalControlsScreen(backStack)*/
is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack) is Route.SettingsAdvanced -> SettingsAdvancedScreen(backStack)
}
} }
} }
} }
@@ -129,14 +125,16 @@ fun MainMenu() {
@Composable @Composable
fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Unit) { fun MainMenuItem(menu: TopLevelMenus, selected: Boolean, onClick: (Route) -> Unit) {
Card( Card(
Modifier.clickable { onClick(menu.menu) }.fillMaxWidth().height(40.dp), { onClick(menu.menu) },
Modifier.fillMaxWidth().height(40.dp),
backgroundColor = if (!selected) { backgroundColor = if (!selected) {
Color.Transparent Color.Transparent
} else { } else {
MaterialTheme.colors.primary.copy(0.30F) MaterialTheme.colors.primary.copy(0.30F)
}, },
contentColor = Color.Transparent, contentColor = Color.Transparent,
elevation = 0.dp elevation = 0.dp,
shape = RoundedCornerShape(8.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.width(16.dp)) 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) { enum class TopLevelMenus(val text: String, val icon: ImageVector, val menu: Route) {
Library("Library", FontAwesomeIcons.Regular.Bookmark, Route.Library), Library("Library", FontAwesomeIcons.Regular.Bookmark, Route.Library),
Sources("Sources", FontAwesomeIcons.Regular.Compass, Route.Sources), 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.weisj.darklaf.theme.IntelliJTheme
import com.github.zsoltk.compose.backpress.BackPressHandler import com.github.zsoltk.compose.backpress.BackPressHandler
import com.github.zsoltk.compose.backpress.LocalBackPressHandler import com.github.zsoltk.compose.backpress.LocalBackPressHandler
import com.github.zsoltk.compose.savedinstancestate.Bundle
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import org.apache.logging.log4j.core.config.Configurator import org.apache.logging.log4j.core.config.Configurator
@@ -112,6 +113,7 @@ fun main() {
) )
} }
val rootBundle = Bundle()
window.show { window.show {
AppTheme { AppTheme {
CompositionLocalProvider( CompositionLocalProvider(
@@ -119,7 +121,7 @@ fun main() {
) { ) {
val initialized by serverService.initialized.collectAsState() val initialized by serverService.initialized.collectAsState()
if (initialized == ServerResult.STARTED || initialized == ServerResult.UNUSED) { if (initialized == ServerResult.STARTED || initialized == ServerResult.UNUSED) {
MainMenu() MainMenu(rootBundle)
} else if (initialized == ServerResult.STARTING) { } else if (initialized == ServerResult.STARTING) {
LoadingScreen() LoadingScreen()
} else if (initialized == ServerResult.FAILED) { } 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.VerticalScrollbar
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.rememberScrollbarAdapter
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ca.gosyer.BuildConfig
import ca.gosyer.data.models.Chapter import ca.gosyer.data.models.Chapter
import ca.gosyer.data.models.Manga import ca.gosyer.data.models.Manga
import ca.gosyer.ui.base.components.KtorImage import ca.gosyer.ui.base.components.KtorImage
import ca.gosyer.ui.base.components.LoadingScreen import ca.gosyer.ui.base.components.LoadingScreen
import ca.gosyer.ui.base.components.Toolbar 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.components.mangaAspectRatio
import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route import ca.gosyer.ui.main.Route
import ca.gosyer.ui.reader.openReaderMenu import ca.gosyer.ui.reader.openReaderMenu
import ca.gosyer.util.compose.ThemedWindow import ca.gosyer.util.compose.ThemedWindow
import ca.gosyer.util.compose.contextMenu
import com.github.zsoltk.compose.router.BackStack import com.github.zsoltk.compose.router.BackStack
import java.util.Date import java.util.Date
fun openMangaMenu(mangaId: Long) { fun openMangaMenu(mangaId: Long) {
ThemedWindow("TachideskJUI") { ThemedWindow(BuildConfig.NAME) {
MangaMenu(mangaId) MangaMenu(mangaId)
} }
} }
@@ -87,9 +91,14 @@ fun MangaMenu(mangaId: Long, backStack: BackStack<Route>? = null) {
MangaItem(manga, serverUrl) MangaItem(manga, serverUrl)
} }
items(chapters) { chapter -> items(chapters) { chapter ->
ChapterItem(chapter, dateFormat::format) { ChapterItem(
openReaderMenu(it, manga.id) chapter,
} dateFormat::format,
onClick = { openReaderMenu(it, manga.id) },
toggleRead = vm::toggleRead,
toggleBookmarked = vm::toggleBookmarked,
markPreviousAsRead = vm::markPreviousRead
)
} }
} }
VerticalScrollbar( VerticalScrollbar(
@@ -178,20 +187,61 @@ private fun MangaInfo(manga: Manga, modifier: Modifier = Modifier) {
} }
@Composable @Composable
fun ChapterItem(chapter: Chapter, format: (Date) -> String, onClick: (Int) -> Unit) { fun ChapterItem(
Surface(modifier = Modifier.fillMaxWidth().clickable { onClick(chapter.chapterIndex) }.height(70.dp).padding(4.dp), elevation = 1.dp) { chapter: Chapter,
Column(Modifier.padding(4.dp)) { format: (Date) -> String,
Text(chapter.name, fontSize = 20.sp, maxLines = 1) 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>() val description = mutableListOf<String>()
if (chapter.dateUpload != 0L) { if (chapter.uploadDate != 0L) {
description += format(Date(chapter.dateUpload)) description += format(Date(chapter.uploadDate))
} }
if (!chapter.scanlator.isNullOrEmpty()) { if (!chapter.scanlator.isNullOrEmpty()) {
description += chapter.scanlator description += chapter.scanlator
} }
if (description.isNotEmpty()) { if (description.isNotEmpty()) {
Spacer(Modifier.height(2.dp)) 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 { async {
try { try {
_chapters.value = chapterHandler.getChapters(mangaId) _chapters.value = chapterHandler.getChapters(mangaId)
@@ -98,5 +98,32 @@ class MangaMenuViewModel @Inject constructor(
else -> SimpleDateFormat(format, Locale.getDefault()) 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) data class Params(val mangaId: Long)
} }

View File

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

View File

@@ -6,28 +6,89 @@
package ca.gosyer.ui.settings package ca.gosyer.ui.settings
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.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.components.Toolbar
import ca.gosyer.ui.base.prefs.PreferenceRow 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.base.vm.viewModel import ca.gosyer.ui.base.vm.viewModel
import ca.gosyer.ui.main.Route import ca.gosyer.ui.main.Route
import ca.gosyer.util.system.filePicker import ca.gosyer.util.system.filePicker
import ca.gosyer.util.system.fileSaver
import com.github.zsoltk.compose.router.BackStack 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 mu.KotlinLogging
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
class SettingsBackupViewModel @Inject constructor() : ViewModel() { class SettingsBackupViewModel @Inject constructor(
private val backupHandler: BackupInteractionHandler
) : ViewModel() {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
fun setFile(file: File?) { private val _restoring = MutableStateFlow(false)
if (file == null || !file.exists()) { val restoring = _restoring.asStateFlow()
logger.info { "Invalid file ${file?.absolutePath}" } private val _restoreError = MutableStateFlow(false)
} else { val restoreError = _restoreError.asStateFlow()
logger.info { file.absolutePath }
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 @Composable
fun SettingsBackupScreen(navController: BackStack<Route>) { fun SettingsBackupScreen(navController: BackStack<Route>) {
val vm = viewModel<SettingsBackupViewModel>() 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 { Column {
Toolbar("Backup Settings", navController, true) Toolbar("Backup Settings", navController, true)
LazyColumn { LazyColumn {
item { item {
PreferenceRow( PreferenceFile(
"Restore Backup", "Restore Backup",
onClick = { "Restore a backup into Tachidesk",
filePicker { restoring,
vm.setFile(it.selectedFile) 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ca.gosyer.data.models.Source 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.ui.sources.components.SourceScreen
import ca.gosyer.util.compose.ThemedWindow import ca.gosyer.util.compose.ThemedWindow
import com.github.zsoltk.compose.savedinstancestate.Bundle import com.github.zsoltk.compose.savedinstancestate.Bundle
import com.github.zsoltk.compose.savedinstancestate.BundleScope import com.github.zsoltk.compose.savedinstancestate.LocalSavedInstanceState
fun openSourcesMenu() { fun openSourcesMenu() {
ThemedWindow(title = "TachideskJUI - Sources") { ThemedWindow(title = "TachideskJUI - Sources") {
@@ -47,9 +48,7 @@ private const val SOURCE_MENU_KEY = "source_menu"
@Composable @Composable
fun SourcesMenu(onMangaClick: (Long) -> Unit) { fun SourcesMenu(onMangaClick: (Long) -> Unit) {
BundleScope(SOURCE_MENU_KEY, autoDispose = false) { SourcesMenu(LocalSavedInstanceState.current, onMangaClick)
SourcesMenu(it, onMangaClick)
}
} }
@Composable @Composable
@@ -87,12 +86,10 @@ fun SourcesMenu(bundle: Bundle, onMangaClick: (Long) -> Unit) {
} }
val selectedSource: Source? = selectedSourceTab val selectedSource: Source? = selectedSourceTab
BundleScope("Sources") { if (selectedSource != null) {
if (selectedSource != null) { SourceScreen(selectedSource, onMangaClick)
SourceScreen(selectedSource, onMangaClick) } else {
} else { SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab)
SourceHomeScreen(isLoading, sources, serverUrl, vm::addTab)
}
} }
} }
} }

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 java.io.File
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.filechooser.FileNameExtensionFilter
val appDirs: AppDirs by lazy { val appDirs: AppDirs by lazy {
AppDirsFactory.getInstance() 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 * 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 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 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 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 * @param onApprove the listener that is called when picking a file is completed
*/ */
fun filePicker( private fun fileChooser(
saving: Boolean = false,
builder: JFileChooser.() -> Unit = {}, builder: JFileChooser.() -> Unit = {},
onCancel: (JFileChooser) -> Unit = {}, onCancel: (JFileChooser) -> Unit = {},
onError: (JFileChooser) -> Unit = {}, onError: (JFileChooser) -> Unit = {},
onApprove: (JFileChooser) -> Unit onApprove: (JFileChooser) -> Unit,
extension: String? = null,
defaultFileName: String = ""
) = SwingUtilities.invokeLater { ) = SwingUtilities.invokeLater {
val fileChooser = JFileChooser() val fileChooser = JFileChooser()
.apply { .apply {
val details = actionMap.get("viewTypeDetails") val details = actionMap.get("viewTypeDetails")
details?.actionPerformed(null) details?.actionPerformed(null)
if (extension != null) {
fileFilter = FileNameExtensionFilter("$extension file", extension)
}
if (saving) {
selectedFile = File(defaultFileName)
}
} }
.apply(builder) .apply(builder)
val result = fileChooser val result = fileChooser.let {
.showOpenDialog(null) if (saving) {
it.showSaveDialog(null)
} else {
it.showOpenDialog(null)
}
}
when (result) { when (result) {
JFileChooser.APPROVE_OPTION -> onApprove(fileChooser) JFileChooser.APPROVE_OPTION -> onApprove(fileChooser)