diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..b6c1b48
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..23b2e1f
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
index 8184c57..93a3134 100644
--- a/.idea/deploymentTargetDropDown.xml
+++ b/.idea/deploymentTargetDropDown.xml
@@ -2,21 +2,11 @@
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/.idea/other.xml b/.idea/other.xml
new file mode 100644
index 0000000..f3d4a2e
--- /dev/null
+++ b/.idea/other.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 612ffe8..13b2ceb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,6 +11,7 @@ plugins {
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.parcelize")
id("com.google.devtools.ksp")
+ id("com.mikepenz.aboutlibraries.plugin")
id("dagger.hilt.android.plugin")
}
@@ -36,7 +37,7 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = "org.xtimms.tokusho.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
}
@@ -85,20 +86,22 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
- implementation(platform("androidx.compose:compose-bom:2024.01.00"))
+ implementation(platform("dev.chrisbanes.compose:compose-bom:2024.02.00-alpha02"))
implementation("androidx.compose.animation:animation-graphics")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
- implementation("androidx.compose.material:material-icons-extended:1.6.0")
- implementation("androidx.compose.material3:material3-android:1.2.0")
- implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
- implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
- implementation("androidx.navigation:navigation-compose:2.7.6")
+ implementation("androidx.compose.material:material-icons-extended:1.6.3")
+ implementation("androidx.compose.material3:material3-android:1.2.1")
+ implementation("androidx.compose.material3:material3-window-size-class:1.2.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+ implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
@@ -112,12 +115,15 @@ dependencies {
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
- implementation("androidx.hilt:hilt-work:1.1.0")
- kapt("androidx.hilt:hilt-compiler:1.1.0")
- implementation("com.github.KotatsuApp:kotatsu-parsers:a8f9423307")
+ implementation("androidx.hilt:hilt-work:1.2.0")
+ kapt("androidx.hilt:hilt-compiler:1.2.0")
+ implementation("com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9") {
+ exclude(group = "org.json", module = "json")
+ }
+ implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
- implementation("com.squareup.okio:okio:3.7.0")
+ implementation("com.squareup.okio:okio:3.8.0")
implementation("com.tencent:mmkv:1.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
@@ -128,6 +134,9 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
+ androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
+ kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json
new file mode 100644
index 0000000..38f6ab6
--- /dev/null
+++ b/app/src/androidTest/assets/categories/simple.json
@@ -0,0 +1,9 @@
+{
+ "id": 4,
+ "title": "Read later",
+ "sortKey": 1,
+ "order": "NEWEST",
+ "createdAt": 1335906000000,
+ "isTrackingEnabled": true,
+ "isVisibleInLibrary": true
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/kotatsu_test.bak b/app/src/androidTest/assets/kotatsu_test.bak
new file mode 100644
index 0000000..a6eae4c
Binary files /dev/null and b/app/src/androidTest/assets/kotatsu_test.bak differ
diff --git a/app/src/androidTest/assets/manga/bad_ids.json b/app/src/androidTest/assets/manga/bad_ids.json
new file mode 100644
index 0000000..7058634
--- /dev/null
+++ b/app/src/androidTest/assets/manga/bad_ids.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n",
+ "chapters": [
+ {
+ "id": 1552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/empty.json b/app/src/androidTest/assets/manga/empty.json
new file mode 100644
index 0000000..91838e2
--- /dev/null
+++ b/app/src/androidTest/assets/manga/empty.json
@@ -0,0 +1,36 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n",
+ "chapters": [],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/first_chapters.json b/app/src/androidTest/assets/manga/first_chapters.json
new file mode 100644
index 0000000..9e1c1d7
--- /dev/null
+++ b/app/src/androidTest/assets/manga/first_chapters.json
@@ -0,0 +1,136 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/full.json b/app/src/androidTest/assets/manga/full.json
new file mode 100644
index 0000000..685e424
--- /dev/null
+++ b/app/src/androidTest/assets/manga/full.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/header.json b/app/src/androidTest/assets/manga/header.json
new file mode 100644
index 0000000..5e53ed5
--- /dev/null
+++ b/app/src/androidTest/assets/manga/header.json
@@ -0,0 +1,35 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": null,
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/without_middle_chapter.json b/app/src/androidTest/assets/manga/without_middle_chapter.json
new file mode 100644
index 0000000..97d797b
--- /dev/null
+++ b/app/src/androidTest/assets/manga/without_middle_chapter.json
@@ -0,0 +1,154 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt
deleted file mode 100644
index b006053..0000000
--- a/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package org.xtimms.tokusho
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("org.xtimms.tokusho", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt b/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt
new file mode 100644
index 0000000..719a393
--- /dev/null
+++ b/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt
@@ -0,0 +1,13 @@
+package org.xtimms.tokusho
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+class HiltTestRunner : AndroidJUnitRunner() {
+
+ override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
+ return super.newApplication(cl, HiltTestApplication::class.java.name, context)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt b/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt
new file mode 100644
index 0000000..23266ba
--- /dev/null
+++ b/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt
@@ -0,0 +1,9 @@
+package org.xtimms.tokusho
+
+import android.app.Instrumentation
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont ->
+ waitForIdle { cont.resume(Unit) }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt b/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt
new file mode 100644
index 0000000..cd95d96
--- /dev/null
+++ b/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt
@@ -0,0 +1,59 @@
+package org.xtimms.tokusho
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.squareup.moshi.FromJson
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.JsonReader
+import com.squareup.moshi.JsonWriter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.ToJson
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okio.buffer
+import okio.source
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.tokusho.core.model.FavouriteCategory
+import java.util.Date
+import kotlin.reflect.KClass
+
+object SampleData {
+
+ private val moshi = Moshi.Builder()
+ .add(DateAdapter())
+ .add(KotlinJsonAdapterFactory())
+ .build()
+
+ val manga: Manga = loadAsset("manga/header.json", Manga::class)
+
+ val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
+
+ val tag = mangaDetails.tags.elementAt(2)
+
+ val chapter = checkNotNull(mangaDetails.chapters)[2]
+
+ val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
+
+ fun loadAsset(name: String, cls: KClass): T {
+ val assets = InstrumentationRegistry.getInstrumentation().context.assets
+ return assets.open(name).use {
+ moshi.adapter(cls.java).fromJson(it.source().buffer())
+ } ?: throw RuntimeException("Cannot read asset from json \"$name\"")
+ }
+
+ private class DateAdapter : JsonAdapter() {
+
+ @FromJson
+ override fun fromJson(reader: JsonReader): Date? {
+ val ms = reader.nextLong()
+ return if (ms == 0L) {
+ null
+ } else {
+ Date(ms)
+ }
+ }
+
+ @ToJson
+ override fun toJson(writer: JsonWriter, value: Date?) {
+ writer.value(value?.time ?: 0L)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt
new file mode 100644
index 0000000..7a1008f
--- /dev/null
+++ b/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt
@@ -0,0 +1,109 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.content.res.AssetManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.xtimms.tokusho.SampleData
+import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.database.entity.toMangaTags
+import org.xtimms.tokusho.data.repository.FavouritesRepository
+import org.xtimms.tokusho.data.repository.HistoryRepository
+import org.xtimms.tokusho.data.repository.backup.BackupRepository
+import java.io.File
+import javax.inject.Inject
+
+@HiltAndroidTest
+@RunWith(AndroidJUnit4::class)
+class AppBackupAgentTest {
+
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var historyRepository: HistoryRepository
+
+ @Inject
+ lateinit var favouritesRepository: FavouritesRepository
+
+ @Inject
+ lateinit var backupRepository: BackupRepository
+
+ @Inject
+ lateinit var database: TokushoDatabase
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ database.clearAllTables()
+ }
+
+ @Test
+ fun backupAndRestore() = runTest {
+ val category = favouritesRepository.createCategory(
+ title = SampleData.favouriteCategory.title,
+ sortOrder = SampleData.favouriteCategory.order,
+ isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
+ isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
+ )
+ favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
+ historyRepository.addOrUpdate(
+ manga = SampleData.mangaDetails,
+ chapterId = SampleData.mangaDetails.chapters!![2].id,
+ page = 3,
+ scroll = 40,
+ percent = 0.2f,
+ )
+ val history = checkNotNull(historyRepository.getOne(SampleData.manga))
+
+ val agent = AppBackupAgent()
+ val backup = agent.createBackupFile(
+ context = InstrumentationRegistry.getInstrumentation().targetContext,
+ repository = backupRepository,
+ )
+
+ database.clearAllTables()
+ assertTrue(favouritesRepository.getAllManga().isEmpty())
+ assertNull(historyRepository.getLastOrNull())
+
+ backup.inputStream().use {
+ agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
+ }
+
+ assertEquals(category, favouritesRepository.getCategory(category.id))
+ assertEquals(history, historyRepository.getOne(SampleData.manga))
+ assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
+
+ val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
+ assertTrue(SampleData.tag in allTags)
+ }
+
+ @Test
+ fun restoreOldBackup() {
+ val agent = AppBackupAgent()
+ val backup = File.createTempFile("backup_", ".tmp")
+ InstrumentationRegistry.getInstrumentation().context.assets
+ .open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
+ .use { input ->
+ backup.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ backup.inputStream().use {
+ agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
+ }
+ runTest {
+ assertEquals(6, historyRepository.observeAll().first().size)
+ assertEquals(2, favouritesRepository.observeCategories().first().size)
+ assertEquals(15, favouritesRepository.getAllManga().size)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1e98635..4c8c5f6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -23,6 +23,7 @@
diff --git a/app/src/main/assets/font/manrope_variable.ttf b/app/src/main/assets/font/manrope_variable.ttf
new file mode 100644
index 0000000..21c45b9
Binary files /dev/null and b/app/src/main/assets/font/manrope_variable.ttf differ
diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt
index 5fb30ca..a53cc62 100644
--- a/app/src/main/java/org/xtimms/tokusho/App.kt
+++ b/app/src/main/java/org/xtimms/tokusho/App.kt
@@ -6,6 +6,8 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.StrictMode
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.ProcessLifecycleOwner
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
@@ -19,6 +21,7 @@ import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.KotatsuAppSettings
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
@@ -30,6 +33,9 @@ class App : Application() {
@Inject
lateinit var database: Provider
+ @Inject
+ lateinit var settings: KotatsuAppSettings
+
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)
diff --git a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt
index fb31be8..d018e00 100644
--- a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt
+++ b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt
@@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import org.xtimms.shiki.ui.theme.SEED
+import org.xtimms.tokusho.ui.theme.SEED
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference
import org.xtimms.tokusho.core.prefs.paletteStyles
diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
index 487bdcb..d3e6b9d 100644
--- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
+++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt
@@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -39,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
@@ -58,6 +60,9 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ private val isReady: MutableState = mutableStateOf(false)
+ private val isDone: MutableState = mutableStateOf(false)
+
@Inject
lateinit var coil: ImageLoader
@@ -65,6 +70,7 @@ class MainActivity : ComponentActivity() {
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen().setKeepOnScreenCondition { !isDone.value }
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -77,18 +83,26 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
- SettingsProvider {
- TokushoTheme(
- darkTheme = LocalDarkTheme.current.isDarkTheme(),
- isDynamicColorEnabled = LocalDynamicColorSwitch.current,
- isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
- ) {
- MainView(
- coil = coil,
- loggers = loggers,
- isCompactScreen = isCompactScreen,
- navController = navController
- )
+ LaunchedEffect(Unit) {
+ isReady.value = true
+ }
+ if (isReady.value) {
+ SettingsProvider {
+ TokushoTheme(
+ darkTheme = LocalDarkTheme.current.isDarkTheme(),
+ isDynamicColorEnabled = LocalDynamicColorSwitch.current,
+ isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
+ ) {
+ MainView(
+ coil = coil,
+ loggers = loggers,
+ isCompactScreen = isCompactScreen,
+ navController = navController
+ )
+ LaunchedEffect(Unit) {
+ isDone.value = true
+ }
+ }
}
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt
index 9652aff..43feba9 100644
--- a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt
+++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt
@@ -14,6 +14,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.cache.CacheDir
@@ -21,11 +24,13 @@ import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.MemoryContentCache
import org.xtimms.tokusho.core.cache.StubContentCache
import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.network.MangaHttpClient
import org.xtimms.tokusho.core.os.NetworkState
import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher
+import org.xtimms.tokusho.core.parser.local.LocalStorageChanges
import org.xtimms.tokusho.utils.CoilImageGetter
import org.xtimms.tokusho.utils.system.connectivityManager
import org.xtimms.tokusho.utils.system.isLowRamDevice
@@ -98,6 +103,16 @@ interface TokushoModule {
}
}
+ @Provides
+ @Singleton
+ @LocalStorageChanges
+ fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow()
+
+ @Provides
+ @LocalStorageChanges
+ fun provideLocalStorageChangesFlow(
+ @LocalStorageChanges flow: MutableSharedFlow,
+ ): SharedFlow = flow.asSharedFlow()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt
index 599fe13..e3c6156 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt
@@ -4,15 +4,18 @@ import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import org.xtimms.tokusho.R
+import org.xtimms.tokusho.utils.composable.rememberResourceBitmapPainter
@Composable
fun AsyncImageImpl(
@@ -38,14 +41,12 @@ fun AsyncImageImpl(
)
else AsyncImage(
imageLoader = coil,
- model = model?.takeUnless { it == "" },
- contentDescription = contentDescription,
+ model = model,
+ placeholder = ColorPainter(Color(0x1F888888)),
+ error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
+ fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
modifier = modifier,
- transform = transform,
- onState = onState,
- alignment = alignment,
contentScale = contentScale,
- colorFilter = colorFilter,
- filterQuality = filterQuality
+ contentDescription = contentDescription
)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
index af32d2c..23fba18 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt
@@ -4,6 +4,8 @@ import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
@@ -21,8 +23,13 @@ import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
import org.xtimms.tokusho.sections.details.DetailsView
+import org.xtimms.tokusho.sections.details.FULL_POSTER_DESTINATION
+import org.xtimms.tokusho.sections.details.FullImageView
import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT
+import org.xtimms.tokusho.sections.details.PICTURES_ARGUMENT
import org.xtimms.tokusho.sections.explore.ExploreView
+import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
+import org.xtimms.tokusho.sections.feed.FeedView
import org.xtimms.tokusho.sections.history.HistoryView
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
import org.xtimms.tokusho.sections.list.MangaListView
@@ -33,6 +40,13 @@ import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.SettingsView
import org.xtimms.tokusho.sections.settings.about.ABOUT_DESTINATION
import org.xtimms.tokusho.sections.settings.about.AboutView
+import org.xtimms.tokusho.sections.settings.about.LICENSES_DESTINATION
+import org.xtimms.tokusho.sections.settings.about.LICENSE_CONTENT_ARGUMENT
+import org.xtimms.tokusho.sections.settings.about.LICENSE_DESTINATION
+import org.xtimms.tokusho.sections.settings.about.LICENSE_NAME_ARGUMENT
+import org.xtimms.tokusho.sections.settings.about.LICENSE_WEBSITE_ARGUMENT
+import org.xtimms.tokusho.sections.settings.about.LicenseView
+import org.xtimms.tokusho.sections.settings.about.OpenSourceLicensesView
import org.xtimms.tokusho.sections.settings.about.UPDATES_DESTINATION
import org.xtimms.tokusho.sections.settings.about.UpdateView
import org.xtimms.tokusho.sections.settings.advanced.ADVANCED_DESTINATION
@@ -43,16 +57,27 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
+import org.xtimms.tokusho.sections.settings.backup.BACKUP_RESTORE_DESTINATION
+import org.xtimms.tokusho.sections.settings.backup.BackupRestoreView
+import org.xtimms.tokusho.sections.settings.backup.RESTORE_ARGUMENT
+import org.xtimms.tokusho.sections.settings.backup.RESTORE_DESTINATION
+import org.xtimms.tokusho.sections.settings.backup.RestoreItemsView
+import org.xtimms.tokusho.sections.settings.network.NETWORK_DESTINATION
+import org.xtimms.tokusho.sections.settings.network.NetworkView
import org.xtimms.tokusho.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.ShelfSettingsView
import org.xtimms.tokusho.sections.settings.shelf.categories.CATEGORIES_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.categories.CategoriesView
+import org.xtimms.tokusho.sections.settings.sources.SOURCES_DESTINATION
+import org.xtimms.tokusho.sections.settings.sources.SourcesView
+import org.xtimms.tokusho.sections.settings.sources.catalog.CATALOG_DESTINATION
+import org.xtimms.tokusho.sections.settings.sources.catalog.SourcesCatalogView
import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.tokusho.sections.settings.storage.StorageView
-import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView
import org.xtimms.tokusho.sections.stats.STATS_DESTINATION
import org.xtimms.tokusho.sections.stats.StatsView
+import org.xtimms.tokusho.utils.StringArrayNavType
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
const val DURATION_ENTER = 400
@@ -77,6 +102,15 @@ fun Navigation(
val navigateBack: () -> Unit = { navController.popBackStack() }
+ val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content ->
+ navController.navigate(
+ LICENSE_DESTINATION
+ .replace(LICENSE_NAME_ARGUMENT, name)
+ .replace(LICENSE_WEBSITE_ARGUMENT, website.orEmpty())
+ .replace(LICENSE_CONTENT_ARGUMENT, content ?: "No license text")
+ )
+ }
+
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
@@ -102,14 +136,18 @@ fun Navigation(
) {
composable(BottomNavDestination.Shelf.route) {
- val library: ShelfMap = emptyMap()
ShelfView(
- currentPage = { 0 },
+ coil = coil,
+ currentPage = { 2 },
showPageTabs = true,
- getNumberOfMangaForCategory = { 2 },
- getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
padding = padding,
topBarHeightPx = topBarHeightPx,
+ navigateToDetails = {
+ navController.navigate(
+ DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
+ )
+ },
+ onRefresh = { true }
)
}
@@ -142,12 +180,26 @@ fun Navigation(
)
}
+ composable(FEED_DESTINATION) {
+ FeedView(
+ navigateBack = navigateBack,
+ navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
+ )
+ }
+
composable(SETTINGS_DESTINATION) {
SettingsView(
navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
+ navigateToBackupRestoreSettings = {
+ navController.navigate(
+ BACKUP_RESTORE_DESTINATION
+ )
+ },
+ navigateToMangaSources = { navController.navigate(SOURCES_DESTINATION) },
+ navigateToNetwork = { navController.navigate(NETWORK_DESTINATION) },
navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
)
@@ -155,7 +207,6 @@ fun Navigation(
composable(APPEARANCE_DESTINATION) {
AppearanceView(
- coil = coil,
navigateBack = navigateBack,
navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
@@ -174,6 +225,43 @@ fun Navigation(
)
}
+ composable(SOURCES_DESTINATION) {
+ SourcesView(
+ navigateBack = navigateBack,
+ navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) },
+ navigateToSourcesManagement = { /*TODO*/ }
+ )
+ }
+
+ composable(CATALOG_DESTINATION) {
+ SourcesCatalogView(
+ navigateBack = navigateBack,
+ )
+ }
+
+ composable(BACKUP_RESTORE_DESTINATION) {
+ BackupRestoreView(
+ navigateBack = navigateBack,
+ navigateToRestoreScreen = {
+ navController.navigate(RESTORE_DESTINATION.replace(RESTORE_ARGUMENT, it))
+ }
+ )
+ }
+
+ composable(
+ route = RESTORE_DESTINATION,
+ arguments = listOf(
+ navArgument(RESTORE_ARGUMENT.removeFirstAndLast()) {
+ type = NavType.StringType
+ }
+ )
+ ) { navEntry ->
+ RestoreItemsView(
+ uri = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?: "",
+ navigateBack = navigateBack
+ )
+ }
+
composable(SHELF_SETTINGS_DESTINATION) {
ShelfSettingsView(
navigateBack = navigateBack,
@@ -187,6 +275,12 @@ fun Navigation(
)
}
+ composable(NETWORK_DESTINATION) {
+ NetworkView(
+ navigateBack = navigateBack,
+ )
+ }
+
composable(STORAGE_DESTINATION) {
StorageView(
navigateBack = navigateBack,
@@ -233,10 +327,43 @@ fun Navigation(
composable(ABOUT_DESTINATION) {
AboutView(
navigateBack = navigateBack,
+ navigateToLicensesPage = { navController.navigate(LICENSES_DESTINATION) },
navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) }
)
}
+ composable(LICENSES_DESTINATION) {
+ OpenSourceLicensesView(
+ navigateBack = navigateBack,
+ navigateToLicensePage = navigateToLicense
+ )
+ }
+
+ composable(
+ route = LICENSE_DESTINATION,
+ arguments = listOf(
+ navArgument(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) {
+ type = NavType.StringType
+ },
+ navArgument(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) {
+ type = NavType.StringType
+ },
+ navArgument(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) {
+ type = NavType.StringType
+ }
+ )
+ ) { navEntry ->
+ LicenseView(
+ name = navEntry.arguments?.getString(LICENSE_NAME_ARGUMENT.removeFirstAndLast())
+ .orEmpty(),
+ website = navEntry.arguments?.getString(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast())
+ .orEmpty(),
+ license = navEntry.arguments?.getString(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast())
+ ?: "No license text",
+ navigateBack = navigateBack
+ )
+ }
+
composable(UPDATES_DESTINATION) {
UpdateView(
navigateBack = navigateBack,
@@ -249,13 +376,42 @@ fun Navigation(
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
type = NavType.LongType
}
- )
+ ),
) { navEntry ->
DetailsView(
coil = coil,
+ mangaId = navEntry.arguments?.getLong(MANGA_ID_ARGUMENT.removeFirstAndLast()) ?: 0L,
navigateBack = navigateBack,
+ navigateToFullImage = { pictures ->
+ navController.navigate(
+ FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures)
+ )
+ },
+ navigateToDetails = {
+ navController.navigate(
+ DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
+ )
+ },
+ navigateToSource = {
+ navController.navigate(
+ LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
+ )
+ }
)
}
- }
+ composable(
+ FULL_POSTER_DESTINATION,
+ arguments = listOf(
+ navArgument(PICTURES_ARGUMENT.removeFirstAndLast()) { type = StringArrayNavType }
+ ),
+ ) { navEntry ->
+ FullImageView(
+ coil = coil,
+ pictures = navEntry.arguments?.getStringArray(PICTURES_ARGUMENT.removeFirstAndLast())
+ ?: emptyArray(),
+ navigateBack = navigateBack
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt
new file mode 100644
index 0000000..4627c75
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt
@@ -0,0 +1,79 @@
+package org.xtimms.tokusho.core.base.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.utils.lang.EventFlow
+import org.xtimms.tokusho.utils.lang.MutableEventFlow
+import org.xtimms.tokusho.utils.lang.call
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
+
+abstract class KotatsuBaseViewModel : ViewModel() {
+
+ @JvmField
+ protected val loadingCounter = MutableStateFlow(0)
+
+ @JvmField
+ protected val errorEvent = MutableEventFlow()
+
+ val onError: EventFlow
+ get() = errorEvent
+
+ val isLoading: StateFlow = loadingCounter.map { it > 0 }
+ .stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
+
+ protected fun launchJob(
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+ block: suspend CoroutineScope.() -> Unit
+ ): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
+
+ protected fun launchLoadingJob(
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+ block: suspend CoroutineScope.() -> Unit
+ ): Job = viewModelScope.launch(context + createErrorHandler(), start) {
+ loadingCounter.increment()
+ try {
+ block()
+ } finally {
+ loadingCounter.decrement()
+ }
+ }
+
+ protected fun Flow.withLoading() = onStart {
+ loadingCounter.increment()
+ }.onCompletion {
+ loadingCounter.decrement()
+ }
+
+ protected fun Flow.withErrorHandling() = catch { error ->
+ errorEvent.call(error)
+ }
+
+ protected fun MutableStateFlow.increment() = update { it + 1 }
+
+ protected fun MutableStateFlow.decrement() = update { it - 1 }
+
+ private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
+ if (throwable !is CancellationException) {
+ errorEvent.call(throwable)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt
new file mode 100644
index 0000000..e99d62e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt
@@ -0,0 +1,114 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.min
+import org.xtimms.tokusho.ui.theme.TokushoTheme
+import java.lang.Integer.MAX_VALUE
+import kotlin.math.min
+
+enum class ButtonType { PRIMARY, SECONDARY, TERTIARY, DELETE }
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun AnimatedButton(
+ modifier: Modifier = Modifier,
+ type: ButtonType,
+ icon: ImageVector? = null,
+ onClick: (() -> Unit) = {},
+ onLongClick: (() -> Unit) = {},
+) {
+ val localDensity = LocalDensity.current
+ var minSize by remember { mutableStateOf(MAX_VALUE.dp) }
+ var minSizeFloat by remember { mutableStateOf(MAX_VALUE.toFloat()) }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed = interactionSource.collectIsPressedAsState()
+ val radius = animateDpAsState(targetValue = if (isPressed.value) 12.dp else minSize / 2)
+
+ val color = when (type) {
+ ButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer
+ ButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer
+ ButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer
+ ButtonType.DELETE -> MaterialTheme.colorScheme.errorContainer
+ }
+
+ val contentColor = when (type) {
+ ButtonType.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer
+ ButtonType.SECONDARY -> MaterialTheme.colorScheme.onSecondaryContainer
+ ButtonType.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer
+ ButtonType.DELETE -> MaterialTheme.colorScheme.onErrorContainer
+ }
+
+ Surface(
+ tonalElevation = 10.dp,
+ modifier = modifier
+ .fillMaxSize()
+ .onGloballyPositioned {
+ minSize = with(localDensity) { min(it.size.height, it.size.width).toDp() }
+ minSizeFloat = min(it.size.height, it.size.width).toFloat()
+ }
+ .clip(RoundedCornerShape(radius.value))
+ ) {
+ Box(
+ modifier = Modifier
+ .background(color = color)
+ .fillMaxSize()
+ .clip(RoundedCornerShape(radius.value))
+ .combinedClickable(
+ interactionSource = interactionSource,
+ indication = ripple(),
+ onClick = { onClick.invoke() },
+ onLongClick = { onLongClick.invoke() },
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (icon !== null) {
+ Icon(
+ imageVector = icon,
+ tint = contentColor,
+ modifier = Modifier.size(min(minSize * 0.5f, 154.dp)),
+ contentDescription = null,
+ )
+ }
+ }
+ }
+}
+
+@Preview(name = "Icon")
+@Composable
+private fun PreviewWithIcon() {
+ TokushoTheme {
+ AnimatedButton(
+ type = ButtonType.PRIMARY,
+ icon = Icons.Outlined.Edit
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt
new file mode 100644
index 0000000..6d6c4bc
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt
@@ -0,0 +1,81 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FloatTweenSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.core.components.shape.WavyShape
+import org.xtimms.tokusho.utils.lang.clamp
+import org.xtimms.tokusho.utils.material.HarmonizedColorPalette
+import kotlin.math.ceil
+
+@Composable
+fun BackgroundProgress(
+ color: Color,
+) {
+
+ val percentWithNewSpent = 0.3f
+
+ val percentWithNewSpentAnimated = animateFloatAsState(
+ label = "percentWithNewSpentAnimated",
+ targetValue = percentWithNewSpent,
+ animationSpec = TweenSpec(300),
+ ).value
+
+ val shift = remember { Animatable(0f) }
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ fun anim() {
+ coroutineScope.launch {
+ shift.animateTo(
+ 1f,
+ animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
+ )
+ shift.snapTo(0f)
+ anim()
+ }
+ }
+
+ anim()
+ }
+
+ Box(Modifier.fillMaxSize()) {
+ Box(
+ modifier = Modifier
+ .background(
+ color.copy(alpha = 0.33f),
+ shape = WavyShape(
+ period = 30.dp,
+ amplitude = percentWithNewSpentAnimated.clamp(0.96f, 1f) * 2.dp,
+ shift = shift.value,
+ ),
+ )
+ .fillMaxHeight()
+ .fillMaxWidth(percentWithNewSpentAnimated),
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt
index 710dfb4..d044b2d 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt
@@ -1,7 +1,25 @@
package org.xtimms.tokusho.core.components
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -9,7 +27,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -18,14 +41,29 @@ import androidx.compose.ui.unit.dp
fun DetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
- onBackClicked: () -> Unit,
+ navigateBack: () -> Unit,
+ navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
+
+ var expanded by remember { mutableStateOf(false) }
+
+ val padding by animateDpAsState(
+ targetValue = if (backgroundAlphaProvider() == 1f) 0.dp else 16.dp,
+ label = "padding",
+ )
+
Column(
- modifier = modifier,
+ modifier = modifier
) {
TopAppBar(
+ navigationIcon = {
+ CircleBackIconButton(
+ modifier = Modifier.padding(start = padding),
+ onClick = navigateBack
+ )
+ },
title = {
Text(
text = title,
@@ -34,10 +72,44 @@ fun DetailsToolbar(
color = LocalContentColor.current.copy(alpha = titleAlphaProvider()),
)
},
- navigationIcon = {
- BackIconButton(
- onClick = onBackClicked
- )
+ actions = {
+ FilledTonalIconButton(
+ modifier = Modifier.padding(end = padding),
+ onClick = { expanded = true }, colors = IconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = MaterialTheme.colorScheme.outline,
+ disabledContentColor = MaterialTheme.colorScheme.outlineVariant
+ )
+ ) {
+ Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Share") },
+ onClick = { /*TODO*/ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Default.Share, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Download") },
+ onClick = { /*TODO*/ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Outlined.Download, contentDescription = null)
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Open in web browser") },
+ onClick = {
+ navigateToWebBrowser()
+ expanded = false
+ },
+ leadingIcon = {
+ Icon(imageVector = Icons.Outlined.Language, contentDescription = null)
+ }
+ )
+ }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt b/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt
new file mode 100644
index 0000000..dc8a953
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt
@@ -0,0 +1,25 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun DotSeparatorText(
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = " • ",
+ modifier = modifier,
+ )
+}
+
+@Composable
+fun DotSeparatorNoSpaceText(
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = "•",
+ modifier = modifier,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt
new file mode 100644
index 0000000..f5f3921
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt
@@ -0,0 +1,125 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.FloatingActionButtonElevation
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+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.graphics.Shape
+import androidx.compose.ui.unit.dp
+
+/**
+ * ExtendedFloatingActionButton with custom transition between collapsed/expanded state.
+ *
+ * @see androidx.compose.material3.ExtendedFloatingActionButton
+ */
+@Composable
+fun ExtendedFloatingActionButton(
+ text: @Composable () -> Unit,
+ icon: @Composable () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ expanded: Boolean = true,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ shape: Shape = MaterialTheme.shapes.large,
+ containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor: Color = contentColorFor(containerColor),
+ elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
+) {
+ val minWidth by animateDpAsState(
+ targetValue = if (expanded) ExtendedFabMinimumWidth else FabContainerWidth,
+ label = "minWidth",
+ )
+ FloatingActionButton(
+ modifier = modifier.sizeIn(minWidth = minWidth),
+ onClick = onClick,
+ interactionSource = interactionSource,
+ shape = shape,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ elevation = elevation,
+ ) {
+ val startPadding by animateDpAsState(
+ targetValue = if (expanded) ExtendedFabIconSize / 2 else 0.dp,
+ label = "startPadding",
+ )
+ val endPadding by animateDpAsState(
+ targetValue = if (expanded) ExtendedFabTextPadding else 0.dp,
+ label = "endPadding",
+ )
+
+ Row(
+ modifier = Modifier.padding(start = startPadding, end = endPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ icon()
+ AnimatedVisibility(
+ visible = expanded,
+ enter = ExtendedFabExpandAnimation,
+ exit = ExtendedFabCollapseAnimation,
+ ) {
+ Row {
+ Spacer(Modifier.width(ExtendedFabIconPadding))
+ text()
+ }
+ }
+ }
+ }
+}
+
+private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
+private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
+
+private val ExtendedFabMinimumWidth = 80.dp
+private val ExtendedFabIconSize = 24.0.dp
+private val ExtendedFabIconPadding = 12.dp
+private val ExtendedFabTextPadding = 20.dp
+
+private val ExtendedFabCollapseAnimation = fadeOut(
+ animationSpec = tween(
+ durationMillis = 100,
+ easing = EasingLinearCubicBezier,
+ ),
+) + shrinkHorizontally(
+ animationSpec = tween(
+ durationMillis = 500,
+ easing = EasingEmphasizedCubicBezier,
+ ),
+ shrinkTowards = Alignment.Start,
+)
+
+private val ExtendedFabExpandAnimation = fadeIn(
+ animationSpec = tween(
+ durationMillis = 200,
+ delayMillis = 100,
+ easing = EasingLinearCubicBezier,
+ ),
+) + expandHorizontally(
+ animationSpec = tween(
+ durationMillis = 500,
+ easing = EasingEmphasizedCubicBezier,
+ ),
+ expandFrom = Alignment.Start,
+)
+
+private val FabContainerWidth = 56.0.dp
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt
index 502aced..aab85f8 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt
@@ -1,12 +1,19 @@
package org.xtimms.tokusho.core.components
+import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.OpenInBrowser
+import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonColors
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
@Composable
@@ -21,6 +28,28 @@ fun BackIconButton(
}
}
+@Composable
+fun CircleBackIconButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ FilledTonalIconButton(
+ modifier = modifier,
+ onClick = onClick,
+ colors = IconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = MaterialTheme.colorScheme.outline,
+ disabledContentColor = MaterialTheme.colorScheme.outlineVariant
+ )
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = "arrow_back"
+ )
+ }
+}
+
@Composable
fun ViewInBrowserButton(
onClick: () -> Unit
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt
index a0a9648..fbfc304 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt
@@ -18,7 +18,7 @@ import org.xtimms.tokusho.core.AsyncImageImpl
enum class MangaCover(val ratio: Float) {
Square(1f / 1f),
- Book(10f / 16f),
+ Book(2f / 3f),
;
@Composable
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt
index 627ac1b..377207c 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt
@@ -6,14 +6,17 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -33,25 +36,19 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
+import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
private const val GridSelectedCoverAlpha = 0.76f
-/**
- * Layout of grid list item with title overlaying the cover.
- * Accepts null [title] for a cover-only view.
- */
@Composable
-fun MangaCompactGridItem(
+fun MangaGridItem(
coil: ImageLoader,
- imageUrl: String,
+ manga: Manga,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
- title: String? = null,
- onClickContinueReading: (() -> Unit)? = null,
- coverAlpha: Float = 1f,
) {
GridItemSelectable(
isSelected = isSelected,
@@ -71,12 +68,53 @@ fun MangaCompactGridItem(
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F),
coil = coil,
- model = imageUrl,
+ model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
}
Text(
- text = title!!,
+ text = manga.title,
+ modifier = Modifier.padding(4.dp),
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 2,
+ style = MaterialTheme.typography.titleSmall,
+ )
+ }
+ }
+}
+
+@Composable
+fun MangaHorizontalItem(
+ coil: ImageLoader,
+ manga: Manga,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ isSelected: Boolean = false,
+) {
+ GridItemSelectable(
+ isSelected = isSelected,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ modifier = Modifier.width(IntrinsicSize.Min)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.Start
+ ) {
+ Box {
+ AsyncImageImpl(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .aspectRatio(10F / 16F)
+ .height(156.dp),
+ coil = coil,
+ model = manga.largeCoverUrl ?: manga.coverUrl,
+ contentDescription = null
+ )
+ }
+ Text(
+ text = manga.title,
modifier = Modifier.padding(4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
@@ -206,18 +244,4 @@ private fun GridItemSelectable(
private fun Modifier.selectedOutline(
isSelected: Boolean,
color: Color,
-) = this then drawBehind { if (isSelected) drawRect(color = color) }
-
-@PreviewLightDark
-@Composable
-fun MangaGridItemPreview() {
- TokushoTheme {
- MangaCompactGridItem(
- coil = ImageLoader(LocalContext.current),
- imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
- title = "Stub",
- onClick = { },
- onLongClick = { }
- )
- }
-}
\ No newline at end of file
+) = this then drawBehind { if (isSelected) drawRect(color = color) }
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
index 281eb4c..e99cc19 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt
@@ -65,14 +65,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import org.xtimms.shiki.ui.theme.FixedAccentColors
+import org.xtimms.tokusho.ui.theme.FixedAccentColors
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity
-import org.xtimms.tokusho.ui.theme.preferenceTitle
import org.xtimms.tokusho.utils.FileSize
private const val horizontal = 8
@@ -170,7 +169,6 @@ internal fun PreferenceItemTitle(
modifier: Modifier = Modifier,
text: String,
maxLines: Int = 2,
- style: TextStyle = preferenceTitle,
enabled: Boolean,
color: Color = MaterialTheme.colorScheme.onBackground,
overflow: TextOverflow = TextOverflow.Ellipsis
@@ -179,7 +177,6 @@ internal fun PreferenceItemTitle(
modifier = modifier,
text = text,
maxLines = maxLines,
- style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
@@ -349,7 +346,6 @@ fun PreferenceSwitchWithContainer(
Text(
text = title,
maxLines = 2,
- style = preferenceTitle,
color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface
)
}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt
new file mode 100644
index 0000000..317be50
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt
@@ -0,0 +1,290 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.animation.core.animate
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
+import androidx.compose.material3.pulltorefresh.PullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+import kotlin.math.pow
+
+/**
+ * @param refreshing Whether the layout is currently refreshing
+ * @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
+ * @param enabled Whether the the layout should react to swipe gestures or not.
+ * @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
+ * @param content The content containing a vertically scrollable composable.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PullRefresh(
+ refreshing: Boolean,
+ enabled: () -> Boolean,
+ onRefresh: () -> Unit,
+ modifier: Modifier = Modifier,
+ indicatorPadding: PaddingValues = PaddingValues(0.dp),
+ content: @Composable () -> Unit,
+) {
+ val state = rememberPullToRefreshState(
+ isRefreshing = refreshing,
+ extraVerticalOffset = indicatorPadding.calculateTopPadding(),
+ enabled = enabled,
+ onRefresh = onRefresh,
+ )
+
+ Box(modifier.nestedScroll(state.nestedScrollConnection)) {
+ content()
+
+ val contentPadding = remember(indicatorPadding) {
+ object : PaddingValues {
+ override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
+ indicatorPadding.calculateLeftPadding(layoutDirection)
+
+ override fun calculateTopPadding(): Dp = 0.dp
+
+ override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
+ indicatorPadding.calculateRightPadding(layoutDirection)
+
+ override fun calculateBottomPadding(): Dp =
+ indicatorPadding.calculateBottomPadding()
+ }
+ }
+ PullToRefreshContainer(
+ state = state,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(contentPadding),
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun rememberPullToRefreshState(
+ isRefreshing: Boolean,
+ extraVerticalOffset: Dp,
+ positionalThreshold: Dp = 64.dp,
+ enabled: () -> Boolean = { true },
+ onRefresh: () -> Unit,
+): PullToRefreshStateImpl {
+ val density = LocalDensity.current
+ val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
+ val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
+ return rememberSaveable(
+ extraVerticalOffset,
+ positionalThresholdPx,
+ enabled,
+ onRefresh,
+ saver = PullToRefreshStateImpl.Saver(
+ extraVerticalOffset = extraVerticalOffsetPx,
+ positionalThreshold = positionalThresholdPx,
+ enabled = enabled,
+ onRefresh = onRefresh,
+ ),
+ ) {
+ PullToRefreshStateImpl(
+ initialRefreshing = isRefreshing,
+ extraVerticalOffset = extraVerticalOffsetPx,
+ positionalThreshold = positionalThresholdPx,
+ enabled = enabled,
+ onRefresh = onRefresh,
+ )
+ }.also {
+ LaunchedEffect(isRefreshing) {
+ if (isRefreshing && !it.isRefreshing) {
+ it.startRefreshAnimated()
+ } else if (!isRefreshing && it.isRefreshing) {
+ it.endRefreshAnimated()
+ }
+ }
+ }
+}
+
+/**
+ * Creates a [PullToRefreshState].
+ *
+ * @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
+ * @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
+ * @param initialRefreshing The initial refreshing value of [PullToRefreshState]
+ * @param enabled a callback used to determine whether scroll events are to be handled by this
+ * @param onRefresh a callback to run when pull-to-refresh action is triggered by user
+ * [PullToRefreshState]
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+private class PullToRefreshStateImpl(
+ initialRefreshing: Boolean,
+ private val extraVerticalOffset: Float,
+ override val positionalThreshold: Float,
+ enabled: () -> Boolean,
+ private val onRefresh: () -> Unit,
+) : PullToRefreshState {
+
+ override val progress get() = adjustedDistancePulled / positionalThreshold
+ override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
+
+ override var isRefreshing by mutableStateOf(initialRefreshing)
+
+ private val refreshingVerticalOffset: Float
+ get() = positionalThreshold + extraVerticalOffset
+
+ override fun startRefresh() {
+ isRefreshing = true
+ verticalOffset = refreshingVerticalOffset
+ }
+
+ suspend fun startRefreshAnimated() {
+ isRefreshing = true
+ animateTo(refreshingVerticalOffset)
+ }
+
+ override fun endRefresh() {
+ verticalOffset = 0f
+ isRefreshing = false
+ }
+
+ suspend fun endRefreshAnimated() {
+ animateTo(0f)
+ isRefreshing = false
+ }
+
+ override var nestedScrollConnection = object : NestedScrollConnection {
+ override fun onPreScroll(
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset = when {
+ !enabled() -> Offset.Zero
+ // Swiping up
+ source == NestedScrollSource.Drag && available.y < 0 -> {
+ consumeAvailableOffset(available)
+ }
+ else -> Offset.Zero
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset = when {
+ !enabled() -> Offset.Zero
+ // Swiping down
+ source == NestedScrollSource.Drag && available.y > 0 -> {
+ consumeAvailableOffset(available)
+ }
+ else -> Offset.Zero
+ }
+
+ override suspend fun onPreFling(available: Velocity): Velocity {
+ return Velocity(0f, onRelease(available.y))
+ }
+ }
+
+ /** Helper method for nested scroll connection */
+ fun consumeAvailableOffset(available: Offset): Offset {
+ val y = if (isRefreshing) {
+ 0f
+ } else {
+ val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
+ val dragConsumed = newOffset - distancePulled
+ distancePulled = newOffset
+ verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
+ dragConsumed
+ }
+ return Offset(0f, y)
+ }
+
+ /** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
+ suspend fun onRelease(velocity: Float): Float {
+ if (isRefreshing) return 0f // Already refreshing, do nothing
+ // Trigger refresh
+ if (adjustedDistancePulled > positionalThreshold) {
+ onRefresh()
+ startRefreshAnimated()
+ } else {
+ animateTo(0f)
+ }
+
+ val consumed = when {
+ // We are flinging without having dragged the pull refresh (for example a fling inside
+ // a list) - don't consume
+ distancePulled == 0f -> 0f
+ // If the velocity is negative, the fling is upwards, and we don't want to prevent the
+ // the list from scrolling
+ velocity < 0f -> 0f
+ // We are showing the indicator, and the fling is downwards - consume everything
+ else -> velocity
+ }
+ distancePulled = 0f
+ return consumed
+ }
+
+ suspend fun animateTo(offset: Float) {
+ animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
+ verticalOffset = value
+ }
+ }
+
+ /** Provides custom vertical offset behavior for [PullToRefreshContainer] */
+ fun calculateVerticalOffset(): Float = when {
+ // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
+ adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
+ else -> {
+ // How far beyond the threshold pull has gone, as a percentage of the threshold.
+ val overshootPercent = abs(progress) - 1.0f
+ // Limit the overshoot to 200%. Linear between 0 and 200.
+ val linearTension = overshootPercent.coerceIn(0f, 2f)
+ // Non-linear tension. Increases with linearTension, but at a decreasing rate.
+ val tensionPercent = linearTension - linearTension.pow(2) / 4
+ // The additional offset beyond the threshold.
+ val extraOffset = positionalThreshold * tensionPercent
+ positionalThreshold + extraOffset
+ }
+ }
+
+ companion object {
+ /** The default [Saver] for [PullToRefreshStateImpl]. */
+ fun Saver(
+ extraVerticalOffset: Float,
+ positionalThreshold: Float,
+ enabled: () -> Boolean,
+ onRefresh: () -> Unit,
+ ) = Saver(
+ save = { it.isRefreshing },
+ restore = { isRefreshing ->
+ PullToRefreshStateImpl(
+ initialRefreshing = isRefreshing,
+ extraVerticalOffset = extraVerticalOffset,
+ positionalThreshold = positionalThreshold,
+ enabled = enabled,
+ onRefresh = onRefresh,
+ )
+ },
+ )
+ }
+
+ private var distancePulled by mutableFloatStateOf(0f)
+ private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt
new file mode 100644
index 0000000..a58f73c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt
@@ -0,0 +1,187 @@
+package org.xtimms.tokusho.core.components
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FloatTweenSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.ui.theme.TokushoTheme
+import org.xtimms.tokusho.utils.material.combineColors
+import org.xtimms.tokusho.utils.material.harmonize
+import org.xtimms.tokusho.utils.material.toPalette
+
+@Composable
+fun RowScope.ReadButton() {
+
+ val shift = remember { Animatable(0f) }
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ fun anim() {
+ coroutineScope.launch {
+ shift.animateTo(
+ 1f,
+ animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
+ )
+ shift.snapTo(0f)
+ anim()
+ }
+ }
+
+ anim()
+ }
+ val percentWithNewSpentAnimated = animateFloatAsState(
+ label = "percentWithNewSpentAnimated",
+ targetValue = 0.3f,
+ animationSpec = TweenSpec(300),
+ ).value
+
+ Card(
+ modifier = Modifier
+ .weight(1F)
+ .height(54.dp),
+ shape = CircleShape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ onClick = {
+ // appViewModel.openSheet(PathState(WALLET_SHEET))
+ }
+ ) {
+ Box(
+ modifier = Modifier.fillMaxHeight(),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ BackgroundProgress(MaterialTheme.colorScheme.primary)
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .drawWithLayer {
+ drawContent()
+ val leftOffset = size.width - 20.dp.toPx()
+ drawRect(
+ topLeft = Offset(leftOffset, 0f),
+ size = Size(
+ 20.dp.toPx(),
+ size.height,
+ ),
+ blendMode = BlendMode.SrcIn,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ Color.Black,
+ Color.Black.copy(alpha = 0f),
+ ),
+ startX = leftOffset,
+ endX = leftOffset + 14.dp.toPx()
+ )
+ )
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+ Text(text = "Continue reading", color = MaterialTheme.colorScheme.onPrimaryContainer)
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
+ with(drawContext.canvas.nativeCanvas) {
+ val checkPoint = saveLayer(null, null)
+ block()
+ restoreToCount(checkPoint)
+ }
+}
+
+fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
+ Modifier.drawWithContent {
+ drawWithLayer {
+ block()
+ }
+ }
+)
+
+@Preview(name = "The budget is almost completely spent")
+@Composable
+private fun Preview() {
+ TokushoTheme {
+ Row {
+ ReadButton()
+ }
+ }
+}
+
+@Preview(name = "Budget half spent")
+@Composable
+private fun PreviewHalf() {
+ TokushoTheme {
+ Row {
+ ReadButton()
+ }
+ }
+}
+
+@Preview(name = "Almost no budget")
+@Composable
+private fun PreviewFull() {
+ TokushoTheme {
+ Row {
+ ReadButton()
+ }
+ }
+}
+
+@Preview(name = "Overspending budget")
+@Composable
+private fun PreviewOverspending() {
+ TokushoTheme {
+ Row {
+ ReadButton()
+ }
+ }
+}
+
+@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun PreviewNightMode() {
+ TokushoTheme {
+ Row {
+ ReadButton()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
index 014f4a5..fb806ee 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt
@@ -1,6 +1,7 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -21,6 +22,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
fun ScaffoldWithTopAppBar(
title: String,
navigateBack: () -> Unit,
+ snackbarHost: @Composable (() -> Unit) = {},
floatingActionButton: @Composable (() -> Unit) = {},
content: @Composable (PaddingValues) -> Unit
) {
@@ -40,6 +42,7 @@ fun ScaffoldWithTopAppBar(
navigateBack = navigateBack
)
},
+ snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
content = content
@@ -84,6 +87,7 @@ fun ScaffoldWithClassicTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
+ actions: @Composable (RowScope.() -> Unit) = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
@@ -99,6 +103,7 @@ fun ScaffoldWithClassicTopAppBar(
ClassicTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
+ actions = actions,
navigateBack = navigateBack
)
},
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt
new file mode 100644
index 0000000..cafbf0f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt
@@ -0,0 +1,54 @@
+package org.xtimms.tokusho.core.components
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.StarOutline
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.ui.theme.TokushoTheme
+import org.xtimms.tokusho.utils.lang.toStringPositiveValueOrUnknown
+
+@Composable
+fun SmallScoreIndicator(
+ score: Float?,
+ modifier: Modifier = Modifier,
+ fontSize: TextUnit = 16.sp,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.StarOutline,
+ contentDescription = stringResource(R.string.mean_score),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ if (score != null) {
+ Text(
+ text = (score.times(5.0F)).toStringPositiveValueOrUnknown(),
+ modifier = Modifier.padding(horizontal = 4.dp),
+ color = MaterialTheme.colorScheme.outline,
+ fontSize = fontSize
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun SmallScoreIndicatorPreview() {
+ TokushoTheme {
+ SmallScoreIndicator(score = 1f)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt
index 088a74a..0d8e2f4 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -14,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
@@ -28,6 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
@@ -42,11 +46,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
+import kotlinx.collections.immutable.persistentListOf
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.initialOffset
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
+import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
@@ -124,7 +130,7 @@ fun TopAppBar(
modifier = modifier.padding(end = 16.dp),
) {
IconButton(
- onClick = { },
+ onClick = { navController.navigate(FEED_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
@@ -218,6 +224,7 @@ fun SmallTopAppBar(
fun ClassicTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior? = null,
+ actions: @Composable (RowScope.() -> Unit),
navigateBack: () -> Unit,
) {
androidx.compose.material3.TopAppBar(
@@ -225,6 +232,7 @@ fun ClassicTopAppBar(
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
+ actions = actions,
scrollBehavior = scrollBehavior
)
}
@@ -248,7 +256,16 @@ fun DefaultTopAppBarWithChipsPreview() {
TokushoTheme {
SmallTopAppBarWithChips(
title = "Tokusho",
- chips = listOf("Chip 1", "Chip 2", "Chip 3", "Chip 4", "Chip 1", "Chip 2", "Chip 3", "Chip 4"),
+ chips = listOf(
+ "Chip 1",
+ "Chip 2",
+ "Chip 3",
+ "Chip 4",
+ "Chip 1",
+ "Chip 2",
+ "Chip 3",
+ "Chip 4"
+ ),
navigateBack = {}
)
}
@@ -273,7 +290,15 @@ fun ClassicTopAppBarPreview() {
TokushoTheme {
ClassicTopAppBar(
title = "Tokusho",
- navigateBack = {}
+ navigateBack = {},
+ actions = {
+ IconButton(onClick = { /*TODO*/ }) {
+ Icon(
+ imageVector = Icons.Filled.Menu,
+ contentDescription = "Localized description"
+ )
+ }
+ }
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt b/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt
new file mode 100644
index 0000000..82804b9
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt
@@ -0,0 +1,60 @@
+package org.xtimms.tokusho.core.components.effects
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import kotlin.random.Random
+
+data class Snowflake(
+ var x: Float,
+ var y: Float,
+ var radius: Float,
+ var speed: Float
+)
+
+@Composable
+fun SnowfallEffect() {
+ val snowflakes = remember { List(100) { generateRandomSnowflake() } }
+ val infiniteTransition = rememberInfiniteTransition(label = "")
+
+ val offsetY by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 100f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 10000, easing = LinearEasing)
+ ), label = ""
+ )
+
+ Canvas(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
+ snowflakes.forEach { snowflake ->
+ drawSnowflake(snowflake, offsetY % size.height)
+ }
+ }
+}
+
+fun generateRandomSnowflake(): Snowflake {
+ return Snowflake(
+ x = Random.nextFloat(),
+ y = Random.nextFloat() * 1000f,
+ radius = Random.nextFloat() * 2f + 2f, // Snowflake size
+ speed = Random.nextFloat() * 0.5f + 1f // Falling speed
+ )
+}
+
+fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
+ val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
+ drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt
new file mode 100644
index 0000000..65db733
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt
@@ -0,0 +1,53 @@
+package org.xtimms.tokusho.core.components.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.materialIcon
+import androidx.compose.material.icons.materialPath
+import androidx.compose.ui.graphics.vector.ImageVector
+
+public val Icons.Outlined.ArrowDecisionOutline: ImageVector
+ get() {
+ if (_arrow_decision_outline != null) {
+ return _arrow_decision_outline!!
+ }
+ _arrow_decision_outline = materialIcon(name = "Outlined.ArrowDecisionOutline") {
+ materialPath {
+ moveTo(9.64f, 13.4f)
+ curveTo(8.63f, 12.5f, 7.34f, 12.03f, 6.0f, 12.0f)
+ verticalLineTo(15.0f)
+ lineTo(2.0f, 11.0f)
+ lineTo(6.0f, 7.0f)
+ verticalLineTo(10.0f)
+ curveTo(7.67f, 10.0f, 9.3f, 10.57f, 10.63f, 11.59f)
+ curveTo(10.22f, 12.15f, 9.89f, 12.76f, 9.64f, 13.4f)
+ moveTo(18.0f, 15.0f)
+ verticalLineTo(12.0f)
+ curveTo(17.5f, 12.0f, 13.5f, 12.16f, 13.05f, 16.2f)
+ curveTo(14.61f, 16.75f, 15.43f, 18.47f, 14.88f, 20.03f)
+ curveTo(14.33f, 21.59f, 12.61f, 22.41f, 11.05f, 21.86f)
+ curveTo(9.5f, 21.3f, 8.67f, 19.59f, 9.22f, 18.03f)
+ curveTo(9.5f, 17.17f, 10.2f, 16.5f, 11.05f, 16.2f)
+ curveTo(11.34f, 12.61f, 14.4f, 9.88f, 18.0f, 10.0f)
+ verticalLineTo(7.0f)
+ lineTo(22.0f, 11.0f)
+ lineTo(18.0f, 15.0f)
+ moveTo(13.0f, 19.0f)
+ arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 18.0f)
+ arcTo(1.0f, 1.0f, 0.0f, false, false, 11.0f, 19.0f)
+ arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 20.0f)
+ arcTo(1.0f, 1.0f, 0.0f, false, false, 13.0f, 19.0f)
+ moveTo(11.0f, 11.12f)
+ curveTo(11.58f, 10.46f, 12.25f, 9.89f, 13.0f, 9.43f)
+ verticalLineTo(5.0f)
+ horizontalLineTo(16.0f)
+ lineTo(12.0f, 1.0f)
+ lineTo(8.0f, 5.0f)
+ horizontalLineTo(11.0f)
+ verticalLineTo(11.12f)
+ close()
+ }
+ }
+ return _arrow_decision_outline!!
+ }
+
+private var _arrow_decision_outline: ImageVector? = null
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt
new file mode 100644
index 0000000..037cfc2
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt
@@ -0,0 +1,293 @@
+package org.xtimms.tokusho.core.components.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
+import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.ImageVector.Builder
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+public val Icons.Filled.Kotatsu: ImageVector
+ get() {
+ if (_kotatsu != null) {
+ return _kotatsu!!
+ }
+ _kotatsu = Builder(name = "Kotatsu", defaultWidth = 1406.2.dp, defaultHeight = 1406.2.dp,
+ viewportWidth = 1406.2f, viewportHeight = 1406.2f).apply {
+ path(fill = SolidColor(Color(0xFF0058C9)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(391.7f, 270.7f)
+ curveToRelative(-51.6f, 18.6f, -96.2f, 88.4f, -117.9f, 183.6f)
+ curveToRelative(-7.8f, 34.8f, -15.1f, 93.5f, -15.1f, 121.7f)
+ verticalLineToRelative(19.7f)
+ lineToRelative(-23.3f, 36.6f)
+ curveToRelative(-65.0f, 101.3f, -124.6f, 206.8f, -180.5f, 319.2f)
+ curveTo(5.1f, 1051.0f, 0.0f, 1063.2f, 0.0f, 1080.9f)
+ curveToRelative(0.0f, 7.8f, 2.0f, 18.0f, 4.0f, 22.2f)
+ curveToRelative(6.6f, 12.0f, 22.2f, 24.4f, 39.7f, 31.0f)
+ lineToRelative(16.0f, 6.2f)
+ lineToRelative(651.1f, -0.2f)
+ curveToRelative(633.1f, 0.0f, 651.1f, -0.4f, 661.2f, -5.3f)
+ curveToRelative(32.6f, -16.9f, 43.0f, -51.7f, 26.2f, -88.0f)
+ curveToRelative(-63.8f, -139.0f, -150.5f, -296.8f, -229.6f, -418.5f)
+ lineToRelative(-19.1f, -29.5f)
+ lineToRelative(-2.2f, -33.7f)
+ curveToRelative(-8.7f, -129.4f, -36.1f, -208.6f, -92.0f, -266.5f)
+ curveToRelative(-24.2f, -24.8f, -33.5f, -30.4f, -50.6f, -30.6f)
+ curveToRelative(-23.9f, 0.0f, -39.9f, 10.9f, -75.6f, 52.1f)
+ curveToRelative(-35.2f, 40.4f, -42.4f, 50.1f, -66.9f, 86.4f)
+ curveToRelative(-12.0f, 17.7f, -27.0f, 38.3f, -33.2f, 45.5f)
+ lineToRelative(-11.3f, 13.5f)
+ horizontalLineToRelative(-117.0f)
+ lineToRelative(-117.0f, -0.2f)
+ lineTo(560.2f, 429.0f)
+ curveTo(515.0f, 359.2f, 440.7f, 274.5f, 419.6f, 268.7f)
+ curveTo(406.8f, 264.9f, 409.0f, 264.5f, 391.7f, 270.7f)
+ close()
+ moveTo(466.2f, 666.4f)
+ curveToRelative(8.9f, 6.2f, 11.3f, 11.8f, 14.4f, 37.7f)
+ curveToRelative(4.0f, 30.6f, 7.7f, 34.8f, 27.5f, 32.4f)
+ curveToRelative(18.0f, -2.2f, 32.6f, 3.6f, 40.8f, 16.6f)
+ curveToRelative(16.0f, 25.9f, -11.5f, 80.2f, -50.6f, 99.3f)
+ curveToRelative(-14.0f, 7.1f, -19.1f, 7.8f, -42.8f, 7.8f)
+ curveToRelative(-22.8f, 0.0f, -28.8f, -1.1f, -39.4f, -6.7f)
+ curveToRelative(-31.2f, -16.4f, -50.3f, -40.3f, -58.3f, -71.8f)
+ curveToRelative(-4.0f, -16.6f, -4.2f, -21.7f, -1.1f, -36.3f)
+ curveToRelative(4.2f, -21.1f, 11.5f, -35.2f, 24.8f, -50.1f)
+ curveTo(404.8f, 669.5f, 449.1f, 654.4f, 466.2f, 666.4f)
+ close()
+ moveTo(964.0f, 669.0f)
+ curveToRelative(8.7f, 7.3f, 9.3f, 9.7f, 13.5f, 43.4f)
+ curveToRelative(2.6f, 20.6f, 8.7f, 26.8f, 25.3f, 24.2f)
+ curveToRelative(16.0f, -2.2f, 29.9f, 2.2f, 39.2f, 12.9f)
+ curveToRelative(15.1f, 18.2f, 6.2f, 53.4f, -20.8f, 82.2f)
+ curveToRelative(-21.7f, 23.1f, -35.2f, 28.4f, -69.2f, 28.8f)
+ curveToRelative(-25.9f, 0.0f, -29.1f, -0.5f, -42.8f, -8.2f)
+ curveToRelative(-20.2f, -11.3f, -38.4f, -29.9f, -47.7f, -49.0f)
+ curveToRelative(-6.2f, -12.8f, -8.2f, -20.8f, -8.9f, -39.2f)
+ curveToRelative(-0.9f, -21.1f, 0.0f, -25.0f, 7.7f, -41.0f)
+ curveToRelative(14.0f, -30.4f, 35.5f, -49.2f, 65.0f, -57.0f)
+ curveTo(945.2f, 660.2f, 954.7f, 661.1f, 964.0f, 669.0f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(1806.5f, 939.2f)
+ verticalLineTo(464.0f)
+ horizontalLineToRelative(88.3f)
+ verticalLineToRelative(475.3f)
+ lineTo(1806.5f, 939.2f)
+ lineTo(1806.5f, 939.2f)
+ close()
+ moveTo(1885.3f, 827.2f)
+ lineToRelative(-4.8f, -104.5f)
+ lineTo(2129.7f, 464.0f)
+ horizontalLineToRelative(99.1f)
+ lineToRelative(-207.1f, 220.0f)
+ lineToRelative(-48.9f, 53.6f)
+ lineTo(1885.3f, 827.2f)
+ close()
+ moveTo(2137.8f, 939.2f)
+ lineToRelative(-181.9f, -216.6f)
+ lineToRelative(58.4f, -64.5f)
+ lineTo(2241.0f, 939.2f)
+ horizontalLineTo(2137.8f)
+ lineTo(2137.8f, 939.2f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(2440.6f, 944.0f)
+ curveToRelative(-37.1f, 0.0f, -70.2f, -8.0f, -99.1f, -24.1f)
+ curveToRelative(-29.0f, -16.1f, -51.8f, -38.1f, -68.6f, -66.2f)
+ curveToRelative(-16.8f, -28.1f, -25.1f, -60.0f, -25.1f, -95.7f)
+ curveToRelative(0.0f, -36.2f, 8.4f, -68.2f, 25.1f, -96.1f)
+ curveToRelative(16.7f, -27.8f, 39.6f, -49.7f, 68.6f, -65.5f)
+ reflectiveCurveToRelative(62.0f, -23.8f, 99.1f, -23.8f)
+ curveToRelative(37.6f, 0.0f, 70.9f, 7.9f, 100.1f, 23.8f)
+ curveToRelative(29.2f, 15.9f, 52.1f, 37.6f, 68.6f, 65.2f)
+ curveToRelative(16.5f, 27.6f, 24.8f, 59.8f, 24.8f, 96.4f)
+ curveToRelative(0.0f, 35.8f, -8.3f, 67.7f, -24.8f, 95.7f)
+ curveToRelative(-16.5f, 28.1f, -39.4f, 50.1f, -68.6f, 66.2f)
+ curveTo(2511.6f, 936.0f, 2478.2f, 944.0f, 2440.6f, 944.0f)
+ close()
+ moveTo(2440.6f, 871.3f)
+ curveToRelative(20.8f, 0.0f, 39.4f, -4.5f, 55.7f, -13.6f)
+ curveToRelative(16.3f, -9.0f, 29.1f, -22.2f, 38.4f, -39.4f)
+ curveToRelative(9.3f, -17.2f, 13.9f, -37.3f, 13.9f, -60.4f)
+ curveToRelative(0.0f, -23.5f, -4.6f, -43.8f, -13.9f, -60.8f)
+ curveToRelative(-9.3f, -17.0f, -22.1f, -30.0f, -38.4f, -39.0f)
+ curveToRelative(-16.3f, -9.0f, -34.6f, -13.6f, -55.0f, -13.6f)
+ curveToRelative(-20.8f, 0.0f, -39.3f, 4.5f, -55.3f, 13.6f)
+ curveToRelative(-16.1f, 9.0f, -28.8f, 22.1f, -38.3f, 39.0f)
+ curveToRelative(-9.5f, 17.0f, -14.3f, 37.2f, -14.3f, 60.8f)
+ curveToRelative(0.0f, 23.1f, 4.8f, 43.2f, 14.3f, 60.4f)
+ reflectiveCurveToRelative(22.3f, 30.3f, 38.3f, 39.4f)
+ curveTo(2402.0f, 866.8f, 2420.3f, 871.3f, 2440.6f, 871.3f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(2667.4f, 647.3f)
+ verticalLineToRelative(-67.9f)
+ horizontalLineToRelative(241.7f)
+ verticalLineToRelative(67.9f)
+ horizontalLineTo(2667.4f)
+ close()
+ moveTo(2852.1f, 944.0f)
+ curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
+ curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
+ verticalLineTo(496.6f)
+ horizontalLineToRelative(84.9f)
+ verticalLineToRelative(323.2f)
+ curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
+ curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
+ curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
+ lineToRelative(23.8f, 60.4f)
+ curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
+ curveTo(2882.4f, 941.9f, 2867.4f, 944.0f, 2852.1f, 944.0f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(3112.8f, 944.0f)
+ curveToRelative(-27.2f, 0.0f, -50.9f, -4.6f, -71.3f, -13.9f)
+ curveToRelative(-20.4f, -9.3f, -36.1f, -22.2f, -47.2f, -38.7f)
+ curveToRelative(-11.1f, -16.5f, -16.6f, -35.2f, -16.6f, -56.0f)
+ curveToRelative(0.0f, -20.4f, 4.9f, -38.7f, 14.6f, -55.0f)
+ curveToRelative(9.7f, -16.3f, 25.7f, -29.2f, 47.9f, -38.7f)
+ reflectiveCurveToRelative(51.6f, -14.3f, 88.3f, -14.3f)
+ horizontalLineToRelative(105.2f)
+ verticalLineToRelative(56.3f)
+ horizontalLineToRelative(-99.1f)
+ curveToRelative(-29.0f, 0.0f, -48.4f, 4.7f, -58.4f, 13.9f)
+ curveToRelative(-10.0f, 9.3f, -14.9f, 20.7f, -14.9f, 34.3f)
+ curveToRelative(0.0f, 15.4f, 6.1f, 27.6f, 18.3f, 36.7f)
+ curveToRelative(12.2f, 9.1f, 29.2f, 13.6f, 50.9f, 13.6f)
+ curveToRelative(20.8f, 0.0f, 39.5f, -4.8f, 56.0f, -14.3f)
+ reflectiveCurveToRelative(28.4f, -23.5f, 35.6f, -42.1f)
+ lineToRelative(14.3f, 50.9f)
+ curveToRelative(-8.1f, 21.3f, -22.7f, 37.8f, -43.8f, 49.6f)
+ reflectiveCurveTo(3144.9f, 944.0f, 3112.8f, 944.0f)
+ close()
+ moveTo(3226.8f, 939.2f)
+ verticalLineToRelative(-73.3f)
+ lineToRelative(-4.8f, -15.6f)
+ verticalLineTo(722.0f)
+ curveToRelative(0.0f, -24.9f, -7.5f, -44.2f, -22.4f, -58.0f)
+ reflectiveCurveToRelative(-37.6f, -20.7f, -67.9f, -20.7f)
+ curveToRelative(-20.4f, 0.0f, -40.4f, 3.2f, -60.1f, 9.5f)
+ curveToRelative(-19.7f, 6.3f, -36.3f, 15.2f, -49.9f, 26.5f)
+ lineToRelative(-33.3f, -61.8f)
+ curveToRelative(19.4f, -14.9f, 42.6f, -26.1f, 69.6f, -33.6f)
+ curveToRelative(26.9f, -7.5f, 54.9f, -11.2f, 83.8f, -11.2f)
+ curveToRelative(52.5f, 0.0f, 93.1f, 12.6f, 121.9f, 37.7f)
+ curveToRelative(28.7f, 25.1f, 43.1f, 63.9f, 43.1f, 116.4f)
+ verticalLineToRelative(212.5f)
+ lineTo(3226.8f, 939.2f)
+ lineTo(3226.8f, 939.2f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(3367.4f, 647.3f)
+ verticalLineToRelative(-67.9f)
+ horizontalLineToRelative(241.7f)
+ verticalLineToRelative(67.9f)
+ horizontalLineTo(3367.4f)
+ close()
+ moveTo(3552.0f, 944.0f)
+ curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
+ curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
+ verticalLineTo(496.6f)
+ horizontalLineToRelative(84.9f)
+ verticalLineToRelative(323.2f)
+ curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
+ curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
+ curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
+ lineToRelative(23.8f, 60.4f)
+ curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
+ curveTo(3582.3f, 941.9f, 3567.4f, 944.0f, 3552.0f, 944.0f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(3815.5f, 944.0f)
+ curveToRelative(-30.3f, 0.0f, -59.4f, -4.0f, -87.2f, -11.9f)
+ reflectiveCurveToRelative(-49.9f, -17.5f, -66.2f, -28.9f)
+ lineToRelative(32.6f, -64.5f)
+ curveToRelative(16.3f, 10.4f, 35.8f, 19.0f, 58.4f, 25.8f)
+ curveToRelative(22.6f, 6.8f, 45.3f, 10.2f, 67.9f, 10.2f)
+ curveToRelative(26.7f, 0.0f, 46.0f, -3.6f, 58.0f, -10.9f)
+ curveToRelative(12.0f, -7.2f, 18.0f, -17.0f, 18.0f, -29.2f)
+ curveToRelative(0.0f, -10.0f, -4.1f, -17.5f, -12.2f, -22.8f)
+ curveToRelative(-8.1f, -5.2f, -18.8f, -9.2f, -31.9f, -11.9f)
+ curveToRelative(-13.1f, -2.7f, -27.7f, -5.2f, -43.8f, -7.5f)
+ reflectiveCurveToRelative(-32.1f, -5.3f, -48.2f, -9.2f)
+ curveToRelative(-16.1f, -3.8f, -30.7f, -9.5f, -43.8f, -17.0f)
+ reflectiveCurveToRelative(-23.8f, -17.5f, -31.9f, -30.2f)
+ curveToRelative(-8.1f, -12.7f, -12.2f, -29.4f, -12.2f, -50.2f)
+ curveToRelative(0.0f, -23.1f, 6.6f, -43.1f, 19.7f, -60.1f)
+ curveToRelative(13.1f, -17.0f, 31.6f, -30.1f, 55.3f, -39.4f)
+ curveToRelative(23.8f, -9.3f, 51.9f, -13.9f, 84.5f, -13.9f)
+ curveToRelative(24.4f, 0.0f, 49.1f, 2.7f, 74.0f, 8.1f)
+ curveToRelative(24.9f, 5.4f, 45.5f, 13.1f, 61.8f, 23.1f)
+ lineToRelative(-32.6f, 64.5f)
+ curveToRelative(-17.2f, -10.4f, -34.5f, -17.5f, -51.9f, -21.4f)
+ curveToRelative(-17.4f, -3.8f, -34.7f, -5.8f, -51.9f, -5.8f)
+ curveToRelative(-25.8f, 0.0f, -44.9f, 3.9f, -57.4f, 11.5f)
+ curveToRelative(-12.4f, 7.7f, -18.7f, 17.4f, -18.7f, 29.2f)
+ curveToRelative(0.0f, 10.9f, 4.1f, 19.0f, 12.2f, 24.4f)
+ curveToRelative(8.1f, 5.4f, 18.8f, 9.7f, 31.9f, 12.9f)
+ curveToRelative(13.1f, 3.2f, 27.7f, 5.8f, 43.8f, 7.8f)
+ reflectiveCurveToRelative(32.0f, 5.1f, 47.9f, 9.2f)
+ curveToRelative(15.8f, 4.1f, 30.4f, 9.6f, 43.8f, 16.6f)
+ curveToRelative(13.3f, 7.0f, 24.1f, 16.9f, 32.3f, 29.5f)
+ curveToRelative(8.1f, 12.7f, 12.2f, 29.2f, 12.2f, 49.6f)
+ curveToRelative(0.0f, 22.6f, -6.7f, 42.3f, -20.0f, 59.1f)
+ curveToRelative(-13.4f, 16.8f, -32.3f, 29.8f, -56.7f, 39.0f)
+ curveTo(3878.6f, 939.3f, 3849.4f, 944.0f, 3815.5f, 944.0f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(4206.5f, 944.0f)
+ curveToRelative(-30.8f, 0.0f, -57.9f, -5.8f, -81.5f, -17.3f)
+ curveToRelative(-23.5f, -11.5f, -41.9f, -29.2f, -55.0f, -53.0f)
+ reflectiveCurveToRelative(-19.7f, -53.7f, -19.7f, -90.0f)
+ verticalLineToRelative(-207.0f)
+ horizontalLineToRelative(84.9f)
+ verticalLineToRelative(195.5f)
+ curveToRelative(0.0f, 32.6f, 7.4f, 56.9f, 22.1f, 73.0f)
+ reflectiveCurveToRelative(35.6f, 24.1f, 62.8f, 24.1f)
+ curveToRelative(19.9f, 0.0f, 37.2f, -4.1f, 51.9f, -12.2f)
+ curveToRelative(14.7f, -8.1f, 26.2f, -20.4f, 34.6f, -36.7f)
+ reflectiveCurveToRelative(12.6f, -36.4f, 12.6f, -60.4f)
+ verticalLineTo(576.7f)
+ horizontalLineToRelative(84.9f)
+ verticalLineToRelative(362.5f)
+ horizontalLineToRelative(-80.8f)
+ verticalLineToRelative(-97.8f)
+ lineToRelative(14.3f, 29.9f)
+ curveToRelative(-12.2f, 23.5f, -30.1f, 41.5f, -53.6f, 54.0f)
+ curveTo(4260.4f, 937.8f, 4234.6f, 944.0f, 4206.5f, 944.0f)
+ close()
+ }
+ }
+ .build()
+ return _kotatsu!!
+ }
+
+private var _kotatsu: ImageVector? = null
diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt b/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt
new file mode 100644
index 0000000..f69a59b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt
@@ -0,0 +1,46 @@
+package org.xtimms.tokusho.core.components.shape
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import kotlin.math.ceil
+
+class WavyShape(
+ private val period: Dp,
+ private val amplitude: Dp,
+ private val shift: Float,
+) : Shape {
+ override fun createOutline(
+ size: Size,
+ layoutDirection: LayoutDirection,
+ density: Density,
+ ) = Outline.Generic(Path().apply {
+ val halfPeriod = with(density) { period.toPx() } / 2
+ val amplitude = with(density) { amplitude.toPx() }
+
+ val wavyPath = Path().apply {
+ moveTo(x = 0f, y = 0f)
+ lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
+ repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
+ relativeQuadraticBezierTo(
+ dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
+ dy1 = halfPeriod / 2,
+ dx2 = 0f,
+ dy2 = halfPeriod,
+ )
+ }
+ lineTo(0f, size.height)
+ }
+ val boundsPath = Path().apply {
+ addRect(Rect(offset = Offset.Zero, size = size))
+ }
+ op(wavyPath, boundsPath, PathOperation.Intersect)
+ })
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt b/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt
index 290d964..110d819 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt
@@ -21,5 +21,84 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
0L,
)
)
+ db.execSQL(
+ "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
+ arrayOf(
+ System.currentTimeMillis(),
+ 1,
+ resources.getString(R.string.reading),
+ SortOrder.NEWEST.name,
+ 1,
+ 1,
+ 0L,
+ )
+ )
+ db.execSQL(
+ "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
+ arrayOf(
+ System.currentTimeMillis(),
+ 1,
+ resources.getString(R.string.completed),
+ SortOrder.NEWEST.name,
+ 1,
+ 1,
+ 0L,
+ )
+ )
+ db.execSQL(
+ "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
+ arrayOf(
+ System.currentTimeMillis(),
+ 1,
+ resources.getString(R.string.dropped),
+ SortOrder.NEWEST.name,
+ 1,
+ 1,
+ 0L,
+ )
+ )
+ db.execSQL(
+ "INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)",
+ arrayOf(
+ "MANGADEX",
+ 1,
+ 1,
+ )
+ )
+ db.execSQL(
+ "INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)",
+ arrayOf(
+ "DESUME",
+ 1,
+ 1,
+ )
+ )
+ db.execSQL(
+ "INSERT INTO manga (manga_id, title, alt_title, url, public_url, rating, nsfw, cover_url, large_cover_url, state, author, source) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
+ arrayOf(
+ 4427365311541330000,
+ "Seitokai ni mo Ana wa Aru!",
+ "",
+ "822c9883-385c-4fd0-9523-16e7789cbeae",
+ "https://mangadex.org/title/822c9883-385c-4fd0-9523-16e7789cbeae",
+ -1.0,
+ 0,
+ "https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg.256.jpg",
+ "https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg",
+ "FINISHED",
+ "Muchi Maro",
+ "MANGADEX",
+ )
+ )
+ db.execSQL(
+ "INSERT INTO favourites (manga_id, category_id, sort_key, created_at, deleted_at) VALUES (?,?,?,?,?)",
+ arrayOf(
+ 4427365311541330000,
+ 1,
+ 0,
+ 1705944302882,
+ 0,
+ )
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
index ec0b656..b811731 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt
@@ -10,11 +10,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import org.xtimms.tokusho.core.database.dao.BookmarksDao
import org.xtimms.tokusho.core.database.dao.FavouriteCategoriesDao
import org.xtimms.tokusho.core.database.dao.FavouritesDao
import org.xtimms.tokusho.core.database.dao.HistoryDao
import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
+import org.xtimms.tokusho.core.database.dao.TagsDao
+import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.HistoryEntity
@@ -34,12 +37,15 @@ const val DATABASE_VERSION = 1
MangaSourceEntity::class,
HistoryEntity::class,
FavouriteEntity::class,
- FavouriteCategoryEntity::class
+ FavouriteCategoryEntity::class,
+ BookmarkEntity::class
],
version = DATABASE_VERSION
)
abstract class TokushoDatabase : RoomDatabase() {
+ abstract fun getTagsDao(): TagsDao
+
abstract fun getHistoryDao(): HistoryDao
abstract fun getMangaDao(): MangaDao
@@ -50,10 +56,12 @@ abstract class TokushoDatabase : RoomDatabase() {
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
+ abstract fun getBookmarksDao(): BookmarksDao
+
}
fun TokushoDatabase(context: Context): TokushoDatabase = Room
- .databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db.db")
+ .databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db")
.addCallback(DatabasePrePopulateCallback(context.resources))
.build()
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt
new file mode 100644
index 0000000..aa88c69
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt
@@ -0,0 +1,57 @@
+package org.xtimms.tokusho.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+import org.xtimms.tokusho.core.database.entity.BookmarkEntity
+import org.xtimms.tokusho.core.database.entity.MangaWithTags
+
+@Dao
+abstract class BookmarksDao {
+
+ @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
+ abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
+
+ @Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
+ abstract suspend fun find(pageId: Long): BookmarkEntity?
+
+ @Transaction
+ @Query(
+ "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
+ )
+ abstract suspend fun findAll(): Map>
+
+ @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
+ abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow
+
+ @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
+ abstract fun observe(mangaId: Long): Flow>
+
+ @Transaction
+ @Query(
+ "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
+ )
+ abstract fun observe(): Flow>>
+
+ @Insert
+ abstract suspend fun insert(entity: BookmarkEntity)
+
+ @Delete
+ abstract suspend fun delete(entity: BookmarkEntity)
+
+ @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
+ abstract suspend fun delete(mangaId: Long, pageId: Long): Int
+
+ @Query("DELETE FROM bookmarks WHERE page_id = :pageId")
+ abstract suspend fun delete(pageId: Long): Int
+
+ @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
+ abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
+
+ @Upsert
+ abstract suspend fun upsert(bookmarks: Collection)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt
index 04ffd75..c8846dc 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt
@@ -86,6 +86,9 @@ abstract class FavouritesDao {
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow
+ @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0 AND category_id = :categoryId")
+ abstract fun observeMangaCountInCategory(categoryId: Long): Flow
+
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt
index 2e9e705..3edf3dd 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt
@@ -2,8 +2,15 @@ package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Query
+import androidx.room.RawQuery
+import androidx.room.Transaction
+import androidx.room.Upsert
+import androidx.sqlite.db.SimpleSQLiteQuery
+import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
+import org.intellij.lang.annotations.Language
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
+import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
@@ -26,4 +33,49 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
+ @Upsert
+ abstract suspend fun upsert(entry: MangaSourceEntity)
+
+ fun observeEnabled(order: SourcesSortOrder): Flow> {
+ val orderBy = getOrderBy(order)
+
+ @Language("RoomSql")
+ val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
+ return observeImpl(query)
+ }
+
+ suspend fun findAllEnabled(order: SourcesSortOrder): List {
+ val orderBy = getOrderBy(order)
+
+ @Language("RoomSql")
+ val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
+ return findAllImpl(query)
+ }
+
+ @Transaction
+ open suspend fun setEnabled(source: String, isEnabled: Boolean) {
+ if (updateIsEnabled(source, isEnabled) == 0) {
+ val entity = MangaSourceEntity(
+ source = source,
+ isEnabled = isEnabled,
+ sortKey = getMaxSortKey() + 1,
+ )
+ upsert(entity)
+ }
+ }
+
+ @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
+ protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
+
+ @RawQuery(observedEntities = [MangaSourceEntity::class])
+ protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow>
+
+ @RawQuery
+ protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List
+
+ private fun getOrderBy(order: SourcesSortOrder) = when (order) {
+ SourcesSortOrder.ALPHABETIC -> "source ASC"
+ SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
+ SourcesSortOrder.MANUAL -> "sort_key ASC"
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt
new file mode 100644
index 0000000..9d16a6a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt
@@ -0,0 +1,88 @@
+package org.xtimms.tokusho.core.database.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import org.xtimms.tokusho.core.database.entity.TagEntity
+
+@Dao
+abstract class TagsDao {
+
+ @Query("SELECT * FROM tags WHERE source = :source")
+ abstract suspend fun findTags(source: String): List
+
+ @Query(
+ """SELECT tags.* FROM tags
+ LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
+ WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
+ GROUP BY tags.title
+ ORDER BY COUNT(manga_id) DESC
+ LIMIT :limit""",
+ )
+ abstract suspend fun findPopularTags(limit: Int): List
+
+ @Query(
+ """SELECT tags.* FROM tags
+ LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
+ WHERE tags.source = :source
+ GROUP BY tags.title
+ ORDER BY COUNT(manga_id) DESC
+ LIMIT :limit""",
+ )
+ abstract suspend fun findPopularTags(source: String, limit: Int): List
+
+ @Query(
+ """SELECT tags.* FROM tags
+ LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
+ WHERE tags.source = :source
+ GROUP BY tags.title
+ ORDER BY COUNT(manga_id) ASC
+ LIMIT :limit""",
+ )
+ abstract suspend fun findRareTags(source: String, limit: Int): List
+
+ @Query(
+ """SELECT tags.* FROM tags
+ LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
+ WHERE tags.source = :source AND title LIKE :query
+ GROUP BY tags.title
+ ORDER BY COUNT(manga_id) DESC
+ LIMIT :limit""",
+ )
+ abstract suspend fun findTags(source: String, query: String, limit: Int): List
+
+ @Query(
+ """SELECT tags.* FROM tags
+ LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
+ WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
+ GROUP BY tags.title
+ ORDER BY COUNT(manga_id) DESC
+ LIMIT :limit""",
+ )
+ abstract suspend fun findTags(query: String, limit: Int): List
+
+ @Query(
+ """
+ SELECT tags.* FROM manga_tags
+ LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
+ WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
+ GROUP BY tags.tag_id
+ ORDER BY COUNT(manga_id) DESC;
+ """,
+ )
+ abstract suspend fun findRelatedTags(tagId: Long): List
+
+ @Query(
+ """
+ SELECT tags.* FROM manga_tags
+ LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
+ WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
+ GROUP BY tags.tag_id
+ ORDER BY COUNT(manga_id) DESC;
+ """,
+ )
+ abstract suspend fun findRelatedTags(ids: Set): List
+
+ @Upsert
+ abstract suspend fun upsert(tags: Iterable)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt
new file mode 100644
index 0000000..73e06b0
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt
@@ -0,0 +1,28 @@
+package org.xtimms.tokusho.core.database.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+
+@Entity(
+ tableName = "bookmarks",
+ primaryKeys = ["manga_id", "page_id"],
+ foreignKeys = [
+ ForeignKey(
+ entity = MangaEntity::class,
+ parentColumns = ["manga_id"],
+ childColumns = ["manga_id"],
+ onDelete = ForeignKey.CASCADE
+ ),
+ ]
+)
+data class BookmarkEntity(
+ @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
+ @ColumnInfo(name = "page_id", index = true) val pageId: Long,
+ @ColumnInfo(name = "chapter_id") val chapterId: Long,
+ @ColumnInfo(name = "page") val page: Int,
+ @ColumnInfo(name = "scroll") val scroll: Int,
+ @ColumnInfo(name = "image") val imageUrl: String,
+ @ColumnInfo(name = "created_at") val createdAt: Long,
+ @ColumnInfo(name = "percent") val percent: Float,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
index b9e72a3..8add3a4 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt
@@ -6,6 +6,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.xtimms.tokusho.core.model.Bookmark
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.core.model.MangaHistory
@@ -58,6 +59,24 @@ fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection.toMangaList() = map { it.toManga() }
+fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
+ manga = manga,
+ pageId = pageId,
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll,
+ imageUrl = imageUrl,
+ createdAt = Instant.ofEpochMilli(createdAt),
+ percent = percent,
+)
+
+fun Collection.toBookmarks(manga: Manga) = map {
+ it.toBookmark(manga)
+}
+
+@JvmName("bookmarksIds")
+fun Collection.ids() = map { it.pageId }
+
// Model to entity
fun Manga.toEntity() = MangaEntity(
@@ -84,6 +103,17 @@ fun MangaTag.toEntity() = TagEntity(
fun Collection.toEntities() = map(MangaTag::toEntity)
+fun Bookmark.toEntity() = BookmarkEntity(
+ mangaId = manga.id,
+ pageId = pageId,
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll,
+ imageUrl = imageUrl,
+ createdAt = createdAt.toEpochMilli(),
+ percent = percent,
+)
+
// Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
diff --git a/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt b/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt
new file mode 100644
index 0000000..079aa1d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.core.exceptions
+
+import okhttp3.Headers
+import okio.IOException
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+class CloudflareProtectedException(
+ val url: String,
+ val source: MangaSource?,
+ @Transient val headers: Headers,
+) : IOException("Protected by Cloudflare")
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt
new file mode 100644
index 0000000..ce21490
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt
@@ -0,0 +1,42 @@
+package org.xtimms.tokusho.core.model
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.xtimms.tokusho.utils.hasImageExtension
+import java.time.Instant
+
+data class Bookmark(
+ val manga: Manga,
+ val pageId: Long,
+ val chapterId: Long,
+ val page: Int,
+ val scroll: Int,
+ val imageUrl: String,
+ val createdAt: Instant,
+ val percent: Float,
+) : ListModel {
+
+ val directImageUrl: String?
+ get() = if (isImageUrlDirect()) imageUrl else null
+
+ val imageLoadData: Any
+ get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Bookmark &&
+ manga.id == other.manga.id &&
+ chapterId == other.chapterId &&
+ page == other.page
+ }
+
+ fun toMangaPage() = MangaPage(
+ id = pageId,
+ url = imageUrl,
+ preview = null,
+ source = manga.source,
+ )
+
+ private fun isImageUrlDirect(): Boolean {
+ return hasImageExtension(imageUrl)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt
new file mode 100644
index 0000000..41b97b4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt
@@ -0,0 +1,42 @@
+package org.xtimms.tokusho.core.model
+
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.xtimms.tokusho.utils.system.creationTime
+import java.io.File
+
+data class LocalManga(
+ val manga: Manga,
+ val file: File = manga.url.toUri().toFile(),
+) {
+
+ var createdAt: Long = -1L
+ private set
+ get() {
+ if (field == -1L) {
+ field = file.creationTime
+ }
+ return field
+ }
+
+ fun isMatchesQuery(query: String): Boolean {
+ return manga.title.contains(query, ignoreCase = true) ||
+ manga.altTitle?.contains(query, ignoreCase = true) == true
+ }
+
+ fun containsTags(tags: Set): Boolean {
+ return manga.tags.containsAll(tags)
+ }
+
+ fun containsAnyTag(tags: Set): Boolean {
+ return tags.any { tag ->
+ manga.tags.contains(tag)
+ }
+ }
+
+ override fun toString(): String {
+ return "LocalManga(${file.path}: ${manga.title})"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
index 7c1333d..012658c 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt
@@ -1,8 +1,64 @@
package org.xtimms.tokusho.core.model
+import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.tokusho.utils.system.iterator
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
fun Collection.distinctById() = distinctBy { it.id }
-fun Collection.findById(id: Long) = find { x -> x.id == id }
\ No newline at end of file
+fun Collection.findById(id: Long) = find { x -> x.id == id }
+
+fun Manga.getPreferredBranch(history: MangaHistory?): String? {
+ val ch = chapters
+ if (ch.isNullOrEmpty()) {
+ return null
+ }
+ if (history != null) {
+ val currentChapter = ch.findById(history.chapterId)
+ if (currentChapter != null) {
+ return currentChapter.branch
+ }
+ }
+ val groups = ch.groupBy { it.branch }
+ if (groups.size == 1) {
+ return groups.keys.first()
+ }
+ for (locale in LocaleListCompat.getAdjustedDefault()) {
+ val displayLanguage = locale.getDisplayLanguage(locale)
+ val displayName = locale.getDisplayName(locale)
+ val candidates = HashMap>(3)
+ for (branch in groups.keys) {
+ if (branch != null && (
+ branch.contains(displayLanguage, ignoreCase = true) ||
+ branch.contains(displayName, ignoreCase = true)
+ )
+ ) {
+ candidates[branch] = groups[branch] ?: continue
+ }
+ }
+ if (candidates.isNotEmpty()) {
+ return candidates.maxBy { it.value.size }.key
+ }
+ }
+ return groups.maxByOrNull { it.value.size }?.key
+}
+
+private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
+ f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
+ it.decimalSeparator = '.'
+ }
+}
+
+fun MangaChapter.formatNumber(): String? {
+ if (number <= 0f) {
+ return null
+ }
+ return chaptersNumberFormat.format(number.toDouble())
+}
+
+val Manga.isLocal: Boolean
+ get() = source == MangaSource.LOCAL
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt
index feac6a1..ad722af 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt
@@ -1,5 +1,6 @@
package org.xtimms.tokusho.core.model
+import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
fun MangaSource(name: String): MangaSource {
@@ -7,4 +8,6 @@ fun MangaSource(name: String): MangaSource {
if (it.name == name) return it
}
return MangaSource.DUMMY
-}
\ No newline at end of file
+}
+
+fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
index 96bdc6a..31af83f 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt
@@ -15,9 +15,11 @@ import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor
+import org.xtimms.tokusho.core.network.interceptors.CloudflareInterceptor
import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor
import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor
import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor
+import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@@ -58,8 +60,12 @@ interface NetworkModule {
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
+ if (AppSettings.isSSLBypassEnabled()) {
+ bypassSSLErrors()
+ }
cache(cache)
addInterceptor(GZipInterceptor())
+ addInterceptor(CloudflareInterceptor())
addInterceptor(RateLimitInterceptor())
}.build()
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt b/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt
new file mode 100644
index 0000000..5cd8861
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt
@@ -0,0 +1,29 @@
+package org.xtimms.tokusho.core.network
+
+import android.annotation.SuppressLint
+import okhttp3.OkHttpClient
+import java.security.SecureRandom
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+
+@SuppressLint("CustomX509TrustManager")
+fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
+ runCatching {
+ val trustAllCerts = object : X509TrustManager {
+ override fun checkClientTrusted(chain: Array, authType: String) = Unit
+
+ override fun checkServerTrusted(chain: Array, authType: String) = Unit
+
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ }
+ val sslContext = SSLContext.getInstance("SSL")
+ sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
+ val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
+ builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
+ builder.hostnameVerifier { _, _ -> true }
+ }.onFailure {
+ it.printStackTrace()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt
new file mode 100644
index 0000000..6845f84
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt
@@ -0,0 +1,32 @@
+package org.xtimms.tokusho.core.network.interceptors
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import org.jsoup.Jsoup
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.tokusho.core.exceptions.CloudflareProtectedException
+import java.net.HttpURLConnection.HTTP_FORBIDDEN
+import java.net.HttpURLConnection.HTTP_UNAVAILABLE
+
+class CloudflareInterceptor : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val response = chain.proceed(chain.request())
+ if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
+ val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
+ Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
+ } ?: return response
+ if (content.getElementById("challenge-error-title") != null) {
+ val request = response.request
+ response.closeQuietly()
+ throw CloudflareProtectedException(
+ url = request.url.toString(),
+ source = request.tag(MangaSource::class.java),
+ headers = request.headers,
+ )
+ }
+ }
+ return response
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt
index 705e66b..9bf02ca 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt
@@ -13,6 +13,7 @@ import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.network.CommonHeaders
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
+import java.net.IDN
import javax.inject.Inject
import javax.inject.Singleton
@@ -39,6 +40,10 @@ class CommonHeadersInterceptor @Inject constructor(
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
}
+ if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
+ val idn = IDN.toASCII(repository.domain)
+ headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
+ }
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
}
@@ -46,7 +51,7 @@ class CommonHeadersInterceptor @Inject constructor(
private fun Headers.Builder.trySet(name: String, value: String) = try {
set(name, value)
} catch (e: IllegalArgumentException) {
-
+ e.printStackTrace()
}
private class ProxyChain(
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt
index 9681079..ba476a5 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt
@@ -1,8 +1,11 @@
package org.xtimms.tokusho.core.parser
+import androidx.room.withTransaction
import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.database.entity.toEntities
+import org.xtimms.tokusho.core.database.entity.toEntity
import org.xtimms.tokusho.core.database.entity.toManga
import javax.inject.Inject
import javax.inject.Provider
@@ -28,4 +31,12 @@ class MangaDataRepository @Inject constructor(
else -> null
}
+ suspend fun storeManga(manga: Manga) {
+ db.withTransaction {
+ val tags = manga.tags.toEntities()
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(manga.toEntity(), tags)
+ }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt
index 0357f0c..de92f61 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.tokusho.core.cache.ContentCache
+import org.xtimms.tokusho.core.parser.local.LocalMangaRepository
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
@@ -50,6 +51,7 @@ interface MangaRepository {
@Singleton
class Factory @Inject constructor(
+ private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
) {
@@ -58,6 +60,9 @@ interface MangaRepository {
@AnyThread
fun create(source: MangaSource): MangaRepository {
+ if (source == MangaSource.LOCAL) {
+ return localMangaRepository
+ }
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
index 746e5a8..5822d3c 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt
@@ -24,10 +24,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.SafeDeferred
+import org.xtimms.tokusho.core.prefs.SourceSettings
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import java.util.Locale
@@ -58,6 +60,12 @@ class RemoteMangaRepository(
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
+ var domain: String
+ get() = parser.domain
+ set(value) {
+ getConfig()[parser.configKeyDomain] = value
+ }
+
val domains: Array
get() = parser.configKeyDomain.presetValues
@@ -119,6 +127,8 @@ class RemoteMangaRepository(
return details.await()
}
+ private fun getConfig() = parser.config as SourceSettings
+
@OptIn(ExperimentalStdlibApi::class)
private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt
new file mode 100644
index 0000000..8915092
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt
@@ -0,0 +1,22 @@
+package org.xtimms.tokusho.core.parser.local
+
+import android.net.Uri
+import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP
+import java.io.File
+
+private fun isCbzExtension(ext: String?): Boolean {
+ return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
+}
+
+fun hasCbzExtension(string: String): Boolean {
+ val ext = string.substringAfterLast('.', "")
+ return isCbzExtension(ext)
+}
+
+fun File.hasCbzExtension() = isCbzExtension(extension)
+
+fun Uri.isZipUri() = scheme.let {
+ it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
+}
+
+fun Uri.isFileUri() = scheme == "file"
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt
new file mode 100644
index 0000000..68f0362
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt
@@ -0,0 +1,8 @@
+package org.xtimms.tokusho.core.parser.local
+
+enum class DownloadFormat {
+
+ AUTOMATIC,
+ SINGLE_CBZ,
+ MULTIPLE_CBZ,
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt
new file mode 100644
index 0000000..7ac8d1f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt
@@ -0,0 +1,220 @@
+package org.xtimms.tokusho.core.parser.local
+
+import android.net.Uri
+import androidx.core.net.toFile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.model.ContentRating
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.core.model.LocalManga
+import org.xtimms.tokusho.core.model.isLocal
+import org.xtimms.tokusho.core.parser.MangaRepository
+import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
+import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
+import org.xtimms.tokusho.core.parser.local.output.LocalMangaUtil
+import org.xtimms.tokusho.data.LocalStorageManager
+import org.xtimms.tokusho.utils.AlphanumComparator
+import org.xtimms.tokusho.utils.CompositeMutex2
+import org.xtimms.tokusho.utils.system.children
+import org.xtimms.tokusho.utils.system.deleteAwait
+import org.xtimms.tokusho.utils.system.filterWith
+import java.io.File
+import java.util.EnumSet
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val MAX_PARALLELISM = 4
+
+@Singleton
+class LocalMangaRepository @Inject constructor(
+ private val storageManager: LocalStorageManager,
+ @LocalStorageChanges private val localStorageChanges: MutableSharedFlow,
+) : MangaRepository {
+
+ override val source = MangaSource.LOCAL
+ private val locks = CompositeMutex2()
+
+ override val isMultipleTagsSupported: Boolean = true
+ override val isTagsExclusionSupported: Boolean = true
+ override val isSearchSupported: Boolean = true
+ override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
+ override val states = emptySet()
+ override val contentRatings = emptySet()
+
+ override suspend fun getList(offset: Int, filter: MangaListFilter?): List {
+ if (offset > 0) {
+ return emptyList()
+ }
+ val list = getRawList()
+ when (filter) {
+ is MangaListFilter.Search -> {
+ list.retainAll { x -> x.isMatchesQuery(filter.query) }
+ }
+
+ is MangaListFilter.Advanced -> {
+ if (filter.tags.isNotEmpty()) {
+ list.retainAll { x -> x.containsTags(filter.tags) }
+ }
+ if (filter.tagsExclude.isNotEmpty()) {
+ list.removeAll { x -> x.containsAnyTag(filter.tags) }
+ }
+ when (filter.sortOrder) {
+ SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
+ SortOrder.RATING -> list.sortByDescending { it.manga.rating }
+ SortOrder.NEWEST,
+ SortOrder.UPDATED,
+ -> list.sortByDescending { it.createdAt }
+
+ else -> Unit
+ }
+ }
+
+ null -> Unit
+ }
+ return list.unwrap()
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga = when {
+ manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
+ "Manga is not local or saved"
+ }
+
+ else -> LocalMangaInput.of(manga).getManga().manga
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ return LocalMangaInput.of(chapter).getPages(chapter)
+ }
+
+ suspend fun delete(manga: Manga): Boolean {
+ val file = Uri.parse(manga.url).toFile()
+ val result = file.deleteAwait()
+ if (result) {
+ localStorageChanges.emit(null)
+ }
+ return result
+ }
+
+ suspend fun deleteChapters(manga: Manga, ids: Set) {
+ lockManga(manga.id)
+ try {
+ val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
+ "Manga is not stored on local storage"
+ }.manga
+ LocalMangaUtil(subject).deleteChapters(ids)
+ localStorageChanges.emit(LocalManga(subject))
+ } finally {
+ unlockManga(manga.id)
+ }
+ }
+
+ suspend fun getRemoteManga(localManga: Manga): Manga? {
+ return runCatchingCancellable {
+ LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal }
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+ }
+
+ suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
+ // fast path
+ LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
+ return it.getManga()
+ }
+ // slow path
+ val files = getAllFiles()
+ return channelFlow {
+ for (file in files) {
+ launch {
+ val mangaInput = LocalMangaInput.of(file)
+ runCatchingCancellable {
+ val mangaInfo = mangaInput.getMangaInfo()
+ if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
+ send(mangaInput)
+ }
+ }.onFailure {
+ it.printStackTrace()
+ }
+ }
+ }
+ }.firstOrNull()?.getManga()
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+
+ override suspend fun getPageUrl(page: MangaPage) = page.url
+
+ override suspend fun getTags() = emptySet()
+
+ override suspend fun getLocales() = emptySet()
+
+ override suspend fun getRelated(seed: Manga): List = emptyList()
+
+ suspend fun getOutputDir(manga: Manga): File? {
+ val defaultDir = storageManager.getDefaultWriteableDir()
+ if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
+ return defaultDir
+ }
+ return storageManager.getWriteableDirs()
+ .firstOrNull {
+ LocalMangaOutput.get(it, manga) != null
+ } ?: defaultDir
+ }
+
+ suspend fun cleanup(): Boolean {
+ if (locks.isNotEmpty()) {
+ return false
+ }
+ val dirs = storageManager.getWriteableDirs()
+ runInterruptible(Dispatchers.IO) {
+ dirs.flatMap { dir ->
+ dir.children().filterWith(TempFileFilter())
+ }.forEach { file ->
+ file.deleteRecursively()
+ }
+ }
+ return true
+ }
+
+ suspend fun lockManga(id: Long) {
+ locks.lock(id)
+ }
+
+ fun unlockManga(id: Long) {
+ locks.unlock(id)
+ }
+
+ private suspend fun getRawList(): ArrayList {
+ val files = getAllFiles().toList() // TODO remove toList()
+ return coroutineScope {
+ val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
+ files.map { file ->
+ async(dispatcher) {
+ runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
+ }
+ }.awaitAll()
+ }.filterNotNullTo(ArrayList(files.size))
+ }
+
+ private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
+ dir.children()
+ }
+
+ private fun Collection.unwrap(): List = map { it.manga }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt
new file mode 100644
index 0000000..c214e5c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt
@@ -0,0 +1,200 @@
+package org.xtimms.tokusho.core.parser.local
+
+import androidx.annotation.WorkerThread
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.util.find
+import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
+import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.xtimms.tokusho.BuildConfig
+import org.xtimms.tokusho.core.model.isLocal
+import org.xtimms.tokusho.utils.AlphanumComparator
+import java.io.File
+
+class MangaIndex(source: String?) {
+
+ private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
+
+ fun setMangaInfo(manga: Manga) {
+ require(!manga.isLocal) { "Local manga information cannot be stored" }
+ json.put("id", manga.id)
+ json.put("title", manga.title)
+ json.put("title_alt", manga.altTitle)
+ json.put("url", manga.url)
+ json.put("public_url", manga.publicUrl)
+ json.put("author", manga.author)
+ json.put("cover", manga.coverUrl)
+ json.put("description", manga.description)
+ json.put("rating", manga.rating)
+ json.put("nsfw", manga.isNsfw)
+ json.put("state", manga.state?.name)
+ json.put("source", manga.source.name)
+ json.put("cover_large", manga.largeCoverUrl)
+ json.put(
+ "tags",
+ JSONArray().also { a ->
+ for (tag in manga.tags) {
+ val jo = JSONObject()
+ jo.put("key", tag.key)
+ jo.put("title", tag.title)
+ a.put(jo)
+ }
+ },
+ )
+ if (!json.has("chapters")) {
+ json.put("chapters", JSONObject())
+ }
+ json.put("app_id", BuildConfig.APPLICATION_ID)
+ json.put("app_version", BuildConfig.VERSION_CODE)
+ }
+
+ fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
+ val source = MangaSource.valueOf(json.getString("source"))
+ Manga(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ altTitle = json.getStringOrNull("title_alt"),
+ url = json.getString("url"),
+ publicUrl = json.getStringOrNull("public_url").orEmpty(),
+ author = json.getStringOrNull("author"),
+ largeCoverUrl = json.getStringOrNull("cover_large"),
+ source = source,
+ rating = json.getDouble("rating").toFloat(),
+ isNsfw = json.getBooleanOrDefault("nsfw", false),
+ coverUrl = json.getString("cover"),
+ state = json.getStringOrNull("state")?.let { stateString ->
+ MangaState.entries.find(stateString)
+ },
+ description = json.getStringOrNull("description"),
+ tags = json.getJSONArray("tags").mapJSONToSet { x ->
+ MangaTag(
+ title = x.getString("title").toTitleCase(),
+ key = x.getString("key"),
+ source = source,
+ )
+ },
+ chapters = getChapters(json.getJSONObject("chapters"), source),
+ )
+ }.getOrNull()
+
+ fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
+
+ fun addChapter(chapter: IndexedValue, filename: String?) {
+ val chapters = json.getJSONObject("chapters")
+ if (!chapters.has(chapter.value.id.toString())) {
+ val jo = JSONObject()
+ jo.put("number", chapter.value.number)
+ jo.put("volume", chapter.value.volume)
+ jo.put("url", chapter.value.url)
+ jo.put("name", chapter.value.name)
+ jo.put("uploadDate", chapter.value.uploadDate)
+ jo.put("scanlator", chapter.value.scanlator)
+ jo.put("branch", chapter.value.branch)
+ jo.put("entries", "%08d_%03d\\d{3}".format(chapter.value.branch.hashCode(), chapter.index + 1))
+ jo.put("file", filename)
+ chapters.put(chapter.value.id.toString(), jo)
+ }
+ }
+
+ fun removeChapter(id: Long): Boolean {
+ return json.getJSONObject("chapters").remove(id.toString()) != null
+ }
+
+ fun getChapterFileName(chapterId: Long): String? {
+ return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
+ }
+
+ fun setCoverEntry(name: String) {
+ json.put("cover_entry", name)
+ }
+
+ fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
+ json.getJSONObject("chapters")
+ .getJSONObject(chapter.id.toString())
+ .getString("entries"),
+ )
+
+ fun sortChaptersByName() {
+ val jo = json.getJSONObject("chapters")
+ val list = ArrayList(jo.length())
+ jo.keys().forEach { id ->
+ val item = jo.getJSONObject(id)
+ item.put("id", id)
+ list.add(item)
+ }
+ val comparator = AlphanumComparator()
+ list.sortWith(compareBy(comparator) { it.getString("name") })
+ val newJo = JSONObject()
+ list.forEachIndexed { i, obj ->
+ obj.put("number", i + 1)
+ val id = obj.remove("id") as String
+ newJo.put(id, obj)
+ }
+ json.put("chapters", newJo)
+ }
+
+ fun clear() {
+ val keys = json.keys()
+ while (keys.hasNext()) {
+ json.remove(keys.next())
+ }
+ }
+
+ fun setFrom(other: MangaIndex) {
+ clear()
+ other.json.keys().forEach { key ->
+ json.putOpt(key, other.json.opt(key))
+ }
+ }
+
+ private fun getChapters(json: JSONObject, source: MangaSource): List {
+ val chapters = ArrayList(json.length())
+ for (k in json.keys()) {
+ val v = json.getJSONObject(k)
+ chapters.add(
+ MangaChapter(
+ id = k.toLong(),
+ name = v.getString("name"),
+ url = v.getString("url"),
+ number = v.getFloatOrDefault("number", 0f),
+ volume = v.getIntOrDefault("volume", 0),
+ uploadDate = v.getLongOrDefault("uploadDate", 0L),
+ scanlator = v.getStringOrNull("scanlator"),
+ branch = v.getStringOrNull("branch"),
+ source = source,
+ ),
+ )
+ }
+ return chapters.sortedBy { it.number }
+ }
+
+ override fun toString(): String = if (BuildConfig.DEBUG) {
+ json.toString(4)
+ } else {
+ json.toString()
+ }
+
+ companion object {
+
+ @WorkerThread
+ fun read(file: File): MangaIndex? {
+ if (file.exists() && file.canRead()) {
+ val text = file.readText()
+ if (text.length > 2) {
+ return MangaIndex(text)
+ }
+ }
+ return null
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt
new file mode 100644
index 0000000..4d01cca
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt
@@ -0,0 +1,7 @@
+package org.xtimms.tokusho.core.parser.local
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class LocalStorageChanges
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt
new file mode 100644
index 0000000..9346e5c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.core.parser.local
+
+import java.io.File
+import java.io.FileFilter
+
+class TempFileFilter : FileFilter {
+
+ override fun accept(file: File): Boolean {
+ return file.name.endsWith(".tmp", ignoreCase = true)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt
new file mode 100644
index 0000000..eaa08cf
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt
@@ -0,0 +1,158 @@
+package org.xtimms.tokusho.core.parser.local.input
+
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.toCamelCase
+import org.xtimms.tokusho.core.model.LocalManga
+import org.xtimms.tokusho.core.parser.local.MangaIndex
+import org.xtimms.tokusho.core.parser.local.hasCbzExtension
+import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
+import org.xtimms.tokusho.utils.AlphanumComparator
+import org.xtimms.tokusho.utils.hasImageExtension
+import org.xtimms.tokusho.utils.lang.longHashCode
+import org.xtimms.tokusho.utils.lang.toListSorted
+import org.xtimms.tokusho.utils.system.children
+import org.xtimms.tokusho.utils.system.creationTime
+import org.xtimms.tokusho.utils.system.walkCompat
+import java.io.File
+import java.util.TreeMap
+import java.util.zip.ZipFile
+
+/**
+ * Manga {Folder}
+ * |--- index.json (optional)
+ * |--- Chapter 1.cbz
+ * |--- Page 1.png
+ * :
+ * L--- Page x.png
+ * |--- Chapter 2.cbz
+ * :
+ * L--- Chapter x.cbz
+ */
+class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
+
+ override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
+ val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
+ val mangaUri = root.toUri().toString()
+ val chapterFiles = getChaptersFiles()
+ val info = index?.getMangaInfo()
+ val cover = fileUri(
+ root,
+ index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
+ )
+ val manga = info?.copy2(
+ source = MangaSource.LOCAL,
+ url = mangaUri,
+ coverUrl = cover,
+ largeCoverUrl = cover,
+ chapters = info.chapters?.mapIndexedNotNull { i, c ->
+ val fileName = index.getChapterFileName(c.id)
+ val file = if (fileName != null) {
+ chapterFiles[fileName]
+ } else {
+ // old downloads
+ chapterFiles.values.elementAtOrNull(i)
+ } ?: return@mapIndexedNotNull null
+ c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
+ },
+ ) ?: Manga(
+ id = root.absolutePath.longHashCode(),
+ title = root.name.toHumanReadable(),
+ url = mangaUri,
+ publicUrl = mangaUri,
+ source = MangaSource.LOCAL,
+ coverUrl = findFirstImageEntry().orEmpty(),
+ chapters = chapterFiles.values.mapIndexed { i, f ->
+ MangaChapter(
+ id = "$i${f.name}".longHashCode(),
+ name = f.nameWithoutExtension.toHumanReadable(),
+ number = 0f,
+ volume = 0,
+ source = MangaSource.LOCAL,
+ uploadDate = f.creationTime,
+ url = f.toUri().toString(),
+ scanlator = null,
+ branch = null,
+ )
+ },
+ altTitle = null,
+ rating = -1f,
+ isNsfw = false,
+ tags = setOf(),
+ state = null,
+ author = null,
+ largeCoverUrl = null,
+ description = null,
+ )
+ LocalManga(manga, root)
+ }
+
+ override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
+ val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
+ index?.getMangaInfo()
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) {
+ val file = chapter.url.toUri().toFile()
+ if (file.isDirectory) {
+ file.children()
+ .filter { it.isFile && hasImageExtension(it) }
+ .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
+ .map {
+ val pageUri = it.toUri().toString()
+ MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
+ }
+ } else {
+ ZipFile(file).use { zip ->
+ zip.entries()
+ .asSequence()
+ .filter { x -> !x.isDirectory }
+ .map { it.name }
+ .toListSorted(AlphanumComparator())
+ .map {
+ val pageUri = zipUri(file, it)
+ MangaPage(
+ id = pageUri.longHashCode(),
+ url = pageUri,
+ preview = null,
+ source = MangaSource.LOCAL,
+ )
+ }
+ }
+ }
+ }
+
+ private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
+
+ private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
+ .filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
+ .associateByTo(TreeMap(AlphanumComparator())) { it.name }
+
+ private fun findFirstImageEntry(): String? {
+ return root.walkCompat(includeDirectories = false)
+ .firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
+ ?: run {
+ val cbz = root.walkCompat(includeDirectories = false)
+ .firstOrNull { it.hasCbzExtension() } ?: return null
+ ZipFile(cbz).use { zip ->
+ zip.entries().asSequence()
+ .firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
+ ?.let { zipUri(cbz, it.name) }
+ }
+ }
+ }
+
+ private fun fileUri(base: File, name: String): String {
+ return File(base, name).toUri().toString()
+ }
+
+ private fun File.isChapterDirectory(): Boolean {
+ return isDirectory && children().any { hasImageExtension(it) }
+ }
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt
new file mode 100644
index 0000000..c4e2160
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt
@@ -0,0 +1,111 @@
+package org.xtimms.tokusho.core.parser.local.input
+
+import android.net.Uri
+import androidx.core.net.toFile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.xtimms.tokusho.core.model.LocalManga
+import org.xtimms.tokusho.core.parser.local.hasCbzExtension
+import java.io.File
+
+sealed class LocalMangaInput(
+ protected val root: File,
+) {
+
+ abstract suspend fun getManga(): LocalManga
+
+ abstract suspend fun getMangaInfo(): Manga?
+
+ abstract suspend fun getPages(chapter: MangaChapter): List
+
+ companion object {
+
+ fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
+
+ fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
+
+ fun of(file: File): LocalMangaInput = when {
+ file.isDirectory -> LocalMangaDirInput(file)
+ else -> LocalMangaZipInput(file)
+ }
+
+ fun ofOrNull(file: File): LocalMangaInput? = when {
+ file.isDirectory -> LocalMangaDirInput(file)
+ hasCbzExtension(file.name) -> LocalMangaZipInput(file)
+ else -> null
+ }
+
+ suspend fun find(roots: Iterable, manga: Manga): LocalMangaInput? = channelFlow {
+ val fileName = manga.title.toFileNameSafe()
+ for (root in roots) {
+ launch {
+ val dir = File(root, fileName)
+ val zip = File(root, "$fileName.cbz")
+ val input = when {
+ dir.isDirectory -> LocalMangaDirInput(dir)
+ zip.isFile -> LocalMangaZipInput(zip)
+ else -> null
+ }
+ val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
+ if (info?.id == manga.id) {
+ send(input)
+ }
+ }
+ }
+ }.flowOn(Dispatchers.Default).firstOrNull()
+
+ @JvmStatic
+ protected fun zipUri(file: File, entryName: String): String =
+ Uri.fromParts("cbz", file.path, entryName).toString()
+
+ @JvmStatic
+ protected fun Manga.copy2(
+ url: String,
+ coverUrl: String,
+ largeCoverUrl: String,
+ chapters: List?,
+ source: MangaSource,
+ ) = Manga(
+ id = id,
+ title = title,
+ altTitle = altTitle,
+ url = url,
+ publicUrl = publicUrl,
+ rating = rating,
+ isNsfw = isNsfw,
+ coverUrl = coverUrl,
+ tags = tags,
+ state = state,
+ author = author,
+ largeCoverUrl = largeCoverUrl,
+ description = description,
+ chapters = chapters,
+ source = source,
+ )
+
+ @JvmStatic
+ protected fun MangaChapter.copy(
+ url: String,
+ source: MangaSource,
+ ) = MangaChapter(
+ id = id,
+ name = name,
+ number = number,
+ volume = volume,
+ url = url,
+ scanlator = scanlator,
+ uploadDate = uploadDate,
+ branch = branch,
+ source = source,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt
new file mode 100644
index 0000000..b5600d9
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt
@@ -0,0 +1,154 @@
+package org.xtimms.tokusho.core.parser.local.input
+
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.collection.ArraySet
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.toCamelCase
+import org.xtimms.tokusho.core.model.LocalManga
+import org.xtimms.tokusho.core.parser.local.MangaIndex
+import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
+import org.xtimms.tokusho.utils.AlphanumComparator
+import org.xtimms.tokusho.utils.lang.longHashCode
+import org.xtimms.tokusho.utils.lang.toListSorted
+import org.xtimms.tokusho.utils.system.readText
+import java.io.File
+import java.util.Enumeration
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Manga archive {.cbz or .zip file}
+ * |--- index.json (optional)
+ * |--- Page 1.png
+ * |--- Page 2.png
+ * :
+ * L--- Page x.png
+ */
+class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
+
+ override suspend fun getManga(): LocalManga {
+ val manga = runInterruptible(Dispatchers.IO) {
+ ZipFile(root).use { zip ->
+ val fileUri = root.toUri().toString()
+ val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
+ val index = entry?.let(zip::readText)?.let(::MangaIndex)
+ val info = index?.getMangaInfo()
+ if (info != null) {
+ val cover = zipUri(
+ root,
+ entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
+ )
+ return@use info.copy2(
+ source = MangaSource.LOCAL,
+ url = fileUri,
+ coverUrl = cover,
+ largeCoverUrl = cover,
+ chapters = info.chapters?.map { c ->
+ c.copy(url = fileUri, source = MangaSource.LOCAL)
+ },
+ )
+ }
+ // fallback
+ val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
+ val chapters = ArraySet()
+ for (x in zip.entries()) {
+ if (!x.isDirectory) {
+ chapters += x.name.substringBeforeLast(File.separatorChar, "")
+ }
+ }
+ val uriBuilder = root.toUri().buildUpon()
+ Manga(
+ id = root.absolutePath.longHashCode(),
+ title = title,
+ url = fileUri,
+ publicUrl = fileUri,
+ source = MangaSource.LOCAL,
+ coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
+ chapters = chapters.sortedWith(AlphanumComparator())
+ .mapIndexed { i, s ->
+ MangaChapter(
+ id = "$i$s".longHashCode(),
+ name = s.ifEmpty { title },
+ number = 0f,
+ volume = 0,
+ source = MangaSource.LOCAL,
+ uploadDate = 0L,
+ url = uriBuilder.fragment(s).build().toString(),
+ scanlator = null,
+ branch = null,
+ )
+ },
+ altTitle = null,
+ rating = -1f,
+ isNsfw = false,
+ tags = setOf(),
+ state = null,
+ author = null,
+ largeCoverUrl = null,
+ description = null,
+ )
+ }
+ }
+ return LocalManga(manga, root)
+ }
+
+ override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
+ ZipFile(root).use { zip ->
+ val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
+ val index = entry?.let(zip::readText)?.let(::MangaIndex)
+ index?.getMangaInfo()
+ }
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ return runInterruptible(Dispatchers.IO) {
+ val uri = Uri.parse(chapter.url)
+ val file = uri.toFile()
+ val zip = ZipFile(file)
+ val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
+ var entries = zip.entries().asSequence()
+ entries = if (index != null) {
+ val pattern = index.getChapterNamesPattern(chapter)
+ entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
+ } else {
+ val parent = uri.fragment.orEmpty()
+ entries.filter { x ->
+ !x.isDirectory && x.name.substringBeforeLast(
+ File.separatorChar,
+ "",
+ ) == parent
+ }
+ }
+ entries
+ .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
+ .map { x ->
+ val entryUri = zipUri(file, x.name)
+ MangaPage(
+ id = entryUri.longHashCode(),
+ url = entryUri,
+ preview = null,
+ source = MangaSource.LOCAL,
+ )
+ }
+ }
+ }
+
+ private fun findFirstImageEntry(entries: Enumeration): ZipEntry? {
+ val list = entries.toList()
+ .filterNot { it.isDirectory }
+ .sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
+ val map = MimeTypeMap.getSingleton()
+ return list.firstOrNull {
+ map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
+ ?.startsWith("image/") == true
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt
new file mode 100644
index 0000000..4b8c52b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt
@@ -0,0 +1,136 @@
+package org.xtimms.tokusho.core.parser.local.output
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.xtimms.tokusho.core.model.isLocal
+import org.xtimms.tokusho.core.parser.local.MangaIndex
+import org.xtimms.tokusho.core.zip.ZipOutput
+import org.xtimms.tokusho.utils.system.deleteAwait
+import org.xtimms.tokusho.utils.system.takeIfReadable
+import java.io.File
+
+class LocalMangaDirOutput(
+ rootFile: File,
+ manga: Manga,
+) : LocalMangaOutput(rootFile) {
+
+ private val chaptersOutput = HashMap()
+ private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
+ private val mutex = Mutex()
+
+ init {
+ if (!manga.isLocal) {
+ index.setMangaInfo(manga)
+ }
+ }
+
+ override suspend fun mergeWithExisting() = Unit
+
+ override suspend fun addCover(file: File, ext: String) = mutex.withLock {
+ val name = buildString {
+ append("cover")
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ file.copyTo(File(rootFile, name), overwrite = true)
+ }
+ index.setCoverEntry(name)
+ flushIndex()
+ }
+
+ override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = mutex.withLock {
+ val output = chaptersOutput.getOrPut(chapter.value) {
+ ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
+ }
+ val name = buildString {
+ append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.addChapter(chapter, chapterFileName(chapter))
+ }
+
+ override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
+ val output = chaptersOutput.remove(chapter) ?: return@withLock false
+ output.flushAndFinish()
+ flushIndex()
+ true
+ }
+
+ override suspend fun finish() = mutex.withLock {
+ flushIndex()
+ for (output in chaptersOutput.values) {
+ output.flushAndFinish()
+ }
+ chaptersOutput.clear()
+ }
+
+ override suspend fun cleanup() = mutex.withLock {
+ for (output in chaptersOutput.values) {
+ output.file.deleteAwait()
+ }
+ }
+
+ override fun close() {
+ for (output in chaptersOutput.values) {
+ output.close()
+ }
+ }
+
+ suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
+ val chapter = checkNotNull(index.getMangaInfo()?.chapters?.withIndex()) {
+ "No chapters found"
+ }.find { x -> x.value.id == chapterId } ?: error("Chapter not found")
+ val chapterDir = File(rootFile, chapterFileName(chapter))
+ chapterDir.deleteAwait()
+ index.removeChapter(chapterId)
+ }
+
+ fun setIndex(newIndex: MangaIndex) {
+ index.setFrom(newIndex)
+ }
+
+ private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
+ finish()
+ close()
+ val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
+ file.renameTo(resFile)
+ }
+
+ private fun chapterFileName(chapter: IndexedValue): String {
+ index.getChapterFileName(chapter.value.id)?.let {
+ return it
+ }
+ val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18)
+ var i = 0
+ while (true) {
+ val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
+ if (!File(rootFile, name).exists()) {
+ return name
+ }
+ i++
+ }
+ }
+
+ private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
+ File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
+ }
+
+ companion object {
+
+ private const val FILENAME_PATTERN = "%08d_%03d%03d"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt
new file mode 100644
index 0000000..ae95592
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt
@@ -0,0 +1,109 @@
+package org.xtimms.tokusho.core.parser.local.output
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okio.Closeable
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.xtimms.tokusho.core.parser.local.DownloadFormat
+import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
+import java.io.File
+
+sealed class LocalMangaOutput(
+ val rootFile: File,
+) : Closeable {
+
+ abstract suspend fun mergeWithExisting()
+
+ abstract suspend fun addCover(file: File, ext: String)
+
+ abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String)
+
+ abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
+
+ abstract suspend fun finish()
+
+ abstract suspend fun cleanup()
+
+ companion object {
+
+ const val ENTRY_NAME_INDEX = "index.json"
+ const val SUFFIX_TMP = ".tmp"
+ private val mutex = Mutex()
+
+ suspend fun getOrCreate(
+ root: File,
+ manga: Manga,
+ format: DownloadFormat,
+ ): LocalMangaOutput = withContext(Dispatchers.IO) {
+ val targetFormat = if (format == DownloadFormat.AUTOMATIC) {
+ if (manga.chapters.let { it != null && it.size <= 3 }) {
+ DownloadFormat.SINGLE_CBZ
+ } else {
+ DownloadFormat.MULTIPLE_CBZ
+ }
+ } else {
+ format
+ }
+ checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat))
+ }
+
+ suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
+ getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC)
+ }
+
+ private suspend fun getImpl(
+ root: File,
+ manga: Manga,
+ onlyIfExists: Boolean,
+ format: DownloadFormat,
+ ): LocalMangaOutput? {
+ mutex.withLock {
+ var i = 0
+ val baseName = manga.title.toFileNameSafe()
+ while (true) {
+ val fileName = if (i == 0) baseName else baseName + "_$i"
+ val dir = File(root, fileName)
+ val zip = File(root, "$fileName.cbz")
+ i++
+ return when {
+ dir.isDirectory -> {
+ if (canWriteTo(dir, manga)) {
+ LocalMangaDirOutput(dir, manga)
+ } else {
+ continue
+ }
+ }
+
+ zip.isFile -> if (canWriteTo(zip, manga)) {
+ LocalMangaZipOutput(zip, manga)
+ } else {
+ continue
+ }
+
+ !onlyIfExists -> when (format) {
+ DownloadFormat.AUTOMATIC -> null
+ DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga)
+ DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga)
+ }
+
+ else -> null
+ }
+ }
+ }
+ }
+
+ private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
+ val info = runCatchingCancellable {
+ LocalMangaInput.of(file).getMangaInfo()
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull() ?: return false
+ return info.id == manga.id
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt
new file mode 100644
index 0000000..e768f93
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt
@@ -0,0 +1,45 @@
+package org.xtimms.tokusho.core.parser.local.output
+
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+class LocalMangaUtil(
+ private val manga: Manga,
+) {
+
+ init {
+ require(manga.source == MangaSource.LOCAL) {
+ "Expected LOCAL source but ${manga.source} found"
+ }
+ }
+
+ suspend fun deleteChapters(ids: Set) {
+ newOutput().use { output ->
+ when (output) {
+ is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
+ LocalMangaZipOutput.filterChapters(output, ids)
+ }
+
+ is LocalMangaDirOutput -> {
+ for (id in ids) {
+ output.deleteChapter(id)
+ }
+ output.finish()
+ }
+ }
+ }
+ }
+
+ private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
+ val file = manga.url.toUri().toFile()
+ if (file.isDirectory) {
+ LocalMangaDirOutput(file, manga)
+ } else {
+ LocalMangaZipOutput(file, manga)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt
new file mode 100644
index 0000000..3b6baa7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt
@@ -0,0 +1,156 @@
+package org.xtimms.tokusho.core.parser.local.output
+
+import androidx.annotation.WorkerThread
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.xtimms.tokusho.core.model.isLocal
+import org.xtimms.tokusho.core.parser.local.MangaIndex
+import org.xtimms.tokusho.core.zip.ZipOutput
+import org.xtimms.tokusho.utils.system.deleteAwait
+import org.xtimms.tokusho.utils.system.readText
+import java.io.File
+import java.util.zip.ZipFile
+
+class LocalMangaZipOutput(
+ rootFile: File,
+ manga: Manga,
+) : LocalMangaOutput(rootFile) {
+
+ private val output = ZipOutput(File(rootFile.path + ".tmp"))
+ private val index = MangaIndex(null)
+ private val mutex = Mutex()
+
+ init {
+ if (!manga.isLocal) {
+ index.setMangaInfo(manga)
+ }
+ }
+
+ override suspend fun mergeWithExisting() = mutex.withLock {
+ if (rootFile.exists()) {
+ runInterruptible(Dispatchers.IO) {
+ mergeWith(rootFile)
+ }
+ }
+ }
+
+ override suspend fun addCover(file: File, ext: String) = mutex.withLock {
+ val name = buildString {
+ append(FILENAME_PATTERN.format(0, 0, 0))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.setCoverEntry(name)
+ }
+
+ override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = mutex.withLock {
+ val name = buildString {
+ append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.addChapter(chapter, null)
+ }
+
+ override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
+
+ override suspend fun finish() = mutex.withLock {
+ runInterruptible(Dispatchers.IO) {
+ output.put(ENTRY_NAME_INDEX, index.toString())
+ output.finish()
+ output.close()
+ }
+ rootFile.deleteAwait()
+ output.file.renameTo(rootFile)
+ Unit
+ }
+
+ override suspend fun cleanup() = mutex.withLock {
+ output.file.deleteAwait()
+ Unit
+ }
+
+ override fun close() {
+ output.close()
+ }
+
+ @WorkerThread
+ private fun mergeWith(other: File) {
+ var otherIndex: MangaIndex? = null
+ ZipFile(other).use { zip ->
+ for (entry in zip.entries()) {
+ if (entry.name == ENTRY_NAME_INDEX) {
+ otherIndex = MangaIndex(
+ zip.getInputStream(entry).use {
+ it.reader().readText()
+ },
+ )
+ } else {
+ output.copyEntryFrom(zip, entry)
+ }
+ }
+ }
+ otherIndex?.getMangaInfo()?.chapters?.withIndex()?.let { chapters ->
+ for (chapter in chapters) {
+ index.addChapter(chapter, null)
+ }
+ }
+ }
+
+ companion object {
+
+ private const val FILENAME_PATTERN = "%08d_%03d%03d"
+
+ @WorkerThread
+ fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) {
+ ZipFile(subject.rootFile).use { zip ->
+ val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
+ idsToRemove.forEach { id -> index.removeChapter(id) }
+ val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
+ index.getChapterNamesPattern(it)
+ }
+ val coverEntryName = index.getCoverEntry()
+ for (entry in zip.entries()) {
+ when {
+ entry.name == ENTRY_NAME_INDEX -> {
+ subject.output.put(ENTRY_NAME_INDEX, index.toString())
+ }
+
+ entry.isDirectory -> {
+ subject.output.addDirectory(entry.name)
+ }
+
+ entry.name == coverEntryName -> {
+ subject.output.copyEntryFrom(zip, entry)
+ }
+
+ else -> {
+ val name = entry.name.substringBefore('.')
+ if (patterns.any { it.matches(name) }) {
+ subject.output.copyEntryFrom(zip, entry)
+ }
+ }
+ }
+ }
+ subject.output.finish()
+ subject.output.close()
+ subject.rootFile.delete()
+ subject.output.file.renameTo(subject.rootFile)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
index b53b542..78f2cc2 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import org.xtimms.shiki.ui.theme.SEED
+import org.xtimms.tokusho.ui.theme.SEED
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.utils.lang.processLifecycleScope
@@ -36,6 +36,10 @@ const val PRE_RELEASE = 1
const val ACRA = "acra"
const val LOGGING = "logging"
+const val SSL_BYPASS = "ssl_bypass"
+const val NSFW = "nsfw"
+const val TABS_MANGA_COUNT = "tabs_manga_count"
+
val paletteStyles = listOf(
PaletteStyle.TonalSpot,
PaletteStyle.Spritz,
@@ -91,12 +95,18 @@ object AppSettings {
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
- fun isACRAEnabled() = ACRA.getBoolean(false)
+ fun isACRAEnabled() = ACRA.getBoolean(true)
fun isLoggingEnabled() = LOGGING.getBoolean(false)
fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true)
+ fun isNSFWEnabled() = NSFW.getBoolean(false)
+
+ fun isSSLBypassEnabled() = SSL_BYPASS.getBoolean(false)
+
+ fun isMangaCountInTabsEnabled() = TABS_MANGA_COUNT.getBoolean(false)
+
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
languageMap.getOrElse(languageNumber) { "" }
diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt
new file mode 100644
index 0000000..6c4fd9a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt
@@ -0,0 +1,89 @@
+package org.xtimms.tokusho.core.prefs
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.preference.PreferenceManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transform
+import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
+import org.xtimms.tokusho.utils.system.getEnumValue
+import org.xtimms.tokusho.utils.system.putEnumValue
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class KotatsuAppSettings @Inject constructor(@ApplicationContext context: Context) {
+
+ private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+
+ var isNsfwContentDisabled: Boolean
+ get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
+ set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
+
+ val isNewSourcesTipEnabled: Boolean
+ get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
+
+ var sourcesSortOrder: SourcesSortOrder
+ get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
+ set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
+
+ fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun observe() = prefs.observe()
+
+ companion object {
+ const val KEY_DISABLE_NSFW = "no_nsfw"
+ const val KEY_SOURCES_NEW = "sources_new"
+ const val KEY_SOURCES_ORDER = "sources_sort_order"
+ }
+}
+
+fun KotatsuAppSettings.observeAsFlow(key: String, valueProducer: KotatsuAppSettings.() -> T) = flow {
+ var lastValue: T = valueProducer()
+ emit(lastValue)
+ observe().collect {
+ if (it == key) {
+ val value = valueProducer()
+ if (value != lastValue) {
+ emit(value)
+ }
+ lastValue = value
+ }
+ }
+}
+
+fun KotatsuAppSettings.observeAsStateFlow(
+ scope: CoroutineScope,
+ key: String,
+ valueProducer: KotatsuAppSettings.() -> T,
+): StateFlow = observe().transform {
+ if (it == key) {
+ emit(valueProducer())
+ }
+}.stateIn(scope, SharingStarted.Eagerly, valueProducer())
+
+fun SharedPreferences.observe(): Flow = callbackFlow {
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ trySendBlocking(key)
+ }
+ registerOnSharedPreferenceChangeListener(listener)
+ awaitClose {
+ unregisterOnSharedPreferenceChangeListener(listener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt
index 60f2c94..f11030a 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt
@@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import kotlinx.collections.immutable.ImmutableList
import org.xtimms.tokusho.core.components.ActionButton
@@ -36,12 +39,16 @@ data class EmptyScreenAction(
@Composable
fun EmptyScreen(
+ icon: ImageVector,
@StringRes title: Int,
+ @StringRes description: Int,
modifier: Modifier = Modifier,
actions: ImmutableList? = null,
) {
EmptyScreen(
+ icon = icon,
message = stringResource(title),
+ summary = stringResource(description),
modifier = modifier,
actions = actions,
)
@@ -49,11 +56,12 @@ fun EmptyScreen(
@Composable
fun EmptyScreen(
+ icon: ImageVector,
message: String,
+ summary: String,
modifier: Modifier = Modifier,
actions: ImmutableList? = null,
) {
- val face = remember { getRandomErrorFace() }
Column(
modifier = modifier
.fillMaxSize()
@@ -62,20 +70,27 @@ fun EmptyScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
- CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
- Text(
- text = face,
- modifier = Modifier.secondaryItemAlpha(),
- style = MaterialTheme.typography.displayMedium,
- )
- }
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(96.dp).secondaryItemAlpha()
+ )
Text(
text = message,
modifier = Modifier
.paddingFromBaseline(top = 24.dp)
.secondaryItemAlpha(),
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ text = summary,
+ modifier = Modifier
+ .paddingFromBaseline(top = 24.dp)
+ .secondaryItemAlpha(),
+ style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
)
@@ -97,16 +112,3 @@ fun EmptyScreen(
}
}
}
-
-private val ErrorFaces = listOf(
- "(・o・;)",
- "Σ(ಠ_ಠ)",
- "ಥ_ಥ",
- "(˘・_・˘)",
- "(; ̄Д ̄)",
- "(・Д・。",
-)
-
-private fun getRandomErrorFace(): String {
- return ErrorFaces[Random.nextInt(ErrorFaces.size)]
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt
index 11dd6ba..07de30c 100644
--- a/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt
+++ b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt
@@ -31,8 +31,8 @@ import java.util.regex.Pattern
object Updater {
- private const val OWNER = "ztimms73"
- private const val REPO = "Tokusho"
+ private const val OWNER = "KotatsuApp"
+ private const val REPO = "Kotatsu"
private const val TAG = "Updates"
private val client = OkHttpClient()
diff --git a/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt
new file mode 100644
index 0000000..2f82d7b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt
@@ -0,0 +1,122 @@
+package org.xtimms.tokusho.core.zip
+
+import androidx.annotation.WorkerThread
+import androidx.collection.ArraySet
+import okio.Closeable
+import org.xtimms.tokusho.utils.system.children
+import java.io.File
+import java.io.FileInputStream
+import java.util.zip.Deflater
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+class ZipOutput(
+ val file: File,
+ compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
+) : Closeable {
+
+ private val entryNames = ArraySet()
+ private var isClosed = false
+ private val output = ZipOutputStream(file.outputStream()).apply {
+ setLevel(compressionLevel)
+ }
+
+ @WorkerThread
+ fun put(name: String, file: File): Boolean {
+ return output.appendFile(file, name)
+ }
+
+ @WorkerThread
+ fun put(name: String, content: String): Boolean {
+ return output.appendText(content, name)
+ }
+
+ @WorkerThread
+ fun addDirectory(name: String): Boolean {
+ val entry = if (name.endsWith("/")) {
+ ZipEntry(name)
+ } else {
+ ZipEntry("$name/")
+ }
+ return if (entryNames.add(entry.name)) {
+ output.putNextEntry(entry)
+ output.closeEntry()
+ true
+ } else {
+ false
+ }
+ }
+
+ @WorkerThread
+ fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
+ return if (entryNames.add(entry.name)) {
+ val zipEntry = ZipEntry(entry.name)
+ output.putNextEntry(zipEntry)
+ try {
+ other.getInputStream(entry).use { input ->
+ input.copyTo(output)
+ }
+ } finally {
+ output.closeEntry()
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ fun finish() {
+ output.finish()
+ output.flush()
+ }
+
+ override fun close() {
+ if (!isClosed) {
+ output.close()
+ isClosed = true
+ }
+ }
+
+ @WorkerThread
+ private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
+ if (fileToZip.isDirectory) {
+ val entry = if (name.endsWith("/")) {
+ ZipEntry(name)
+ } else {
+ ZipEntry("$name/")
+ }
+ if (!entryNames.add(entry.name)) {
+ return false
+ }
+ putNextEntry(entry)
+ closeEntry()
+ fileToZip.children().forEach { childFile ->
+ appendFile(childFile, "$name/${childFile.name}")
+ }
+ } else {
+ FileInputStream(fileToZip).use { fis ->
+ if (!entryNames.add(name)) {
+ return false
+ }
+ val zipEntry = ZipEntry(name)
+ putNextEntry(zipEntry)
+ fis.copyTo(this)
+ closeEntry()
+ }
+ }
+ return true
+ }
+
+ @WorkerThread
+ private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
+ if (!entryNames.add(name)) {
+ return false
+ }
+ val zipEntry = ZipEntry(name)
+ putNextEntry(zipEntry)
+ content.byteInputStream().copyTo(this)
+ closeEntry()
+ return true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
index 6238d32..f2c5edd 100644
--- a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
+++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt
@@ -59,6 +59,28 @@ class LocalStorageManager @Inject constructor(
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
}
+ suspend fun getReadableDirs(): List = runInterruptible(Dispatchers.IO) {
+ getConfiguredStorageDirs()
+ .filter { it.isReadable() }
+ }
+
+ suspend fun getWriteableDirs(): List = runInterruptible(Dispatchers.IO) {
+ getConfiguredStorageDirs()
+ .filter { it.isWriteable() }
+ }
+
+ suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
+ val preferredDir = context.filesDir?.takeIf { it.isWriteable() }
+ preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
+ }
+
+ @WorkerThread
+ private fun getConfiguredStorageDirs(): MutableSet {
+ val set = getAvailableStorageDirs()
+ set.addAll(setOf(context.filesDir))
+ return set
+ }
+
@WorkerThread
private fun getAvailableStorageDirs(): MutableSet {
val result = LinkedHashSet()
@@ -103,4 +125,11 @@ class LocalStorageManager @Inject constructor(
}
}
+ private fun File.isReadable() = runCatching {
+ canRead()
+ }.getOrDefault(false)
+
+ private fun File.isWriteable() = runCatching {
+ canWrite()
+ }.getOrDefault(false)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt
new file mode 100644
index 0000000..8ee9e0d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt
@@ -0,0 +1,102 @@
+package org.xtimms.tokusho.data.repository
+
+import android.database.SQLException
+import androidx.room.withTransaction
+import dagger.Reusable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.database.entity.BookmarkEntity
+import org.xtimms.tokusho.core.database.entity.toBookmark
+import org.xtimms.tokusho.core.database.entity.toBookmarks
+import org.xtimms.tokusho.core.database.entity.toEntities
+import org.xtimms.tokusho.core.database.entity.toEntity
+import org.xtimms.tokusho.core.database.entity.toManga
+import org.xtimms.tokusho.core.model.Bookmark
+import org.xtimms.tokusho.utils.ReversibleHandle
+import org.xtimms.tokusho.utils.lang.mapItems
+import javax.inject.Inject
+
+@Reusable
+class BookmarksRepository @Inject constructor(
+ private val db: TokushoDatabase,
+) {
+
+ fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow {
+ return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
+ }
+
+ fun observeBookmarks(manga: Manga): Flow> {
+ return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
+ }
+
+ fun observeBookmarks(): Flow>> {
+ return db.getBookmarksDao().observe().map { map ->
+ val res = LinkedHashMap>(map.size)
+ for ((k, v) in map) {
+ val manga = k.toManga()
+ res[manga] = v.toBookmarks(manga)
+ }
+ res
+ }
+ }
+
+ suspend fun addBookmark(bookmark: Bookmark) {
+ db.withTransaction {
+ val tags = bookmark.manga.tags.toEntities()
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
+ db.getBookmarksDao().insert(bookmark.toEntity())
+ }
+ }
+
+ suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
+ val entity = bookmark.toEntity().copy(
+ imageUrl = imageUrl,
+ )
+ db.getBookmarksDao().upsert(listOf(entity))
+ }
+
+ suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
+ check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
+ "Bookmark not found"
+ }
+ }
+
+ suspend fun removeBookmark(bookmark: Bookmark) {
+ removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
+ }
+
+ suspend fun removeBookmarks(ids: Set): ReversibleHandle {
+ val entities = ArrayList(ids.size)
+ db.withTransaction {
+ val dao = db.getBookmarksDao()
+ for (pageId in ids) {
+ val e = dao.find(pageId)
+ if (e != null) {
+ entities.add(e)
+ }
+ dao.delete(pageId)
+ }
+ }
+ return BookmarksRestorer(entities)
+ }
+
+ private inner class BookmarksRestorer(
+ private val entities: Collection,
+ ) : ReversibleHandle {
+
+ override suspend fun reverse() {
+ db.withTransaction {
+ for (e in entities) {
+ try {
+ db.getBookmarksDao().insert(e)
+ } catch (e: SQLException) {
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt
new file mode 100644
index 0000000..6152648
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt
@@ -0,0 +1,40 @@
+package org.xtimms.tokusho.data.repository
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaListFilter
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.almostEquals
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.core.parser.MangaRepository
+import org.xtimms.tokusho.utils.lang.asArrayList
+import javax.inject.Inject
+
+class ExploreRepository @Inject constructor(
+ private val sourcesRepository: MangaSourcesRepository,
+ private val historyRepository: HistoryRepository,
+ private val mangaRepositoryFactory: MangaRepository.Factory,
+) {
+
+ private suspend fun getList(
+ source: MangaSource,
+ tags: List,
+ ): List = runCatchingCancellable {
+ val repository = mangaRepositoryFactory.create(source)
+ val order = repository.sortOrders.random()
+ val availableTags = repository.getTags()
+ val tag = tags.firstNotNullOfOrNull { title ->
+ availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
+ }
+ val list = repository.getList(
+ offset = 0,
+ filter = MangaListFilter.Advanced.Builder(order)
+ .tags(setOfNotNull(tag))
+ .build(),
+ ).asArrayList()
+ list.shuffle()
+ list
+ }.onFailure {
+ // TODO
+ }.getOrDefault(emptyList())
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt
index 8c801e0..bcc80aa 100644
--- a/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt
@@ -2,6 +2,7 @@ package org.xtimms.tokusho.data.repository
import androidx.room.withTransaction
import dagger.Reusable
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@@ -9,8 +10,13 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
+import org.xtimms.tokusho.core.database.entity.FavouriteEntity
+import org.xtimms.tokusho.core.database.entity.toEntities
+import org.xtimms.tokusho.core.database.entity.toEntity
import org.xtimms.tokusho.core.database.entity.toFavouriteCategory
import org.xtimms.tokusho.core.database.entity.toManga
+import org.xtimms.tokusho.core.database.entity.toMangaList
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.utils.ReversibleHandle
@@ -22,11 +28,27 @@ class FavouritesRepository @Inject constructor(
private val db: TokushoDatabase,
) {
+ suspend fun getAllManga(): List {
+ val entities = db.getFavouritesDao().findAll()
+ return entities.toMangaList()
+ }
+
+ suspend fun getLastManga(limit: Int): List {
+ val entities = db.getFavouritesDao().findLast(limit)
+ return entities.toMangaList()
+ }
+
fun observeAll(categoryId: Long, order: ListSortOrder): Flow> {
return db.getFavouritesDao().observeAll(categoryId, order)
.mapItems { it.toManga() }
}
+ suspend fun getManga(categoryId: Long): List {
+ val entities = db.getFavouritesDao().findAll(categoryId)
+ return entities.toMangaList()
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
fun observeAll(categoryId: Long): Flow> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
@@ -37,6 +59,17 @@ class FavouritesRepository @Inject constructor(
.distinctUntilChanged()
}
+ fun observeMangaCountInCategory(categoryId: Long): Flow {
+ return db.getFavouritesDao().observeMangaCountInCategory(categoryId)
+ .distinctUntilChanged()
+ }
+
+ fun observeCategories(): Flow> {
+ return db.getFavouriteCategoriesDao().observeAll().mapItems {
+ it.toFavouriteCategory()
+ }.distinctUntilChanged()
+ }
+
fun observeCategoriesForLibrary(): Flow> {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems {
it.toFavouriteCategory()
@@ -47,10 +80,34 @@ class FavouritesRepository @Inject constructor(
return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() }
}
+ suspend fun getCategory(id: Long): FavouriteCategory {
+ return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
+ }
+
suspend fun getCategoriesIds(mangaIds: Collection): Set {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}
+ suspend fun createCategory(
+ title: String,
+ sortOrder: ListSortOrder,
+ isTrackerEnabled: Boolean,
+ isVisibleOnShelf: Boolean,
+ ): FavouriteCategory {
+ val entity = FavouriteCategoryEntity(
+ title = title,
+ createdAt = System.currentTimeMillis(),
+ sortKey = db.getFavouriteCategoriesDao().getNextSortKey(),
+ categoryId = 0,
+ order = sortOrder.name,
+ track = isTrackerEnabled,
+ deletedAt = 0L,
+ isVisibleInLibrary = isVisibleOnShelf,
+ )
+ val id = db.getFavouriteCategoriesDao().insert(entity)
+ return entity.toFavouriteCategory(id)
+ }
+
suspend fun updateCategory(
id: Long,
title: String,
@@ -91,6 +148,24 @@ class FavouritesRepository @Inject constructor(
}
}
+ suspend fun addToCategory(categoryId: Long, mangas: Collection) {
+ db.withTransaction {
+ for (manga in mangas) {
+ val tags = manga.tags.toEntities()
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(manga.toEntity(), tags)
+ val entity = FavouriteEntity(
+ mangaId = manga.id,
+ categoryId = categoryId,
+ createdAt = System.currentTimeMillis(),
+ sortKey = 0,
+ deletedAt = 0L,
+ )
+ db.getFavouritesDao().insert(entity)
+ }
+ }
+ }
+
suspend fun removeFromFavourites(ids: Collection): ReversibleHandle {
db.withTransaction {
for (id in ids) {
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt
index b5339ea..44df9c0 100644
--- a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt
@@ -1,14 +1,20 @@
package org.xtimms.tokusho.data.repository
+import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.HistoryEntity
+import org.xtimms.tokusho.core.database.entity.toManga
import org.xtimms.tokusho.core.database.entity.toMangaHistory
+import org.xtimms.tokusho.core.database.entity.toMangaTags
import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.core.model.findById
+import org.xtimms.tokusho.core.model.isNsfw
+import org.xtimms.tokusho.core.parser.MangaDataRepository
+import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
const val PROGRESS_NONE = -1f
@@ -16,8 +22,26 @@ const val PROGRESS_NONE = -1f
@Reusable
class HistoryRepository @Inject constructor(
private val db: TokushoDatabase,
+ private val mangaRepository: MangaDataRepository,
) {
+ suspend fun getLastOrNull(): Manga? {
+ val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
+ return entity.manga.toManga(entity.tags.toMangaTags())
+ }
+
+ fun observeAll(): Flow> {
+ return db.getHistoryDao().observeAll().mapItems {
+ it.manga.toManga(it.tags.toMangaTags())
+ }
+ }
+
+ fun observeAll(limit: Int): Flow> {
+ return db.getHistoryDao().observeAll(limit).mapItems {
+ it.manga.toManga(it.tags.toMangaTags())
+ }
+ }
+
suspend fun getOne(manga: Manga): MangaHistory? {
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
}
@@ -28,6 +52,31 @@ class HistoryRepository @Inject constructor(
}
}
+ suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
+ if (shouldSkip(manga)) {
+ return
+ }
+ db.withTransaction {
+ mangaRepository.storeManga(manga)
+ db.getHistoryDao().upsert(
+ HistoryEntity(
+ mangaId = manga.id,
+ createdAt = System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis(),
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
+ percent = percent,
+ deletedAt = 0L,
+ ),
+ )
+ }
+ }
+
+ fun shouldSkip(manga: Manga): Boolean {
+ return ((manga.source.isNsfw() || manga.isNsfw))
+ }
+
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt
index fc3b94a..f279cb3 100644
--- a/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt
@@ -1,17 +1,39 @@
package org.xtimms.tokusho.data.repository
+import androidx.compose.runtime.Composable
import dagger.Reusable
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
+import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
+import org.xtimms.tokusho.core.model.MangaSource
+import org.xtimms.tokusho.core.model.isNsfw
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.KotatsuAppSettings
+import org.xtimms.tokusho.core.prefs.observeAsFlow
+import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
+import org.xtimms.tokusho.utils.ReversibleHandle
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
+@OptIn(ExperimentalCoroutinesApi::class)
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: TokushoDatabase,
+ private val settings: KotatsuAppSettings,
) {
private val dao: MangaSourcesDao
@@ -27,4 +49,95 @@ class MangaSourcesRepository @Inject constructor(
val allMangaSources: Set
get() = Collections.unmodifiableSet(remoteSources)
+ suspend fun getEnabledSources(): List {
+ val order = settings.sourcesSortOrder
+ return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled)
+ }
+
+ suspend fun getDisabledSources(): List {
+ return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
+ }
+
+ fun observeEnabledSourcesCount(): Flow {
+ return combine(
+ observeIsNsfwDisabled(),
+ dao.observeEnabled(SourcesSortOrder.MANUAL),
+ ) { skipNsfw, sources ->
+ sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
+ }.distinctUntilChanged()
+ }
+
+ fun observeAvailableSourcesCount(): Flow {
+ return combine(
+ observeIsNsfwDisabled(),
+ dao.observeEnabled(SourcesSortOrder.MANUAL),
+ ) { skipNsfw, enabledSources ->
+ val enabled = enabledSources.mapToSet { it.source }
+ allMangaSources.count { x ->
+ x.name !in enabled && (!skipNsfw || !x.isNsfw())
+ }
+ }.distinctUntilChanged()
+ }
+
+ fun observeEnabledSources(): Flow> = combine(
+ observeIsNsfwDisabled(),
+ observeSortOrder(),
+ ) { skipNsfw, order ->
+ dao.observeEnabled(order).map {
+ it.toSources(skipNsfw)
+ }
+ }.flatMapLatest { it }
+
+ suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle {
+ dao.setEnabled(source.name, isEnabled)
+ return ReversibleHandle {
+ dao.setEnabled(source.name, !isEnabled)
+ }
+ }
+
+ fun observeNewSources(): Flow> = observeIsNewSourcesEnabled().flatMapLatest {
+ if (it) {
+ combine(
+ dao.observeAll(),
+ observeIsNsfwDisabled(),
+ ) { entities, skipNsfw ->
+ val result = EnumSet.copyOf(remoteSources)
+ for (e in entities) {
+ result.remove(MangaSource(e.source))
+ }
+ if (skipNsfw) {
+ result.removeAll { x -> x.isNsfw() }
+ }
+ result
+ }.distinctUntilChanged()
+ } else {
+ flowOf(emptySet())
+ }
+ }
+
+ private fun List.toSources(
+ skipNsfwSources: Boolean,
+ ): List {
+ val result = ArrayList(size)
+ for (entity in this) {
+ val source = MangaSource(entity.source)
+ if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
+ continue
+ }
+ if (source in remoteSources) {
+ result.add(source)
+ }
+ }
+ return result
+ }
+
+ private fun observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow()
+
+ private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) {
+ isNewSourcesTipEnabled
+ }
+
+ private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) {
+ sourcesSortOrder
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt
new file mode 100644
index 0000000..d4c12f4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt
@@ -0,0 +1,21 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import org.json.JSONArray
+
+class BackupEntry(
+ val name: Name,
+ val data: JSONArray
+) {
+
+ enum class Name(
+ val key: String,
+ ) {
+
+ INDEX("index"),
+ HISTORY("history"),
+ CATEGORIES("categories"),
+ FAVOURITES("favourites"),
+ BOOKMARKS("bookmarks"),
+ SOURCES("sources"),
+ }
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt
new file mode 100644
index 0000000..5b0b3c7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt
@@ -0,0 +1,200 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import androidx.room.withTransaction
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.util.json.JSONIterator
+import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
+import org.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.BuildConfig
+import org.xtimms.tokusho.core.database.TokushoDatabase
+import java.util.Date
+import javax.inject.Inject
+
+private const val PAGE_SIZE = 10
+
+class BackupRepository @Inject constructor(
+ private val db: TokushoDatabase,
+) {
+
+ suspend fun dumpHistory(): BackupEntry {
+ var offset = 0
+ val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
+ while (true) {
+ val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
+ if (history.isEmpty()) {
+ break
+ }
+ offset += history.size
+ for (item in history) {
+ val manga = JsonSerializer(item.manga).toJson()
+ val tags = JSONArray()
+ item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
+ manga.put("tags", tags)
+ val json = JsonSerializer(item.history).toJson()
+ json.put("manga", manga)
+ entry.data.put(json)
+ }
+ }
+ return entry
+ }
+
+ suspend fun dumpCategories(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
+ val categories = db.getFavouriteCategoriesDao().findAll()
+ for (item in categories) {
+ entry.data.put(JsonSerializer(item).toJson())
+ }
+ return entry
+ }
+
+ suspend fun dumpFavourites(): BackupEntry {
+ var offset = 0
+ val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
+ while (true) {
+ val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
+ if (favourites.isEmpty()) {
+ break
+ }
+ offset += favourites.size
+ for (item in favourites) {
+ val manga = JsonSerializer(item.manga).toJson()
+ val tags = JSONArray()
+ item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
+ manga.put("tags", tags)
+ val json = JsonSerializer(item.favourite).toJson()
+ json.put("manga", manga)
+ entry.data.put(json)
+ }
+ }
+ return entry
+ }
+
+ suspend fun dumpBookmarks(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
+ val all = db.getBookmarksDao().findAll()
+ for ((m, b) in all) {
+ val json = JSONObject()
+ val manga = JsonSerializer(m.manga).toJson()
+ json.put("manga", manga)
+ val tags = JSONArray()
+ m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
+ json.put("tags", tags)
+ val bookmarks = JSONArray()
+ b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
+ json.put("bookmarks", bookmarks)
+ entry.data.put(json)
+ }
+ return entry
+ }
+
+ suspend fun dumpSources(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
+ val all = db.getSourcesDao().findAll()
+ for (source in all) {
+ val json = JsonSerializer(source).toJson()
+ entry.data.put(json)
+ }
+ return entry
+ }
+
+ fun createIndex(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
+ val json = JSONObject()
+ json.put("app_id", BuildConfig.APPLICATION_ID)
+ json.put("app_version", BuildConfig.VERSION_CODE)
+ json.put("created_at", System.currentTimeMillis())
+ entry.data.put(json)
+ return entry
+ }
+
+ fun getBackupDate(entry: BackupEntry?): Date? {
+ val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
+ return if (timestamp == 0L) null else Date(timestamp)
+ }
+
+ suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = JsonDeserializer(mangaJson).toMangaEntity()
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ JsonDeserializer(it).toTagEntity()
+ }
+ val history = JsonDeserializer(item).toHistoryEntity()
+ result += runCatchingCancellable {
+ db.withTransaction {
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(manga, tags)
+ db.getHistoryDao().upsert(history)
+ }
+ }
+ }
+ return result
+ }
+
+ suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val category = JsonDeserializer(item).toFavouriteCategoryEntity()
+ result += runCatchingCancellable {
+ db.getFavouriteCategoriesDao().upsert(category)
+ }
+ }
+ return result
+ }
+
+ suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = JsonDeserializer(mangaJson).toMangaEntity()
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ JsonDeserializer(it).toTagEntity()
+ }
+ val favourite = JsonDeserializer(item).toFavouriteEntity()
+ result += runCatchingCancellable {
+ db.withTransaction {
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(manga, tags)
+ db.getFavouritesDao().upsert(favourite)
+ }
+ }
+ }
+ return result
+ }
+
+ suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = JsonDeserializer(mangaJson).toMangaEntity()
+ val tags = item.getJSONArray("tags").mapJSON {
+ JsonDeserializer(it).toTagEntity()
+ }
+ val bookmarks = item.getJSONArray("bookmarks").mapJSON {
+ JsonDeserializer(it).toBookmarkEntity()
+ }
+ result += runCatchingCancellable {
+ db.withTransaction {
+ db.getTagsDao().upsert(tags)
+ db.getMangaDao().upsert(manga, tags)
+ db.getBookmarksDao().upsert(bookmarks)
+ }
+ }
+ }
+ return result
+ }
+
+ suspend fun restoreSources(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val source = JsonDeserializer(item).toMangaSourceEntity()
+ result += runCatchingCancellable {
+ db.getSourcesDao().upsert(source)
+ }
+ }
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt
new file mode 100644
index 0000000..54e11bf
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt
@@ -0,0 +1,44 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
+import okio.Closeable
+import org.json.JSONArray
+import org.xtimms.tokusho.utils.lang.processLifecycleScope
+import java.io.File
+import java.util.EnumSet
+import java.util.zip.ZipFile
+
+class BackupZipInput(val file: File) : Closeable {
+
+ private val zipFile = ZipFile(file)
+
+ suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
+ val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
+ val json = zipFile.getInputStream(entry).use {
+ JSONArray(it.bufferedReader().readText())
+ }
+ BackupEntry(name, json)
+ }
+
+ suspend fun entries(): Set = runInterruptible(Dispatchers.IO) {
+ zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
+ BackupEntry.Name.entries.find { it.key == ze.name }
+ }
+ }
+
+ override fun close() {
+ zipFile.close()
+ }
+
+ fun cleanupAsync() {
+ processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
+ runCatching {
+ close()
+ file.delete()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt
new file mode 100644
index 0000000..4a0a8d3
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt
@@ -0,0 +1,46 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okio.Closeable
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.zip.ZipOutput
+import java.io.File
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import java.util.zip.Deflater
+
+class BackupZipOutput(val file: File) : Closeable {
+
+ private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
+
+ suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
+ output.put(entry.name.key, entry.data.toString(2))
+ }
+
+ suspend fun finish() = runInterruptible(Dispatchers.IO) {
+ output.finish()
+ }
+
+ override fun close() {
+ output.close()
+ }
+}
+
+const val DIR_BACKUPS = "backups"
+
+suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
+ val dir = context.run {
+ getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
+ }
+ dir.mkdirs()
+ val filename = buildString {
+ append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
+ append('_')
+ append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
+ append(".bk.zip")
+ }
+ BackupZipOutput(File(dir, filename))
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt
new file mode 100644
index 0000000..5cda730
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt
@@ -0,0 +1,42 @@
+package org.xtimms.tokusho.data.repository.backup
+
+class CompositeResult {
+
+ private var successCount: Int = 0
+ private val errors = ArrayList()
+
+ val size: Int
+ get() = successCount + errors.size
+
+ val failures: List
+ get() = errors.filterNotNull()
+
+ val isEmpty: Boolean
+ get() = errors.isEmpty() && successCount == 0
+
+ val isAllSuccess: Boolean
+ get() = errors.none { it != null }
+
+ val isAllFailed: Boolean
+ get() = successCount == 0 && errors.isNotEmpty()
+
+ operator fun plusAssign(result: Result<*>) {
+ when {
+ result.isSuccess -> successCount++
+ result.isFailure -> errors.add(result.exceptionOrNull())
+ }
+ }
+
+ operator fun plusAssign(other: CompositeResult) {
+ this.successCount += other.successCount
+ this.errors += other.errors
+ }
+
+ operator fun plus(other: CompositeResult): CompositeResult {
+ val result = CompositeResult()
+ result.successCount = this.successCount + other.successCount
+ result.errors.addAll(this.errors)
+ result.errors.addAll(other.errors)
+ return result
+ }
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt
new file mode 100644
index 0000000..e51dc1b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt
@@ -0,0 +1,100 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+import org.xtimms.tokusho.core.database.entity.BookmarkEntity
+import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
+import org.xtimms.tokusho.core.database.entity.FavouriteEntity
+import org.xtimms.tokusho.core.database.entity.HistoryEntity
+import org.xtimms.tokusho.core.database.entity.MangaEntity
+import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
+import org.xtimms.tokusho.core.database.entity.TagEntity
+
+class JsonDeserializer(private val json: JSONObject) {
+
+ fun toFavouriteEntity() = FavouriteEntity(
+ mangaId = json.getLong("manga_id"),
+ categoryId = json.getLong("category_id"),
+ sortKey = json.getIntOrDefault("sort_key", 0),
+ createdAt = json.getLong("created_at"),
+ deletedAt = 0L,
+ )
+
+ fun toMangaEntity() = MangaEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ altTitle = json.getStringOrNull("alt_title"),
+ url = json.getString("url"),
+ publicUrl = json.getStringOrNull("public_url").orEmpty(),
+ rating = json.getDouble("rating").toFloat(),
+ isNsfw = json.getBooleanOrDefault("nsfw", false),
+ coverUrl = json.getString("cover_url"),
+ largeCoverUrl = json.getStringOrNull("large_cover_url"),
+ state = json.getStringOrNull("state"),
+ author = json.getStringOrNull("author"),
+ source = json.getString("source"),
+ )
+
+ fun toTagEntity() = TagEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ key = json.getString("key"),
+ source = json.getString("source"),
+ )
+
+ fun toHistoryEntity() = HistoryEntity(
+ mangaId = json.getLong("manga_id"),
+ createdAt = json.getLong("created_at"),
+ updatedAt = json.getLong("updated_at"),
+ chapterId = json.getLong("chapter_id"),
+ page = json.getInt("page"),
+ scroll = json.getDouble("scroll").toFloat(),
+ percent = json.getFloatOrDefault("percent", -1f),
+ deletedAt = 0L,
+ )
+
+ fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
+ categoryId = json.getInt("category_id"),
+ createdAt = json.getLong("created_at"),
+ sortKey = json.getInt("sort_key"),
+ title = json.getString("title"),
+ order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
+ track = json.getBooleanOrDefault("track", true),
+ isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
+ deletedAt = 0L,
+ )
+
+ fun toBookmarkEntity() = BookmarkEntity(
+ mangaId = json.getLong("manga_id"),
+ pageId = json.getLong("page_id"),
+ chapterId = json.getLong("chapter_id"),
+ page = json.getInt("page"),
+ scroll = json.getInt("scroll"),
+ imageUrl = json.getString("image_url"),
+ createdAt = json.getLong("created_at"),
+ percent = json.getDouble("percent").toFloat(),
+ )
+
+ fun toMangaSourceEntity() = MangaSourceEntity(
+ source = json.getString("source"),
+ isEnabled = json.getBoolean("enabled"),
+ sortKey = json.getInt("sort_key"),
+ )
+
+ fun toMap(): Map {
+ val map = mutableMapOf()
+ val keys = json.keys()
+
+ while (keys.hasNext()) {
+ val key = keys.next()
+ val value = json.get(key)
+ map[key] = value
+ }
+
+ return map
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt
new file mode 100644
index 0000000..9e3a221
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt
@@ -0,0 +1,99 @@
+package org.xtimms.tokusho.data.repository.backup
+
+import org.json.JSONObject
+import org.xtimms.tokusho.core.database.entity.BookmarkEntity
+import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
+import org.xtimms.tokusho.core.database.entity.FavouriteEntity
+import org.xtimms.tokusho.core.database.entity.HistoryEntity
+import org.xtimms.tokusho.core.database.entity.MangaEntity
+import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
+import org.xtimms.tokusho.core.database.entity.TagEntity
+
+class JsonSerializer private constructor(private val json: JSONObject) {
+
+ constructor(e: FavouriteEntity) : this(
+ JSONObject().apply {
+ put("manga_id", e.mangaId)
+ put("category_id", e.categoryId)
+ put("sort_key", e.sortKey)
+ put("created_at", e.createdAt)
+ },
+ )
+
+ constructor(e: FavouriteCategoryEntity) : this(
+ JSONObject().apply {
+ put("category_id", e.categoryId)
+ put("created_at", e.createdAt)
+ put("sort_key", e.sortKey)
+ put("title", e.title)
+ put("order", e.order)
+ put("track", e.track)
+ put("show_in_lib", e.isVisibleInLibrary)
+ },
+ )
+
+ constructor(e: HistoryEntity) : this(
+ JSONObject().apply {
+ put("manga_id", e.mangaId)
+ put("created_at", e.createdAt)
+ put("updated_at", e.updatedAt)
+ put("chapter_id", e.chapterId)
+ put("page", e.page)
+ put("scroll", e.scroll)
+ put("percent", e.percent)
+ },
+ )
+
+ constructor(e: TagEntity) : this(
+ JSONObject().apply {
+ put("id", e.id)
+ put("title", e.title)
+ put("key", e.key)
+ put("source", e.source)
+ },
+ )
+
+ constructor(e: MangaEntity) : this(
+ JSONObject().apply {
+ put("id", e.id)
+ put("title", e.title)
+ put("alt_title", e.altTitle)
+ put("url", e.url)
+ put("public_url", e.publicUrl)
+ put("rating", e.rating)
+ put("nsfw", e.isNsfw)
+ put("cover_url", e.coverUrl)
+ put("large_cover_url", e.largeCoverUrl)
+ put("state", e.state)
+ put("author", e.author)
+ put("source", e.source)
+ },
+ )
+
+ constructor(e: BookmarkEntity) : this(
+ JSONObject().apply {
+ put("manga_id", e.mangaId)
+ put("page_id", e.pageId)
+ put("chapter_id", e.chapterId)
+ put("page", e.page)
+ put("scroll", e.scroll)
+ put("image_url", e.imageUrl)
+ put("created_at", e.createdAt)
+ put("percent", e.percent)
+ },
+ )
+
+ constructor(e: MangaSourceEntity) : this(
+ JSONObject().apply {
+ put("source", e.source)
+ put("enabled", e.isEnabled)
+ put("sort_key", e.sortKey)
+ },
+ )
+
+ constructor(m: Map) : this(
+ JSONObject(m),
+ )
+
+ fun toJson(): JSONObject = json
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt
new file mode 100644
index 0000000..59eecd2
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt
@@ -0,0 +1,127 @@
+package org.xtimms.tokusho.sections.details
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.DotSeparatorText
+import org.xtimms.tokusho.utils.composable.selectedBackground
+import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ChapterListItem(
+ title: String,
+ date: Long?,
+ scanlator: String?,
+ read: Boolean,
+ bookmark: Boolean,
+ selected: Boolean,
+ onLongClick: () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+
+ val textAlpha = if (read) .38f else 1f
+ val textSubtitleAlpha = if (read) .38f else SecondaryItemAlpha
+
+ Box(
+ modifier = Modifier.clipToBounds()
+ ) {
+ Row(
+ modifier = modifier
+ .selectedBackground(selected)
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ )
+ .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ var textHeight by remember { mutableIntStateOf(0) }
+ if (!read) {
+ Icon(
+ imageVector = Icons.Filled.Circle,
+ contentDescription = stringResource(R.string.unread),
+ modifier = Modifier
+ .height(8.dp)
+ .padding(end = 4.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ if (bookmark) {
+ Icon(
+ imageVector = Icons.Filled.Bookmark,
+ contentDescription = stringResource(R.string.action_filter_bookmarked),
+ modifier = Modifier
+ .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalContentColor.current.copy(alpha = textAlpha),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ onTextLayout = { textHeight = it.size.height },
+ )
+ }
+
+ Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
+ ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
+ if (date != null) {
+ Text(
+ text = date.toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (scanlator != null) DotSeparatorText()
+ }
+ if (scanlator != null) {
+ Text(
+ text = scanlator,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt
new file mode 100644
index 0000000..0d15011
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt
@@ -0,0 +1,63 @@
+package org.xtimms.tokusho.sections.details
+
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.xtimms.tokusho.core.model.Bookmark
+import org.xtimms.tokusho.core.model.MangaHistory
+import org.xtimms.tokusho.sections.details.data.MangaDetails
+import org.xtimms.tokusho.sections.details.model.ChapterItem
+import org.xtimms.tokusho.sections.details.model.toListItem
+
+fun MangaDetails.mapChapters(
+ history: MangaHistory?,
+ newCount: Int,
+ branch: String?,
+ bookmarks: List,
+): List {
+ val remoteChapters = chapters[branch].orEmpty()
+ val localChapters = local?.manga?.getChapters(branch).orEmpty()
+ if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
+ return emptyList()
+ }
+ val bookmarked = bookmarks.mapToSet { it.chapterId }
+ val currentId = history?.chapterId ?: 0L
+ val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
+ val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
+ remoteChapters.mapTo(this) { it.id }
+ localChapters.mapTo(this) { it.id }
+ }
+ val result = ArrayList(ids.size)
+ val localMap = if (localChapters.isNotEmpty()) {
+ localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
+ } else {
+ null
+ }
+ var isUnread = currentId !in ids
+ for (chapter in remoteChapters) {
+ val local = localMap?.remove(chapter.id)
+ if (chapter.id == currentId) {
+ isUnread = true
+ }
+ result += (local ?: chapter).toListItem(
+ isCurrent = chapter.id == currentId,
+ isUnread = isUnread,
+ isNew = isUnread && result.size >= newFrom,
+ isDownloaded = local != null,
+ isBookmarked = chapter.id in bookmarked,
+ )
+ }
+ if (!localMap.isNullOrEmpty()) {
+ for (chapter in localMap.values) {
+ if (chapter.id == currentId) {
+ isUnread = true
+ }
+ result += chapter.toListItem(
+ isCurrent = chapter.id == currentId,
+ isUnread = isUnread,
+ isNew = false,
+ isDownloaded = !isLocal,
+ isBookmarked = chapter.id in bookmarked,
+ )
+ }
+ }
+ return result
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt
index 60f0cdd..e4ca197 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt
@@ -1,47 +1,78 @@
package org.xtimms.tokusho.sections.details
+import android.net.Uri
import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.ChipDefaults
+import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.outlined.Block
-import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DoneAll
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.FileDownload
+import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Language
+import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Upcoming
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.ChipColors
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.InputChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
@@ -54,29 +85,39 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage
+import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.AsyncImageImpl
+import org.xtimms.tokusho.core.components.AnimatedButton
+import org.xtimms.tokusho.core.components.ButtonType
import org.xtimms.tokusho.core.components.MangaCover
+import org.xtimms.tokusho.core.components.MangaHorizontalItem
+import org.xtimms.tokusho.core.components.ReadButton
+import org.xtimms.tokusho.core.parser.favicon.faviconUri
import org.xtimms.tokusho.ui.theme.TokushoTheme
+import org.xtimms.tokusho.ui.theme.applyOpacity
+import org.xtimms.tokusho.ui.theme.disabledIconOpacity
import org.xtimms.tokusho.utils.composable.clickableNoIndication
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
import kotlin.math.roundToInt
@@ -87,33 +128,42 @@ private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTIL
fun DetailsInfoBox(
coil: ImageLoader,
imageUrl: String,
+ favicon: Uri,
title: String,
- author: String?,
+ altTitle: String,
+ score: Float,
+ author: String,
artist: String?,
+ isNsfw: Boolean,
state: MangaState?,
+ source: MangaSource,
+ chapters: String?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
+ onCoverClick: () -> Unit,
+ isInShelf: Boolean,
+ onAddToShelfClicked: () -> Unit,
+ onSourceClicked: () -> Unit,
) {
- Box(modifier = modifier) {
+ Column(modifier = modifier) {
val backdropGradientColors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
- AsyncImage(
+ AsyncImageImpl(
+ coil = coil,
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
- .matchParentSize()
- .drawWithContent {
- drawContent()
- drawRect(
- brush = Brush.verticalGradient(colors = backdropGradientColors),
- )
- }
- .blur(2.dp)
- .alpha(0.2f)
+ .padding(start = 16.dp, end = 16.dp)
+ .aspectRatio(1f)
+ .clickable(
+ role = Role.Button,
+ onClick = onCoverClick
+ )
+ .clip(MaterialTheme.shapes.large)
)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
@@ -122,20 +172,37 @@ fun DetailsInfoBox(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
+ favicon = favicon,
title = title,
+ altTitle = altTitle,
+ score = score,
author = author,
artist = artist,
- state = state
+ isNsfw = isNsfw,
+ state = state,
+ source = source,
+ chapters = chapters,
+ isInShelf = isInShelf,
+ onAddToShelfClicked = onAddToShelfClicked,
+ onSourceClicked = onSourceClicked
)
} else {
MangaAndSourceTitlesLarge(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
+ favicon = favicon,
title = title,
+ altTitle = altTitle,
+ score = score,
author = author,
artist = artist,
- state = state
+ isNsfw = isNsfw,
+ state = state,
+ source = source,
+ isInShelf = isInShelf,
+ onAddToShelfClicked = onAddToShelfClicked,
+ onSourceClicked = onSourceClicked
)
}
}
@@ -147,10 +214,18 @@ private fun MangaAndSourceTitlesLarge(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
+ favicon: Uri,
title: String,
- author: String?,
+ altTitle: String,
+ score: Float,
+ author: String,
artist: String?,
- state: MangaState?
+ isNsfw: Boolean,
+ source: MangaSource,
+ state: MangaState?,
+ isInShelf: Boolean,
+ onAddToShelfClicked: () -> Unit,
+ onSourceClicked: () -> Unit,
) {
Column(
modifier = Modifier
@@ -166,9 +241,18 @@ private fun MangaAndSourceTitlesLarge(
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
+ coil = coil,
+ favicon = favicon,
title = title,
+ altTitle = altTitle,
+ score = score,
author = author,
- artist = artist,
+ isNsfw = isNsfw,
+ state = state,
+ source = source.title,
+ isInShelf = isInShelf,
+ onAddToShelfClicked = onAddToShelfClicked,
+ onSourceClicked = onSourceClicked
)
}
}
@@ -178,100 +262,304 @@ private fun MangaAndSourceTitlesSmall(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
+ favicon: Uri,
title: String,
- author: String?,
+ altTitle: String,
+ score: Float,
+ author: String,
artist: String?,
+ isNsfw: Boolean,
state: MangaState?,
+ source: MangaSource,
+ chapters: String?,
+ isInShelf: Boolean,
+ onAddToShelfClicked: () -> Unit,
+ onSourceClicked: () -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Row(
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
+ .padding(top = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
) {
- MangaCover.Book(
- coil = coil,
+ /*AsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
modifier = Modifier
- .sizeIn(maxWidth = 100.dp)
- .align(Alignment.Top),
- data = imageUrl,
- contentDescription = stringResource(R.string.manga_cover),
- )
- Column(
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- DetailsContentInfo(
- title = title,
- author = author,
- artist = artist,
- )
- }
- }
- Row {
- DetailsRow(
- source = "MangaDex",
- chapters = "22 chapters",
- state = state
+ .padding(PaddingValues(bottom = 8.dp))
+ .clip(RoundedCornerShape(100))
+ .size(48.dp),
+ )*/
+ DetailsContentInfo(
+ coil = coil,
+ favicon = favicon,
+ title = title,
+ altTitle = altTitle,
+ score = score,
+ author = author,
+ isNsfw = isNsfw,
+ state = state,
+ source = source.title,
+ isInShelf = isInShelf,
+ onAddToShelfClicked = onAddToShelfClicked,
+ onSourceClicked = onSourceClicked
)
}
}
-
}
+@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class,
+ ExperimentalMaterial3Api::class
+)
@Composable
-private fun ColumnScope.DetailsContentInfo(
+private fun DetailsContentInfo(
+ coil: ImageLoader,
+ favicon: Uri,
title: String,
- author: String?,
- artist: String?,
+ altTitle: String,
+ score: Float,
+ author: String,
+ isNsfw: Boolean,
+ state: MangaState?,
+ source: String?,
+ isInShelf: Boolean,
+ onAddToShelfClicked: () -> Unit,
+ onSourceClicked: () -> Unit,
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
) {
- val context = LocalContext.current
- Text(
- text = title.ifBlank { stringResource(id = R.string.unknown_title) },
- style = MaterialTheme.typography.headlineSmall,
- textAlign = textAlign
- )
-
- Spacer(modifier = Modifier.height(2.dp))
-
- Row(
- modifier = Modifier.secondaryItemAlpha(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Outlined.Person,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
- Text(
- text = author?.takeIf { it.isNotBlank() }
- ?: stringResource(id = R.string.unknown_author),
- style = MaterialTheme.typography.titleSmall,
- textAlign = textAlign
- )
- }
-
- if (!artist.isNullOrBlank() && author != artist) {
- Row(
- modifier = Modifier.secondaryItemAlpha(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ Row {
+ Column(
+ modifier = Modifier
+ .padding(end = 16.dp, start = 16.dp)
) {
- Icon(
- imageVector = Icons.Outlined.Brush,
- contentDescription = null,
- modifier = Modifier.size(16.dp)
- )
+ val sourceTitle = source?.takeIf { it.isNotBlank() }
+ ?: stringResource(id = R.string.unknown)
Text(
- text = artist,
- style = MaterialTheme.typography.titleSmall,
+ text = title.ifBlank { stringResource(id = R.string.unknown_title) },
+ style = MaterialTheme.typography.headlineLarge,
textAlign = textAlign,
+ lineHeight = 36.sp,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 3
)
+
+ if (altTitle.isNotBlank()) {
+ Text(
+ text = altTitle,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = textAlign,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 2
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ if (author.isNotEmpty()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(MaterialTheme.typography.titleLarge.fontSize.value.dp),
+ imageVector = Icons.Outlined.Person,
+ contentDescription = null
+ )
+ Text(
+ text = author,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = textAlign,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 2
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+
+ FlowRow(
+ modifier = Modifier.padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
+ InputChip(
+ selected = false,
+ onClick = { onAddToShelfClicked() },
+ label = {
+ Text(
+ text = if (isInShelf)
+ stringResource(id = R.string.in_shelf)
+ else
+ stringResource(id = R.string.add_to_shelf),
+ color = if (isInShelf)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ modifier = Modifier.size(18.dp),
+ imageVector = Icons.Outlined.LocalLibrary,
+ contentDescription = null,
+ tint = if (isInShelf)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.outline
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Outlined.KeyboardArrowDown,
+ contentDescription = null,
+ tint = if (isInShelf)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.outline
+ )
+ },
+ border = BorderStroke(
+ 1.dp,
+ if (isInShelf) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
+ )
+ )
+ AssistChip(
+ onClick = { onSourceClicked() },
+ leadingIcon = {
+ AsyncImageImpl(
+ coil = coil,
+ modifier = Modifier
+ .size(18.dp)
+ .clip(RoundedCornerShape(100)),
+ model = favicon,
+ contentScale = ContentScale.Crop,
+ contentDescription = null
+ )
+ },
+ label = { Text(text = sourceTitle) },
+ )
+ AssistChip(
+ onClick = { /*TODO*/ },
+ leadingIcon = {
+ Icon(
+ imageVector = when (state) {
+ MangaState.ONGOING -> Icons.Outlined.Schedule
+ MangaState.FINISHED -> Icons.Outlined.DoneAll
+ MangaState.ABANDONED -> Icons.Outlined.Close
+ MangaState.PAUSED -> Icons.Outlined.Pause
+ MangaState.UPCOMING -> Icons.Outlined.Upcoming
+ else -> Icons.Outlined.Block
+ },
+ contentDescription = null,
+ modifier = Modifier
+ .size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ },
+ label = {
+ Text(
+ text = when (state) {
+ MangaState.ONGOING -> stringResource(id = R.string.ongoing)
+ MangaState.FINISHED -> stringResource(id = R.string.finished)
+ MangaState.ABANDONED -> stringResource(id = R.string.abandoned)
+ MangaState.PAUSED -> stringResource(id = R.string.paused)
+ MangaState.UPCOMING -> stringResource(id = R.string.upcoming)
+ else -> stringResource(id = R.string.unknown)
+ },
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ },
+ )
+ if (isNsfw) {
+ AssistChip(
+ onClick = { /*TODO*/ },
+ leadingIcon = {
+ Icon(
+ modifier = Modifier.size(18.dp),
+ imageVector = Icons.Outlined.WarningAmber,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ label = { Text(text = "18+", color = MaterialTheme.colorScheme.error) },
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
+ )
+ }
+ OutlinedIconButton(
+ modifier = Modifier
+ .height(32.dp)
+ .width(56.dp),
+ onClick = { /*TODO*/ },
+ shape = MaterialTheme.shapes.small,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
+ ) {
+ val rotating by rememberInfiniteTransition("rotating").animateFloat(
+ label = "rotating",
+ initialValue = 360f,
+ targetValue = -360f,
+ animationSpec = infiniteRepeatable(tween(3000), RepeatMode.Restart)
+ )
+ Icon(
+ modifier = Modifier
+ .size(18.dp)
+ .rotate(rotating),
+ imageVector = Icons.Outlined.Sync,
+ contentDescription = "",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ReadButton()
+ /*FilledTonalButton(
+ modifier = Modifier
+ .height(54.dp)
+ .weight(1f),
+ onClick = { /*TODO*/ }
+ ) {
+ Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(id = R.string.read))
+ }*/
+
+ AnimatedButton(
+ modifier = Modifier
+ .size(54.dp),
+ type = ButtonType.TERTIARY,
+ icon = Icons.Outlined.FileDownload
+ )
+ }
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
}
+
+ /*Row(modifier = Modifier
+ .weight(.5f)
+ .padding(start = 4.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AnimatedButton(
+ modifier = Modifier.size(54.dp),
+ type = KeyboardButtonType.PRIMARY,
+ icon = Icons.Outlined.FavoriteBorder
+ )
+ AnimatedButton(
+ modifier = Modifier
+ .height(54.dp)
+ .fillMaxWidth(),
+ type = KeyboardButtonType.TERTIARY,
+ icon = Icons.Outlined.PlayArrow
+ )
+ }*/
}
}
@@ -366,23 +654,37 @@ private fun RowScope.DetailsRow(
fun ExpandableMangaDescription(
defaultExpandState: Boolean,
description: String?,
- tagsProvider: () -> List?,
+ tagsProvider: () -> Set?,
onTagSearch: (String) -> Unit,
onCopyTagToClipboard: (tag: String) -> Unit,
modifier: Modifier = Modifier,
) {
- Column(modifier = modifier) {
+ Column(modifier = modifier.padding(start = 16.dp, end = 8.dp)) {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(defaultExpandState)
}
val desc =
- description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder)
+ description.takeIf { !it.isNullOrBlank() }
+ ?: stringResource(R.string.description_placeholder)
val trimmedDescription = remember(desc) {
desc
.replace(whitespaceLineRegex, "\n")
.trimEnd()
}
val tags = tagsProvider()
+
+ Text(
+ text = stringResource(id = R.string.description),
+ style = MaterialTheme.typography.titleLarge
+ )
+ MangaSummary(
+ expandedDescription = desc,
+ shrunkDescription = trimmedDescription,
+ expanded = expanded,
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .clickableNoIndication { onExpanded(!expanded) },
+ )
if (!tags.isNullOrEmpty()) {
Box(
modifier = Modifier
@@ -410,13 +712,13 @@ fun ExpandableMangaDescription(
)
}
FlowRow(
- modifier = Modifier.padding(horizontal = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
tags.forEach {
TagsChip(
modifier = DefaultTagChipModifier,
- text = it.title,
+ tag = it,
onClick = {
tagSelected = it.title
showMenu = true
@@ -426,14 +728,7 @@ fun ExpandableMangaDescription(
}
}
}
- MangaSummary(
- expandedDescription = desc,
- shrunkDescription = trimmedDescription,
- expanded = expanded,
- modifier = Modifier
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .clickableNoIndication { onExpanded(!expanded) },
- )
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
}
}
@@ -481,7 +776,8 @@ private fun MangaSummary(
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
contentAlignment = Alignment.Center,
) {
- val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
+ val image =
+ AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = stringResource(
@@ -523,7 +819,7 @@ private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagsChip(
- text: String,
+ tag: MangaTag,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
@@ -531,7 +827,52 @@ private fun TagsChip(
SuggestionChip(
modifier = modifier,
onClick = onClick,
- label = { Text(text = text) },
+ label = { Text(text = tag.title) },
)
}
+}
+
+@Preview(showBackground = true, showSystemUi = true)
+@Composable
+fun DetailsInfoBoxPreview() {
+ TokushoTheme {
+ LazyColumn() {
+ item {
+ DetailsInfoBox(
+ coil = ImageLoader(LocalContext.current),
+ appBarPadding = 0.dp,
+ imageUrl = "",
+ favicon = MangaSource.MANGADEX.faviconUri(),
+ title = "Yofukashi no Uta",
+ altTitle = "よふかしのうた",
+ score = 3f,
+ author = "Kotoyama",
+ artist = null,
+ isNsfw = true,
+ state = null,
+ source = MangaSource.MANGADEX,
+ chapters = "22",
+ isTabletUi = false,
+ onCoverClick = {},
+ isInShelf = true,
+ onAddToShelfClicked = {},
+ onSourceClicked = {}
+ )
+ }
+ item {
+ ExpandableMangaDescription(
+ defaultExpandState = true,
+ description = "Test ".repeat(5),
+ tagsProvider = {
+ setOf(
+ MangaTag("Test", "1", MangaSource.DUMMY),
+ MangaTag("Test", "2", MangaSource.DUMMY)
+ )
+ },
+ onTagSearch = { },
+ onCopyTagToClipboard = { }
+ )
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
index 895aed8..89e84c2 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt
@@ -1,46 +1,101 @@
package org.xtimms.tokusho.sections.details
+import android.net.Uri
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Timelapse
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
+import kotlinx.coroutines.flow.collectLatest
+import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DetailsToolbar
-import org.xtimms.tokusho.core.components.PreferenceItem
-import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.components.MangaHorizontalItem
+import org.xtimms.tokusho.core.parser.favicon.faviconUri
+import org.xtimms.tokusho.utils.lang.toNavArgument
const val MANGA_ID_ARGUMENT = "{mangaId}"
const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailsView(
coil: ImageLoader,
+ mangaId: Long,
+ viewModel: DetailsViewModel = hiltViewModel(),
navigateBack: () -> Unit,
+ navigateToFullImage: (String) -> Unit,
+ navigateToDetails: (Long) -> Unit,
+ navigateToSource: (MangaSource) -> Unit
) {
val context = LocalContext.current
- val viewModel: DetailsViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
val chapterListState = rememberLazyListState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ var openCategoriesBottomSheet by rememberSaveable { mutableStateOf(false) }
+
+ val uriHandler = LocalUriHandler.current
+ fun openUrl(url: String) {
+ uriHandler.openUri(url)
+ }
+
+ val isChaptersEmpty by viewModel.isChaptersEmpty.collectAsStateWithLifecycle(false)
+ val chapters by viewModel.chapters.collectAsStateWithLifecycle(emptyList())
+ val relatedManga by viewModel.relatedManga.collectAsStateWithLifecycle(emptyList())
+ val readingTime by viewModel.readingTime.collectAsStateWithLifecycle(null)
+ val favouriteCategories by viewModel.favouriteCategories.collectAsStateWithLifecycle()
+
+ LaunchedEffect(mangaId) {
+ if (viewModel.details.value == null) viewModel.doLoad(mangaId)
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { e ->
+ when (e) {
+ DetailsViewModel.Event.InternalError ->
+ snackbarHostState.showSnackbar(context.getString(R.string.error_occured))
+ }
+ }
+ }
Scaffold(
topBar = {
@@ -59,22 +114,27 @@ fun DetailsView(
label = "Top Bar Background",
)
DetailsToolbar(
- title = uiState.details?.toManga()?.title ?: "Unknown",
+ title = viewModel.details.value?.toManga()?.title.orEmpty(),
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
- onBackClicked = { navigateBack() }
+ navigateBack = { navigateBack() },
+ navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
)
},
- bottomBar = {
-
- },
+ snackbarHost = {
+ SnackbarHost(
+ hostState = snackbarHostState
+ )
+ }
) { contentPadding ->
val topPadding = contentPadding.calculateTopPadding()
val layoutDirection = LocalLayoutDirection.current
+ val relatedMangaListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = PaddingValues(
+ top = contentPadding.calculateTopPadding() - 60.dp,
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding(),
@@ -86,30 +146,49 @@ fun DetailsView(
) {
DetailsInfoBox(
coil = coil,
- imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
- title = uiState.details?.toManga()?.title ?: "",
- author = uiState.details?.toManga()?.author ?: "",
+ imageUrl = viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty(),
+ favicon = viewModel.details.value?.toManga()?.source?.faviconUri() ?: Uri.EMPTY,
+ title = viewModel.details.value?.toManga()?.title.orEmpty(),
+ altTitle = viewModel.details.value?.toManga()?.altTitle.orEmpty(),
+ score = viewModel.details.value?.toManga()?.rating ?: RATING_UNKNOWN,
+ author = viewModel.details.value?.toManga()?.author.orEmpty(),
artist = "",
- state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
+ isNsfw = viewModel.details.value?.toManga()?.isNsfw ?: true,
+ state = viewModel.details.value?.toManga()?.state ?: MangaState.FINISHED,
+ source = viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY,
+ chapters = chapters.size.toString(),
isTabletUi = false,
appBarPadding = topPadding,
+ onCoverClick = {
+ navigateToFullImage(
+ arrayOf(
+ viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty()
+ ).toNavArgument()
+ )
+ },
+ isInShelf = favouriteCategories,
+ onAddToShelfClicked = {
+ openCategoriesBottomSheet = !openCategoriesBottomSheet
+ },
+ onSourceClicked = {
+ navigateToSource(
+ viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY
+ )
+ }
)
}
- val time = viewModel.readingTime.value
- if (AppSettings.isReadingTimeEstimationEnabled() || time == null) {
+ /*if (AppSettings.isReadingTimeEstimationEnabled() || readingTime != null) {
item {
- if (time != null) {
PreferenceItem(
- title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource(
+ title = if (readingTime?.isContinue == true) stringResource(id = R.string.approximate_remaining_time) else stringResource(
id = R.string.approximate_reading_time
),
- description = time.format(context.resources),
+ description = readingTime?.format(context.resources),
icon = Icons.Outlined.Timelapse
)
- }
}
- }
+ }*/
item(
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
@@ -117,13 +196,78 @@ fun DetailsView(
) {
ExpandableMangaDescription(
defaultExpandState = true,
- description = uiState.details?.toManga()?.description ?: "",
- tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
+ description = viewModel.details.value?.toManga()?.description,
+ tagsProvider = { viewModel.details.value?.toManga()?.tags },
onTagSearch = { },
onCopyTagToClipboard = { },
)
}
+
+ item {
+ Column {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp),
+ text = stringResource(id = R.string.related_manga),
+ style = MaterialTheme.typography.titleLarge
+ )
+ LazyRow(
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .sizeIn(minHeight = 100.dp),
+ state = relatedMangaListState,
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ flingBehavior = rememberSnapFlingBehavior(lazyListState = relatedMangaListState)
+ ) {
+ items(
+ items = relatedManga,
+ key = { it.id },
+ contentType = { it }
+ ) {
+ MangaHorizontalItem(
+ coil = coil,
+ manga = it,
+ onClick = { navigateToDetails(it.id) },
+ onLongClick = { })
+ }
+ }
+ HorizontalDivider(modifier = Modifier.padding(16.dp))
+ }
+ }
+
+ item {
+ Text(
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
+ text = stringResource(id = R.string.chapters),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+
+ items(
+ items = chapters
+ ) {
+ ChapterListItem(
+ title = it.chapter.name,
+ date = it.chapter.uploadDate,
+ scanlator = it.chapter.scanlator,
+ read = it.isUnread,
+ bookmark = false,
+ selected = false,
+ onLongClick = { /*TODO*/ },
+ onClick = { /*TODO*/ }
+ )
+ }
}
}
+ if (openCategoriesBottomSheet) {
+ val windowInsets = WindowInsets(0)
+
+ ModalBottomSheet(
+ onDismissRequest = { openCategoriesBottomSheet = false },
+ windowInsets = windowInsets
+ ) {
+ Text(text = "Hello MBS")
+ Spacer(modifier = Modifier.height(1000.dp))
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
index 9156e55..409337c 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt
@@ -4,61 +4,153 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.parsers.model.Manga
-import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.core.model.findById
+import org.xtimms.tokusho.core.model.getPreferredBranch
import org.xtimms.tokusho.core.parser.MangaIntent
+import org.xtimms.tokusho.data.repository.BookmarksRepository
+import org.xtimms.tokusho.data.repository.FavouritesRepository
import org.xtimms.tokusho.data.repository.HistoryRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
+import org.xtimms.tokusho.sections.details.domain.BranchComparator
+import org.xtimms.tokusho.sections.details.domain.DetailsInteractor
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
+import org.xtimms.tokusho.sections.details.domain.RelatedMangaUseCase
+import org.xtimms.tokusho.sections.details.model.ChapterItem
+import org.xtimms.tokusho.sections.details.model.HistoryInfo
+import org.xtimms.tokusho.sections.details.model.MangaBranch
import org.xtimms.tokusho.utils.lang.onEachWhile
+import org.xtimms.tokusho.utils.lang.removeFirstAndLast
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
+ private val interactor: DetailsInteractor,
private val historyRepository: HistoryRepository,
+ private val bookmarksRepository: BookmarksRepository,
+ private val favouritesRepository: FavouritesRepository,
private val detailsLoadUseCase: DetailsLoadUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
-) : BaseViewModel(), DetailsEvent {
+ private val relatedMangaUseCase: RelatedMangaUseCase,
+) : KotatsuBaseViewModel() {
- override val mutableUiState = MutableStateFlow(DetailsUiState())
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+ private var loadingJob: Job
+ private val mangaId = savedStateHandle.get(MANGA_ID_ARGUMENT.removeFirstAndLast())!!
private val intent = MangaIntent(savedStateHandle)
- private val mangaId = intent.id
- private var loadingJob: Job
+ var details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
- val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
- val mangaD = details.map { x -> x?.toManga() }
+ val manga = details.map { x -> x?.toManga() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+ val favouriteCategories = interactor.observeIsFavourite(mangaId)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
+
val remoteManga = MutableStateFlow(null)
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val newChaptersCount = details.flatMapLatest { d ->
+ flowOf(0)
+ }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
+
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow(null)
- @Deprecated("")
- val description = details
- .map { it?.description }
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
+ val historyInfo: StateFlow = combine(
+ manga,
+ selectedBranch,
+ history,
+ ) { m, b, h ->
+ HistoryInfo(m, b, h)
+ }.stateIn(
+ scope = viewModelScope + Dispatchers.Default,
+ started = SharingStarted.Eagerly,
+ initialValue = HistoryInfo(null, null, null),
+ )
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val bookmarks = manga.flatMapLatest {
+ if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
+ }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val relatedManga: StateFlow> = manga.mapLatest {
+ if (it != null) {
+ relatedMangaUseCase.invoke(it).orEmpty()
+ } else {
+ emptyList()
+ }
+ }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ val branches: StateFlow> = combine(
+ details,
+ selectedBranch,
+ history,
+ ) { m, b, h ->
+ val c = m?.chapters
+ if (c.isNullOrEmpty()) {
+ return@combine emptyList()
+ }
+ val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
+ c.map { x ->
+ MangaBranch(
+ name = x.key,
+ count = x.value.size,
+ isSelected = x.key == b,
+ isCurrent = h != null && x.key == currentBranch,
+ )
+ }.sortedWith(BranchComparator())
+ }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow = details.map {
it != null && it.isLoaded && it.allChapters.isEmpty()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
+ val chapters = combine(
+ combine(
+ details,
+ history,
+ selectedBranch,
+ newChaptersCount,
+ bookmarks,
+ ) { manga, history, branch, news, bookmarks ->
+ manga?.mapChapters(
+ history,
+ news,
+ branch,
+ bookmarks,
+ ).orEmpty()
+ },
+ chaptersQuery,
+ ) { list, query ->
+ list.filterSearch(query)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
val readingTime = combine(
details,
selectedBranch,
@@ -67,29 +159,41 @@ class DetailsViewModel @Inject constructor(
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
- init {
- loadingJob = doLoad()
- }
+ val selectedBranchValue: String?
+ get() = selectedBranch.value
- fun reload() {
- loadingJob.cancel()
- loadingJob = doLoad()
+ init {
+ loadingJob = doLoad(mangaId)
}
- private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
- detailsLoadUseCase.invoke(mangaId ?: 0L)
+ fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) {
+ detailsLoadUseCase.invoke(mangaId)
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
+ val manga = it.toManga()
+ // find default branch
+ val hist = historyRepository.getOne(manga)
+ selectedBranch.value = manga.getPreferredBranch(hist)
true
+ }.catch { error ->
+ _events.send(Event.InternalError)
}.collect {
- //details.value = it
- mutableUiState.update {
- it.copy(
- details = details.value
- )
- }
+ details.value = it
}
}
+
+ private fun List.filterSearch(query: String): List {
+ if (query.isEmpty() || this.isEmpty()) {
+ return this
+ }
+ return filter {
+ it.chapter.name.contains(query, ignoreCase = true)
+ }
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt
index 0d39b3a..ecb5bb5 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt
@@ -29,8 +29,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import coil.ImageLoader
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
+import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.core.components.BackIconButton
import org.xtimms.tokusho.core.components.ViewInBrowserButton
import org.xtimms.tokusho.ui.theme.TokushoTheme
@@ -41,6 +43,7 @@ const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun FullImageView(
+ coil: ImageLoader,
pictures: Array,
navigateBack: () -> Unit,
) {
@@ -86,7 +89,8 @@ fun FullImageView(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
- AsyncImage(
+ AsyncImageImpl(
+ coil = coil,
model = pictures[page],
contentDescription = "image$page",
modifier = Modifier.fillMaxSize(),
@@ -101,21 +105,23 @@ fun FullImageView(
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
- pictures.forEachIndexed { index, _ ->
- val color =
- if (pagerState.currentPage == index)
- MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.primaryContainer
- Box(
- modifier = Modifier
- .padding(4.dp)
- .clip(CircleShape)
- .background(color)
- .size(8.dp)
- .clickable {
- coroutineScope.launch { pagerState.animateScrollToPage(index) }
- }
- )
+ if (pictures.size > 1) {
+ pictures.forEachIndexed { index, _ ->
+ val color =
+ if (pagerState.currentPage == index)
+ MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.primaryContainer
+ Box(
+ modifier = Modifier
+ .padding(4.dp)
+ .clip(CircleShape)
+ .background(color)
+ .size(8.dp)
+ .clickable {
+ coroutineScope.launch { pagerState.animateScrollToPage(index) }
+ }
+ )
+ }
}
}
}
@@ -127,6 +133,7 @@ fun FullImageView(
fun FullPosterPreview() {
TokushoTheme {
FullImageView(
+ coil = ImageLoader(LocalContext.current),
pictures = arrayOf("", ""),
navigateBack = {}
)
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt
index 8739961..839498a 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt
@@ -2,9 +2,12 @@ package org.xtimms.tokusho.sections.details.data
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.xtimms.tokusho.core.model.LocalManga
+import org.xtimms.tokusho.core.model.isLocal
data class MangaDetails(
private val manga: Manga,
+ private val localManga: LocalManga?,
val description: CharSequence?,
val isLoaded: Boolean,
) {
@@ -19,13 +22,44 @@ data class MangaDetails(
val allChapters: List by lazy { listOf() }
+ val isLocal
+ get() = manga.isLocal
+
+ val local: LocalManga?
+ get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
+
fun toManga() = manga
fun filterChapters(branch: String?) = MangaDetails(
manga = manga.filterChapters(branch),
+ localManga = localManga?.run {
+ copy(manga = manga.filterChapters(branch))
+ },
description = description,
isLoaded = isLoaded,
)
+
+ private fun mergeChapters(): List {
+ val chapters = manga.chapters
+ val localChapters = local?.manga?.chapters.orEmpty()
+ if (chapters.isNullOrEmpty()) {
+ return localChapters
+ }
+ val localMap = if (localChapters.isNotEmpty()) {
+ localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
+ } else {
+ null
+ }
+ val result = ArrayList(chapters.size)
+ for (chapter in chapters) {
+ val local = localMap?.remove(chapter.id)
+ result += local ?: chapter
+ }
+ if (!localMap.isNullOrEmpty()) {
+ result.addAll(localMap.values)
+ }
+ return result
+ }
}
fun Manga.filterChapters(branch: String?): Manga {
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt
new file mode 100644
index 0000000..af79ade
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt
@@ -0,0 +1,8 @@
+package org.xtimms.tokusho.sections.details.domain
+
+import org.xtimms.tokusho.sections.details.model.MangaBranch
+
+class BranchComparator : Comparator {
+
+ override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt
new file mode 100644
index 0000000..516d137
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt
@@ -0,0 +1,25 @@
+package org.xtimms.tokusho.sections.details.domain
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.data.repository.FavouritesRepository
+import org.xtimms.tokusho.data.repository.HistoryRepository
+import javax.inject.Inject
+
+class DetailsInteractor @Inject constructor(
+ private val historyRepository: HistoryRepository,
+ private val favouritesRepository: FavouritesRepository,
+) {
+
+ fun observeIsFavourite(mangaId: Long): Flow {
+ return favouritesRepository.observeCategoriesIds(mangaId)
+ .map { it.isNotEmpty() }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
index d9f8881..b90e0aa 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt
@@ -7,14 +7,19 @@ import android.text.style.ForegroundColorSpan
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.core.model.isLocal
import org.xtimms.tokusho.core.parser.MangaDataRepository
+import org.xtimms.tokusho.core.parser.MangaIntent
import org.xtimms.tokusho.core.parser.MangaRepository
+import org.xtimms.tokusho.core.parser.local.LocalMangaRepository
import org.xtimms.tokusho.sections.details.data.MangaDetails
+import org.xtimms.tokusho.utils.lang.peek
import org.xtimms.tokusho.utils.lang.sanitize
import java.io.IOException
import javax.inject.Inject
@@ -22,6 +27,7 @@ import javax.inject.Inject
class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val mangaDataRepository: MangaDataRepository,
+ private val localMangaRepository: LocalMangaRepository,
private val imageGetter: Html.ImageGetter,
) {
@@ -29,11 +35,18 @@ class DetailsLoadUseCase @Inject constructor(
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
"Cannot resolve id $mangaId"
}
- send(MangaDetails(manga, null, false))
+ val local = if (!manga.isLocal) {
+ async {
+ localMangaRepository.findSavedManga(manga)
+ }
+ } else {
+ null
+ }
+ send(MangaDetails(manga, null, null, false))
try {
val details = getDetails(manga)
- send(MangaDetails(details, details.description?.parseAsHtml(withImages = false), false))
- send(MangaDetails(details, details.description?.parseAsHtml(withImages = true), true))
+ send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
+ send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) {
throw e
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt
new file mode 100644
index 0000000..a71066e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt
@@ -0,0 +1,17 @@
+package org.xtimms.tokusho.sections.details.domain
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.core.parser.MangaRepository
+import javax.inject.Inject
+
+class RelatedMangaUseCase @Inject constructor(
+ private val mangaRepositoryFactory: MangaRepository.Factory,
+) {
+
+ suspend operator fun invoke(seed: Manga) = runCatchingCancellable {
+ mangaRepositoryFactory.create(seed.source).getRelated(seed)
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt
new file mode 100644
index 0000000..305806c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt
@@ -0,0 +1,94 @@
+package org.xtimms.tokusho.sections.details.model
+
+import android.text.format.DateUtils
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.xtimms.tokusho.core.model.ListModel
+import org.xtimms.tokusho.core.model.formatNumber
+import org.jsoup.internal.StringUtil.StringJoiner
+
+data class ChapterItem(
+ val chapter: MangaChapter,
+ val flags: Int,
+ private val uploadDateMs: Long,
+) : ListModel {
+
+ var description: String? = null
+ private set
+ get() {
+ if (field != null) return field
+ field = buildDescription()
+ return field
+ }
+
+ var uploadDate: CharSequence? = null
+ private set
+ get() {
+ if (field != null) return field
+ if (uploadDateMs == 0L) return null
+ field = DateUtils.getRelativeTimeSpanString(
+ uploadDateMs,
+ System.currentTimeMillis(),
+ DateUtils.DAY_IN_MILLIS,
+ )
+ return field
+ }
+
+ val isCurrent: Boolean
+ get() = hasFlag(FLAG_CURRENT)
+
+ val isUnread: Boolean
+ get() = hasFlag(FLAG_UNREAD)
+
+ val isDownloaded: Boolean
+ get() = hasFlag(FLAG_DOWNLOADED)
+
+ val isBookmarked: Boolean
+ get() = hasFlag(FLAG_BOOKMARKED)
+
+ val isNew: Boolean
+ get() = hasFlag(FLAG_NEW)
+
+ private fun buildDescription(): String {
+ val joiner = StringJoiner(" • ")
+ chapter.formatNumber()?.let {
+ joiner.add("#").append(it)
+ }
+ uploadDate?.let { date ->
+ joiner.add(date.toString())
+ }
+ chapter.scanlator?.let { scanlator ->
+ if (scanlator.isNotBlank()) {
+ joiner.add(scanlator)
+ }
+ }
+ return joiner.complete()
+ }
+
+ private fun hasFlag(flag: Int): Boolean {
+ return (flags and flag) == flag
+ }
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is ChapterItem && chapter.id == other.chapter.id
+ }
+
+ override fun getChangePayload(previousState: ListModel): Any? {
+ if (previousState !is ChapterItem) {
+ return super.getChangePayload(previousState)
+ }
+ return if (chapter == previousState.chapter && flags != previousState.flags) {
+ flags
+ } else {
+ super.getChangePayload(previousState)
+ }
+ }
+
+ companion object {
+
+ const val FLAG_UNREAD = 2
+ const val FLAG_CURRENT = 4
+ const val FLAG_NEW = 8
+ const val FLAG_BOOKMARKED = 16
+ const val FLAG_DOWNLOADED = 32
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt
new file mode 100644
index 0000000..73dc6de
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt
@@ -0,0 +1,30 @@
+package org.xtimms.tokusho.sections.details.model
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.tokusho.core.model.MangaHistory
+
+data class HistoryInfo(
+ val totalChapters: Int,
+ val currentChapter: Int,
+ val history: MangaHistory?,
+) {
+ val isValid: Boolean
+ get() = totalChapters >= 0
+}
+
+fun HistoryInfo(
+ manga: Manga?,
+ branch: String?,
+ history: MangaHistory?,
+): HistoryInfo {
+ val chapters = manga?.getChapters(branch)
+ return HistoryInfo(
+ totalChapters = chapters?.size ?: -1,
+ currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
+ chapters.indexOfFirst { it.id == history.chapterId }
+ } else {
+ -1
+ },
+ history = history,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt
new file mode 100644
index 0000000..96e81a1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt
@@ -0,0 +1,28 @@
+package org.xtimms.tokusho.sections.details.model
+
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_BOOKMARKED
+import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_CURRENT
+import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_DOWNLOADED
+import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_NEW
+import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_UNREAD
+
+fun MangaChapter.toListItem(
+ isCurrent: Boolean,
+ isUnread: Boolean,
+ isNew: Boolean,
+ isDownloaded: Boolean,
+ isBookmarked: Boolean,
+): ChapterItem {
+ var flags = 0
+ if (isCurrent) flags = flags or FLAG_CURRENT
+ if (isUnread) flags = flags or FLAG_UNREAD
+ if (isNew) flags = flags or FLAG_NEW
+ if (isBookmarked) flags = flags or FLAG_BOOKMARKED
+ if (isDownloaded) flags = flags or FLAG_DOWNLOADED
+ return ChapterItem(
+ chapter = this,
+ flags = flags,
+ uploadDateMs = uploadDate,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt
new file mode 100644
index 0000000..c1916ba
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt
@@ -0,0 +1,19 @@
+package org.xtimms.tokusho.sections.details.model
+
+import org.xtimms.tokusho.core.model.ListModel
+
+data class MangaBranch(
+ val name: String?,
+ val count: Int,
+ val isSelected: Boolean,
+ val isCurrent: Boolean,
+) : ListModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is MangaBranch && other.name == name
+ }
+
+ override fun toString(): String {
+ return "$name: $count"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
index a83e42b..ca30fc7 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt
@@ -2,76 +2,68 @@ package org.xtimms.tokusho.sections.explore
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.ExtensionOff
import androidx.compose.material.icons.outlined.SdStorage
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
-import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ExploreButton
+import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.SourceItem
import org.xtimms.tokusho.core.components.icons.Dice
-import org.xtimms.tokusho.core.parser.favicon.faviconUri
-import org.xtimms.tokusho.utils.system.toast
+import org.xtimms.tokusho.ui.theme.TokushoTheme
const val EXPLORE_DESTINATION = "explore"
@Composable
fun ExploreView(
+ viewModel: ExploreViewModel = hiltViewModel(),
coil: ImageLoader,
- navigateToSource: (MangaSource) -> Unit,
- topBarHeightPx: Float,
- listState: LazyGridState,
- padding: PaddingValues,
-) {
- val viewModel: ExploreViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
- ExploreViewContent(
- coil = coil,
- navigateToSource = navigateToSource,
- uiState = uiState,
- event = viewModel,
- topBarHeightPx = topBarHeightPx,
- listState = listState,
- padding = padding
- )
-}
-
-@Composable
-fun ExploreViewContent(
- coil: ImageLoader,
- navigateToSource: (MangaSource) -> Unit,
- uiState: ExploreUiState,
- event: ExploreEvent?,
+ navigateToSource: (SourceItemModel) -> Unit,
nestedScrollConnection: NestedScrollConnection? = null,
topBarHeightPx: Float = 0f,
topBarOffsetY: Animatable = Animatable(0f),
@@ -82,12 +74,7 @@ fun ExploreViewContent(
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
- if (uiState.message != null) {
- LaunchedEffect(uiState.message) {
- context.toast(uiState.message)
- event?.onMessageDisplayed()
- }
- }
+ val sources = viewModel.content.collectAsStateWithLifecycle(emptyList())
Box(
modifier = Modifier
@@ -104,7 +91,7 @@ fun ExploreViewContent(
else Modifier
)
LazyVerticalGrid(
- columns = GridCells.Adaptive(minSize = 90.dp),
+ columns = GridCells.Fixed(4),
modifier = listModifier,
state = listState,
contentPadding = PaddingValues(
@@ -154,9 +141,71 @@ fun ExploreViewContent(
)
}
}
+ item(
+ span = { GridItemSpan(maxCurrentLineSpan) }
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .clip(MaterialTheme.shapes.extraLarge),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp)
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ text = "Рекомендации",
+ style = MaterialTheme.typography.labelLarge
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Image(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(72.dp))
+ .aspectRatio(1f),
+ contentScale = ContentScale.Crop,
+ painter = painterResource(id = R.drawable.ookami),
+ contentDescription = ""
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = "Text",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Text",
+ modifier = Modifier.padding(vertical = 2.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Button(
+ onClick = {},
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = "More")
+ }
+ }
+ }
+ }
items(
- items = uiState.sources,
- key = { it.name },
+ items = sources.value,
+ key = { it.id },
contentType = { it }
) { item ->
Box(
@@ -165,7 +214,7 @@ fun ExploreViewContent(
) {
SourceItem(
coil = coil,
- faviconUrl = item.faviconUri(),
+ faviconUrl = item.favicon,
title = item.title
) {
navigateToSource(item)
@@ -174,4 +223,23 @@ fun ExploreViewContent(
}
}
}
+}
+
+@PreviewLightDark
+@Composable
+fun RecommendationPreview() {
+ TokushoTheme {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(id = R.string.manga_sources),
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.labelLarge
+ )
+ TextButton(onClick = { /*TODO*/ }) {
+ Text(text = stringResource(id = R.string.catalog))
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt
index 0c10a17..429bec5 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt
@@ -3,34 +3,51 @@ package org.xtimms.tokusho.sections.explore
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.plus
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.core.parser.favicon.faviconUri
+import org.xtimms.tokusho.data.repository.ExploreRepository
import org.xtimms.tokusho.data.repository.MangaSourcesRepository
+import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
+ private val exploreRepository: ExploreRepository,
private val mangaSourcesRepository: MangaSourcesRepository,
-) : BaseViewModel(), ExploreEvent {
+) : KotatsuBaseViewModel() {
- override val mutableUiState = MutableStateFlow(
- ExploreUiState(
- isLoading = true,
- )
- )
+ private val sourcesStateFlow = mangaSourcesRepository.observeEnabledSources()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- init {
- launchJob(Dispatchers.Default) {
- val result = mangaSourcesRepository.allMangaSources
- mutableUiState.update {
- it.copy(
- sources = result.toList(),
- )
- }
- setLoading(false)
- }
+ val content = sourcesStateFlow
+ .filterNotNull()
+ .mapItems { SourceItemModel(it.ordinal, it.name, it.title, it.faviconUri()) }
+ .distinctUntilChanged()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+
+ private fun createContentFlow() = combine(
+ mangaSourcesRepository.observeEnabledSources(),
+ mangaSourcesRepository.observeNewSources(),
+ ) { content, newSources ->
+ buildList(content, newSources)
+ }
+
+ private fun buildList(
+ sources: List,
+ newSources: Set,
+ ): List {
+ val result = ArrayList(sources.size + 3)
+ return result
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt
new file mode 100644
index 0000000..3bec067
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt
@@ -0,0 +1,16 @@
+package org.xtimms.tokusho.sections.explore
+
+import android.net.Uri
+import org.xtimms.tokusho.core.model.ListModel
+
+data class SourceItemModel(
+ val id: Int,
+ val name: String,
+ val title: String,
+ val favicon: Uri
+) : ListModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is SourceItemModel && other.id == id
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt
new file mode 100644
index 0000000..1daf28e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt
@@ -0,0 +1,12 @@
+package org.xtimms.tokusho.sections.explore.data
+
+import androidx.annotation.StringRes
+import org.xtimms.tokusho.R
+
+enum class SourcesSortOrder(
+ @StringRes val titleResId: Int,
+) {
+ ALPHABETIC(R.string.by_name),
+ POPULARITY(R.string.popular),
+ MANUAL(R.string.manual),
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt
new file mode 100644
index 0000000..8c692ee
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt
@@ -0,0 +1,52 @@
+package org.xtimms.tokusho.sections.feed
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ClearAll
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
+
+const val FEED_DESTINATION = "feed"
+
+@Composable
+fun FeedView(
+ navigateBack: () -> Unit,
+ navigateToShelf: () -> Unit,
+) {
+ rememberScrollState()
+
+ ScaffoldWithClassicTopAppBar(
+ title = stringResource(R.string.feed),
+ navigateBack = navigateBack,
+ actions = {
+ IconButton(onClick = { navigateToShelf() }) {
+ Icon(imageVector = Icons.Outlined.Tune, contentDescription = null)
+ }
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(onClick = { /*TODO*/ }
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ClearAll,
+ contentDescription = "Clear all"
+ )
+ Text(
+ text = stringResource(R.string.clear_all),
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp)
+ )
+ }
+ }
+ ) { padding ->
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt
index c164119..c8ce003 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -47,7 +49,11 @@ fun HistoryViewContent(
)
.padding(padding)
) {
- EmptyScreen(title = R.string.nothing_here)
+ EmptyScreen(
+ icon = Icons.Outlined.History,
+ title = R.string.empty_history_title,
+ description = R.string.empty_history_description
+ )
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
index 6216eb8..00a827d 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt
@@ -1,14 +1,18 @@
package org.xtimms.tokusho.sections.list
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
@@ -30,7 +34,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.tokusho.core.components.MangaCompactGridItem
+import org.xtimms.tokusho.core.components.MangaGridItem
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBarWithChips
import org.xtimms.tokusho.utils.composable.onBottomReached
import org.xtimms.tokusho.utils.system.toast
@@ -78,7 +82,16 @@ private fun MangaListViewContent(
ScaffoldWithSmallTopAppBarWithChips(
title = source.title,
- chips = listOf("Chip 1", "Chip 2", "Chip 3", "Chip 4", "Chip 1", "Chip 2", "Chip 3", "Chip 4"),
+ chips = listOf(
+ "Chip 1",
+ "Chip 2",
+ "Chip 3",
+ "Chip 4",
+ "Chip 1",
+ "Chip 2",
+ "Chip 3",
+ "Chip 4"
+ ),
navigateBack = navigateBack,
contentWindowInsets = WindowInsets.systemBars
.only(WindowInsetsSides.Horizontal)
@@ -87,45 +100,59 @@ private fun MangaListViewContent(
listState.onBottomReached(buffer = 5) {
event?.loadMore()
}
- Column(
+ Box(
modifier = Modifier
.padding(padding),
- horizontalAlignment = Alignment.CenterHorizontally
+ contentAlignment = Alignment.Center
) {
- if (!uiState.isLoading) LazyVerticalGrid(
- columns = GridCells.Adaptive(minSize = 100.dp),
- state = listState,
- modifier = Modifier.fillMaxHeight(),
- contentPadding = PaddingValues(
- start = 8.dp,
- top = 8.dp,
- end = 8.dp,
- bottom = WindowInsets.navigationBars.asPaddingValues()
- .calculateBottomPadding()
- ),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ AnimatedVisibility(
+ visible = uiState.isLoading,
+ exit = fadeOut(),
) {
- items(
- items = uiState.manga,
- key = { it.id },
- contentType = { it }
- ) { item ->
- Box(
- modifier = Modifier.fillMaxWidth(),
- contentAlignment = Alignment.TopCenter
- ) {
- MangaCompactGridItem(
- coil = coil,
- imageUrl = item.coverUrl,
- title = item.title,
- onClick = { navigateToDetails(item.id) },
- onLongClick = { },
- )
+ CircularProgressIndicator()
+ }
+ AnimatedVisibility(
+ visible = !uiState.isLoading,
+ enter = slideInVertically(tween(500)) { 64 } + fadeIn(),
+ exit = fadeOut()
+ ) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 100.dp),
+ state = listState,
+ modifier = Modifier.fillMaxHeight(),
+ contentPadding = PaddingValues(
+ start = 8.dp,
+ top = 8.dp,
+ end = 8.dp,
+ bottom = WindowInsets.navigationBars.asPaddingValues()
+ .calculateBottomPadding()
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(
+ 8.dp,
+ Alignment.CenterHorizontally
+ ),
+ ) {
+ items(
+ items = uiState.manga,
+ key = { it.id },
+ contentType = { it }
+ ) { item ->
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ MangaGridItem(
+ coil = coil,
+ manga = item,
+ onClick = {
+ navigateToDetails(item.id)
+ },
+ onLongClick = { },
+ )
+ }
}
}
- } else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
}
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt
index be7269b..7b439b5 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.SearchOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -102,7 +104,11 @@ fun SearchView(
) {
val context = LocalContext.current
- EmptyScreen(title = R.string.nothing_here)
+ EmptyScreen(
+ icon = Icons.Outlined.SearchOff,
+ title = R.string.nothing_found,
+ description = R.string.nothing_found_summary
+ )
}
@Preview(showBackground = true)
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
index b045bb4..4237d22 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt
@@ -22,12 +22,15 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BatterySaver
import androidx.compose.material.icons.outlined.Code
+import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material.icons.outlined.Palette
+import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Storage
+import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -41,60 +44,30 @@ import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferencesHintCard
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem
-import org.xtimms.tokusho.sections.settings.storage.StorageEvent
-import org.xtimms.tokusho.sections.settings.storage.StorageUiState
-import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
import org.xtimms.tokusho.utils.FileSize
-import org.xtimms.tokusho.utils.system.toast
const val SETTINGS_DESTINATION = "settings"
-@Composable
-fun SettingsView(
- navigateBack: () -> Unit,
- navigateToAppearance: () -> Unit,
- navigateToAbout: () -> Unit,
- navigateToAdvanced: () -> Unit,
- navigateToShelfSettings: () -> Unit,
- navigateToStorage: () -> Unit
-) {
-
- val viewModel: StorageViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
- SettingsViewContent(
- uiState = uiState,
- event = viewModel,
- navigateBack = navigateBack,
- navigateToAppearance = navigateToAppearance,
- navigateToAbout = navigateToAbout,
- navigateToAdvanced = navigateToAdvanced,
- navigateToShelfSettings = navigateToShelfSettings,
- navigateToStorage = navigateToStorage
- )
-}
-
@SuppressLint("BatteryLife")
@Composable
-private fun SettingsViewContent(
- uiState: StorageUiState,
- event: StorageEvent?,
+fun SettingsView(
+ viewModel: SettingsViewModel = hiltViewModel(),
navigateBack: () -> Unit,
navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit,
+ navigateToBackupRestoreSettings: () -> Unit,
+ navigateToMangaSources: () -> Unit,
+ navigateToNetwork: () -> Unit,
navigateToShelfSettings: () -> Unit,
navigateToStorage: () -> Unit
) {
val context = LocalContext.current
- if (uiState.message != null) {
- LaunchedEffect(uiState.message) {
- context.toast(uiState.message)
- event?.onMessageDisplayed()
- }
- }
+ val state by viewModel.viewStateFlow.collectAsState()
+ val total = viewModel.totalSourcesCount
+ val enabled = viewModel.enabledSourcesCount.collectAsStateWithLifecycle()
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
var showBatteryHint by remember {
@@ -155,6 +128,18 @@ private fun SettingsViewContent(
onClick = navigateToAppearance
)
}
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.manga_sources),
+ description = if (enabled.value >= 0) stringResource(
+ id = R.string.enabled_d_of_d,
+ enabled.value,
+ total
+ ) else context.resources.getQuantityString(R.plurals.items, total, total),
+ icon = Icons.Outlined.CollectionsBookmark,
+ onClick = navigateToMangaSources
+ )
+ }
item {
SettingItem(
title = stringResource(id = R.string.nav_shelf),
@@ -164,26 +149,42 @@ private fun SettingsViewContent(
)
}
item {
- val allCaches = uiState.httpCacheSize +
- uiState.pagesCache +
- uiState.thumbnailsCache
+ SettingItem(
+ title = stringResource(id = R.string.backup_and_restore),
+ description = "TODO",
+ icon = Icons.Outlined.SettingsBackupRestore,
+ onClick = navigateToBackupRestoreSettings
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.network),
+ description = stringResource(id = R.string.network_page),
+ icon = Icons.Outlined.Wifi,
+ onClick = navigateToNetwork
+ )
+ }
+ item {
+ val allCaches = state.httpCacheSize +
+ state.pagesCache +
+ state.thumbnailsCache
val desc = buildString {
- append((allCaches / uiState.availableSpace) * 100)
+ append((allCaches / state.availableSpace) * 100)
append(context.getString(R.string.space_used))
append(" - ")
append(
FileSize.BYTES.freeFormat(
context,
- (uiState.availableSpace -
- uiState.httpCacheSize -
- uiState.pagesCache -
- uiState.thumbnailsCache).toFloat()
+ (state.availableSpace -
+ state.httpCacheSize -
+ state.pagesCache -
+ state.thumbnailsCache).toFloat()
)
)
}
SettingItem(
title = stringResource(id = R.string.storage),
- description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc,
+ description = desc,
icon = Icons.Outlined.Storage,
onClick = navigateToStorage
)
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..8f6d7c1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt
@@ -0,0 +1,58 @@
+package org.xtimms.tokusho.sections.settings
+
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.runInterruptible
+import okhttp3.Cache
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.core.cache.CacheDir
+import org.xtimms.tokusho.data.LocalStorageManager
+import org.xtimms.tokusho.data.repository.MangaSourcesRepository
+import org.xtimms.tokusho.sections.settings.sources.SourcesSettingsViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val storageManager: LocalStorageManager,
+ private val httpCache: Cache,
+ sourcesRepository: MangaSourcesRepository,
+) : KotatsuBaseViewModel() {
+
+ private var storageUsageJob: Job? = null
+
+ private val mutableViewStateFlow = MutableStateFlow(ViewState())
+ val viewStateFlow = mutableViewStateFlow.asStateFlow()
+
+ data class ViewState(
+ val pagesCache: Long = -1L,
+ val thumbnailsCache: Long = -1L,
+ val availableSpace: Long = -1L,
+ val httpCacheSize: Long = -1L,
+ )
+
+ init {
+ storageUsageJob = launchJob(Dispatchers.Default) {
+ mutableViewStateFlow.update {
+ it.copy(
+ availableSpace = storageManager.computeAvailableSize(),
+ pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
+ thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
+ httpCacheSize = runInterruptible { httpCache.size() }
+ )
+ }
+ }
+ }
+
+ val totalSourcesCount = sourcesRepository.allMangaSources.size
+
+ val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
index 58547d9..4d536ca 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.DeveloperBoard
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material.icons.outlined.UpdateDisabled
@@ -40,6 +41,7 @@ const val weblate = "https://hosted.weblate.org/engage/tokusho/"
@Composable
fun AboutView(
navigateBack: () -> Unit,
+ navigateToLicensesPage: () -> Unit,
navigateToUpdatePage: () -> Unit,
) {
@@ -95,6 +97,14 @@ fun AboutView(
context.toast(R.string.info_copied)
}
}
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.open_source_licenses),
+ icon = Icons.Outlined.DeveloperBoard
+ ) {
+ navigateToLicensesPage()
+ }
+ }
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt
new file mode 100644
index 0000000..32a8536
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt
@@ -0,0 +1,66 @@
+package org.xtimms.tokusho.sections.settings.about
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.text.HtmlCompat
+import com.google.android.material.textview.MaterialTextView
+import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
+
+const val LICENSE_NAME_ARGUMENT = "{name}"
+const val LICENSE_WEBSITE_ARGUMENT = "{website}"
+const val LICENSE_CONTENT_ARGUMENT = "{content}"
+const val LICENSE_DESTINATION =
+ "license/${LICENSE_NAME_ARGUMENT}?${LICENSE_WEBSITE_ARGUMENT}?${LICENSE_CONTENT_ARGUMENT}"
+
+@Composable
+fun LicenseView(
+ name: String,
+ website: String,
+ license: String,
+ navigateBack: () -> Unit
+) {
+
+ val uriHandler = LocalUriHandler.current
+
+ ScaffoldWithClassicTopAppBar(
+ title = name,
+ navigateBack = navigateBack,
+ actions = {
+ IconButton(onClick = { uriHandler.openUri(website) }) {
+ Icon(imageVector = Icons.Outlined.Public, contentDescription = null)
+ }
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(padding)
+ .padding(16.dp),
+ ) {
+ HtmlLicenseText(html = license)
+ }
+ }
+}
+
+@Composable
+private fun HtmlLicenseText(html: String) {
+ AndroidView(
+ factory = {
+ MaterialTextView(it)
+ },
+ update = {
+ it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt
new file mode 100644
index 0000000..74e4d49
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt
@@ -0,0 +1,37 @@
+package org.xtimms.tokusho.sections.settings.about
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
+import com.mikepenz.aboutlibraries.ui.compose.m3.util.htmlReadyLicenseContent
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
+
+const val LICENSES_DESTINATION = "licenses"
+
+@Composable
+fun OpenSourceLicensesView(
+ navigateBack: () -> Unit,
+ navigateToLicensePage: (String, String?, String?) -> Unit
+) {
+
+ ScaffoldWithClassicTopAppBar(
+ title = stringResource(R.string.about),
+ navigateBack = navigateBack
+ ) { padding ->
+ LibrariesContainer(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentPadding = padding,
+ onLibraryClick = {
+ navigateToLicensePage(
+ it.library.name,
+ it.library.website,
+ it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty()
+ )
+ },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
index 7127008..bdaa30c 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.outlined.ColorLens
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LightMode
+import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -65,6 +66,7 @@ import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
import org.xtimms.tokusho.core.prefs.READING_TIME
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
+import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT
import org.xtimms.tokusho.core.prefs.paletteStyles
import org.xtimms.tokusho.ui.harmonize.hct.Hct
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
@@ -83,7 +85,6 @@ val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppearanceView(
- coil: ImageLoader,
navigateBack: () -> Unit,
navigateToDarkTheme: () -> Unit,
navigateToLanguages: () -> Unit
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt
new file mode 100644
index 0000000..cbee200
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt
@@ -0,0 +1,118 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.app.backup.BackupAgent
+import android.app.backup.BackupDataInput
+import android.app.backup.BackupDataOutput
+import android.app.backup.FullBackupDataOutput
+import android.content.Context
+import android.os.ParcelFileDescriptor
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.runBlocking
+import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.data.repository.backup.BackupEntry
+import org.xtimms.tokusho.data.repository.backup.BackupRepository
+import org.xtimms.tokusho.data.repository.backup.BackupZipInput
+import org.xtimms.tokusho.data.repository.backup.BackupZipOutput
+import java.io.File
+import java.io.FileDescriptor
+import java.io.FileInputStream
+import java.io.InputStream
+import java.io.OutputStream
+
+class AppBackupAgent : BackupAgent() {
+
+ override fun onBackup(
+ oldState: ParcelFileDescriptor?,
+ data: BackupDataOutput?,
+ newState: ParcelFileDescriptor?
+ ) = Unit
+
+ override fun onRestore(
+ data: BackupDataInput?,
+ appVersionCode: Int,
+ newState: ParcelFileDescriptor?
+ ) = Unit
+
+ override fun onFullBackup(data: FullBackupDataOutput) {
+ super.onFullBackup(data)
+ val file =
+ createBackupFile(this, BackupRepository(TokushoDatabase(applicationContext)))
+ try {
+ fullBackupFile(file, data)
+ } finally {
+ file.delete()
+ }
+ }
+
+ override fun onRestoreFile(
+ data: ParcelFileDescriptor,
+ size: Long,
+ destination: File?,
+ type: Int,
+ mode: Long,
+ mtime: Long
+ ) {
+ if (destination?.name?.endsWith(".bk.zip") == true) {
+ restoreBackupFile(
+ data.fileDescriptor,
+ size,
+ BackupRepository(TokushoDatabase(applicationContext)),
+ )
+ destination.delete()
+ } else {
+ super.onRestoreFile(data, size, destination, type, mode, mtime)
+ }
+ }
+
+ @VisibleForTesting
+ fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
+ BackupZipOutput(context).use { backup ->
+ backup.put(repository.createIndex())
+ backup.put(repository.dumpHistory())
+ backup.put(repository.dumpCategories())
+ backup.put(repository.dumpFavourites())
+ backup.put(repository.dumpBookmarks())
+ backup.put(repository.dumpSources())
+ backup.finish()
+ backup.file
+ }
+ }
+
+ @VisibleForTesting
+ fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
+ val tempFile = File.createTempFile("backup_", ".tmp")
+ FileInputStream(fd).use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyLimitedTo(output, size)
+ }
+ }
+ val backup = BackupZipInput(tempFile)
+ try {
+ runBlocking {
+ backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) }
+ backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
+ backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) }
+ backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
+ backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
+ }
+ } finally {
+ backup.close()
+ tempFile.delete()
+ }
+ }
+
+ private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
+ var bytesCopied: Long = 0
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
+ var bytes = read(buffer)
+ while (bytes >= 0) {
+ out.write(buffer, 0, bytes)
+ bytesCopied += bytes
+ val bytesLeft = (limit - bytesCopied).toInt()
+ if (bytesLeft <= 0) {
+ break
+ }
+ bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt
new file mode 100644
index 0000000..7592cc3
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt
@@ -0,0 +1,28 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import androidx.annotation.StringRes
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.model.ListModel
+import org.xtimms.tokusho.data.repository.backup.BackupEntry
+
+data class BackupEntryModel(
+ val name: BackupEntry.Name,
+ val isChecked: Boolean,
+ val isEnabled: Boolean,
+) : ListModel {
+
+ @get:StringRes
+ val titleResId: Int
+ get() = when (name) {
+ BackupEntry.Name.INDEX -> 0 // should not appear here
+ BackupEntry.Name.HISTORY -> R.string.history
+ BackupEntry.Name.CATEGORIES -> R.string.categories
+ BackupEntry.Name.FAVOURITES -> R.string.nav_shelf
+ BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
+ BackupEntry.Name.SOURCES -> R.string.remote_sources
+ }
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is BackupEntryModel && other.name == name
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt
new file mode 100644
index 0000000..09d6e28
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt
@@ -0,0 +1,65 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.core.components.PreferenceItemDescription
+import org.xtimms.tokusho.core.components.PreferenceItemTitle
+import org.xtimms.tokusho.ui.theme.TokushoTheme
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun BackupItem(
+ title: String,
+ enabled: Boolean = true,
+ isChecked: Boolean = true,
+ onClick: () -> Unit = {},
+) {
+ Surface(
+ modifier = Modifier.combinedClickable(
+ onClick = onClick,
+ enabled = enabled,
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp, 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ modifier = Modifier.padding(start = 8.dp),
+ checked = isChecked,
+ onCheckedChange = null
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 24.dp)
+ ) {
+ PreferenceItemTitle(text = title, enabled = enabled)
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun BackupItemPreview() {
+ TokushoTheme {
+ BackupItem(title = "Title")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt
new file mode 100644
index 0000000..3457cad
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt
@@ -0,0 +1,23 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.app.backup.BackupManager
+import android.content.Context
+import androidx.room.InvalidationTracker
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.xtimms.tokusho.core.database.TABLE_FAVOURITES
+import org.xtimms.tokusho.core.database.TABLE_FAVOURITE_CATEGORIES
+import org.xtimms.tokusho.core.database.TABLE_HISTORY
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BackupObserver @Inject constructor(
+ @ApplicationContext context: Context,
+) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
+
+ private val backupManager = BackupManager(context)
+
+ override fun onInvalidated(tables: Set) {
+ backupManager.dataChanged()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt
new file mode 100644
index 0000000..c77ef9d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt
@@ -0,0 +1,218 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.Settings
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AvTimer
+import androidx.compose.material.icons.outlined.Create
+import androidx.compose.material.icons.outlined.Restore
+import androidx.compose.material.icons.outlined.SdCardAlert
+import androidx.compose.material.icons.outlined.SnippetFolder
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.PreferenceInfo
+import org.xtimms.tokusho.core.components.PreferenceItem
+import org.xtimms.tokusho.core.components.PreferenceSubtitle
+import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer
+import org.xtimms.tokusho.core.components.PreferencesHintCard
+import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.components.icons.Kotatsu
+import org.xtimms.tokusho.utils.lang.tryLaunch
+import org.xtimms.tokusho.utils.system.toast
+import java.io.File
+import java.io.FileOutputStream
+
+const val BACKUP_RESTORE_DESTINATION = "backup_restore"
+
+@Composable
+fun BackupRestoreView(
+ backupViewModel: BackupViewModel = hiltViewModel(),
+ restoreViewModel: RestoreViewModel = hiltViewModel(),
+ navigateBack: () -> Unit,
+ navigateToRestoreScreen: (String) -> Unit
+) {
+
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var backup: File? = null
+
+ fun saveBackup(file: File, output: Uri) {
+ try {
+ context.contentResolver.openFileDescriptor(output, "w")?.use { fd ->
+ FileOutputStream(fd.fileDescriptor).use {
+ it.write(file.readBytes())
+ }
+ }
+ Toast.makeText(context, R.string.backup_saved, Toast.LENGTH_SHORT).show()
+ } catch (e: InterruptedException) {
+ throw e
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ val writeBackup = rememberLauncherForActivityResult(
+ object : ActivityResultContracts.CreateDocument("application/zip") {
+ override fun createIntent(context: Context, input: String): Intent {
+ val intent = super.createIntent(context, input)
+ return Intent.createChooser(intent, context.getString(R.string.file_create_backup))
+ }
+ }
+ ) { uri ->
+ val file = backup
+ if (uri != null && file != null) {
+ saveBackup(file, uri)
+ } else {
+ return@rememberLauncherForActivityResult
+ }
+ }
+
+ val chooseBackup = rememberLauncherForActivityResult(
+ object : ActivityResultContracts.OpenDocument() {
+ override fun createIntent(context: Context, input: Array): Intent {
+ val intent = super.createIntent(context, input)
+ return Intent.createChooser(intent, context.getString(R.string.file_select_backup))
+ }
+ },
+ ) { uri ->
+ if (uri == null) {
+ context.toast(R.string.file_null_uri_error)
+ return@rememberLauncherForActivityResult
+ }
+
+ navigateToRestoreScreen(uri.toString())
+ }
+
+ val showDirectoryAlert =
+ Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager()
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.backup_and_restore),
+ snackbarHost = {
+ SnackbarHost(
+ modifier = Modifier.systemBarsPadding(),
+ hostState = snackbarHostState
+ )
+ },
+ navigateBack = navigateBack
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ if (showDirectoryAlert)
+ item {
+ PreferencesHintCard(
+ title = stringResource(R.string.permission_issue),
+ description = stringResource(R.string.permission_issue_desc),
+ icon = Icons.Outlined.SdCardAlert,
+ ) {
+ if (Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager()) {
+ Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ data = Uri.parse("package:" + context.packageName)
+ if (resolveActivity(context.packageManager) != null)
+ context.startActivity(this)
+ }
+ }
+ }
+ }
+ item {
+ PreferencesHintCard(
+ title = stringResource(R.string.supports_kotatsu_backups),
+ description = stringResource(R.string.supports_kotatsu_backups_desc),
+ icon = Icons.Filled.Kotatsu,
+ )
+ }
+ item {
+ PreferenceSwitchWithContainer(
+ title = stringResource(id = R.string.enable_periodic_backups),
+ icon = null,
+ isChecked = true
+ ) {
+
+ }
+ }
+ item { PreferenceSubtitle(text = stringResource(id = R.string.general)) }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.backup_creation_frequency),
+ description = "Once per week",
+ icon = Icons.Outlined.AvTimer
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.backup_output_directory),
+ description = "TODO",
+ icon = Icons.Outlined.SnippetFolder
+ )
+ }
+ item { PreferenceSubtitle(text = stringResource(id = R.string.actions)) }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.create_data_backup),
+ description = stringResource(id = R.string.create_data_backup_desc),
+ icon = Icons.Outlined.Create,
+ trailingIcon = { UpdateProgressIndicator() }
+ ) {
+ writeBackup.tryLaunch(backup?.name ?: "")
+ }
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.restore_from_backup),
+ description = stringResource(id = R.string.restore_from_backup_desc),
+ icon = Icons.Outlined.Restore
+ ) {
+ chooseBackup.launch(arrayOf("*/*"))
+ }
+ }
+ item { HorizontalDivider() }
+ item {
+ PreferenceInfo(text = stringResource(id = R.string.backup_restore_hint))
+ }
+ }
+ }
+
+}
+
+@Composable
+private fun UpdateProgressIndicator() {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(start = 8.dp, end = 16.dp)
+ .size(24.dp)
+ .padding(2.dp)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt
new file mode 100644
index 0000000..91afbfb
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt
@@ -0,0 +1,52 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.content.Context
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.data.repository.backup.BackupRepository
+import org.xtimms.tokusho.data.repository.backup.BackupZipOutput
+import org.xtimms.tokusho.utils.lang.MutableEventFlow
+import org.xtimms.tokusho.utils.lang.call
+import java.io.File
+import javax.inject.Inject
+
+@HiltViewModel
+class BackupViewModel @Inject constructor(
+ private val repository: BackupRepository,
+ @ApplicationContext context: Context,
+) : KotatsuBaseViewModel() {
+
+ val progress = MutableStateFlow(-1f)
+ val onBackupDone = MutableEventFlow()
+
+ init {
+ launchLoadingJob {
+ val file = BackupZipOutput(context).use { backup ->
+ val step = 1f / 6f
+ backup.put(repository.createIndex())
+
+ progress.value = 0f
+ backup.put(repository.dumpHistory())
+
+ progress.value += step
+ backup.put(repository.dumpCategories())
+
+ progress.value += step
+ backup.put(repository.dumpFavourites())
+
+ progress.value += step
+ backup.put(repository.dumpBookmarks())
+
+ progress.value += step
+ backup.put(repository.dumpSources())
+
+ backup.finish()
+ progress.value = 1f
+ backup.file
+ }
+ onBackupDone.call(file)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt
new file mode 100644
index 0000000..3d7b28a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt
@@ -0,0 +1,98 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.content.Context
+import android.os.Build
+import androidx.documentfile.provider.DocumentFile
+import androidx.hilt.work.HiltWorker
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.await
+import androidx.work.workDataOf
+import dagger.Reusable
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import org.xtimms.tokusho.data.repository.backup.BackupRepository
+import org.xtimms.tokusho.data.repository.backup.BackupZipOutput
+import org.xtimms.tokusho.utils.lang.awaitUniqueWorkInfoByName
+import org.xtimms.tokusho.utils.system.deleteAwait
+import org.xtimms.tokusho.work.PeriodicWorkScheduler
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+@HiltWorker
+class PeriodicalBackupWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted params: WorkerParameters,
+ private val repository: BackupRepository,
+) : CoroutineWorker(appContext, params) {
+
+ override suspend fun doWork(): Result {
+ val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
+ val file = BackupZipOutput(applicationContext).use { backup ->
+ backup.put(repository.createIndex())
+ backup.put(repository.dumpHistory())
+ backup.put(repository.dumpCategories())
+ backup.put(repository.dumpFavourites())
+ backup.put(repository.dumpBookmarks())
+ backup.put(repository.dumpSources())
+ backup.finish()
+ backup.file
+ }
+ return Result.success(resultData)
+ }
+
+ @Reusable
+ class Scheduler @Inject constructor(
+ private val workManager: WorkManager,
+ ) : PeriodicWorkScheduler {
+
+ override suspend fun schedule() {
+ val constraints = Constraints.Builder()
+ .setRequiresStorageNotLow(true)
+ constraints.setRequiresDeviceIdle(true)
+ val request = PeriodicWorkRequestBuilder(
+ 10000,
+ TimeUnit.DAYS,
+ ).setConstraints(constraints.build())
+ .keepResultsForAtLeast(20, TimeUnit.DAYS)
+ .addTag(TAG)
+ .build()
+ workManager
+ .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
+ .await()
+ }
+
+ override suspend fun unschedule() {
+ workManager
+ .cancelUniqueWork(TAG)
+ .await()
+ }
+
+ override suspend fun isScheduled(): Boolean {
+ return workManager
+ .awaitUniqueWorkInfoByName(TAG)
+ .any { !it.state.isFinished }
+ }
+
+ suspend fun getLastSuccessfulBackup(): Date? {
+ return workManager
+ .awaitUniqueWorkInfoByName(TAG)
+ .lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
+ ?.outputData
+ ?.getLong(DATA_TIMESTAMP, 0)
+ ?.let { if (it != 0L) Date(it) else null }
+ }
+ }
+
+ private companion object {
+
+ const val TAG = "backups"
+ const val DATA_TIMESTAMP = "ts"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt
new file mode 100644
index 0000000..7470d69
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt
@@ -0,0 +1,107 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AccessTime
+import androidx.compose.material.icons.outlined.Restore
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.PreferencesHintCard
+import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.updates.Updater
+import org.xtimms.tokusho.sections.settings.about.ProgressIndicatorButton
+import org.xtimms.tokusho.utils.DeviceUtil
+import org.xtimms.tokusho.utils.system.suspendToast
+
+const val RESTORE_ARGUMENT = "{source}"
+const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}"
+
+@Composable
+fun RestoreItemsView(
+ uri: String,
+ restoreViewModel: RestoreViewModel = hiltViewModel(),
+ navigateBack: () -> Unit,
+) {
+
+ val items = restoreViewModel.availableEntries.collectAsStateWithLifecycle()
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.restore_from_backup),
+ navigateBack = navigateBack
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
+ item {
+ PreferencesHintCard(
+ title = stringResource(id = R.string.restore_miui_warning),
+ icon = null
+ )
+ }
+ }
+ item {
+ PreferencesHintCard(
+ title = stringResource(id = R.string.backup_creation_date),
+ description = restoreViewModel.backupDate.value.toString(),
+ icon = Icons.Outlined.AccessTime
+ )
+ }
+ items(
+ count = 5
+ ) {
+ BackupItem(
+ title = it.toString()
+ )
+ }
+ item {
+ var isLoading by remember { mutableStateOf(false) }
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ProgressIndicatorButton(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .padding(top = 6.dp)
+ .padding(bottom = 12.dp),
+ text = stringResource(
+ id = R.string.restore
+ ),
+ icon = Icons.Outlined.Restore,
+ isLoading = isLoading
+ ) {
+ restoreViewModel.restore()
+ }
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt
new file mode 100644
index 0000000..1329497
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt
@@ -0,0 +1,149 @@
+package org.xtimms.tokusho.sections.settings.backup
+
+import android.content.Context
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.util.SuspendLazy
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.data.repository.backup.BackupEntry
+import org.xtimms.tokusho.data.repository.backup.BackupRepository
+import org.xtimms.tokusho.data.repository.backup.BackupZipInput
+import org.xtimms.tokusho.data.repository.backup.CompositeResult
+import org.xtimms.tokusho.utils.lang.MutableEventFlow
+import org.xtimms.tokusho.utils.lang.call
+import org.xtimms.tokusho.utils.lang.toUriOrNull
+import java.io.File
+import java.io.FileNotFoundException
+import java.util.Date
+import java.util.EnumMap
+import java.util.EnumSet
+import javax.inject.Inject
+
+@HiltViewModel
+class RestoreViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val repository: BackupRepository,
+ @ApplicationContext context: Context,
+) : KotatsuBaseViewModel() {
+
+ private val backupInput = SuspendLazy {
+ val uri = savedStateHandle.get(RESTORE_ARGUMENT)?.toUriOrNull() ?: throw FileNotFoundException()
+ val contentResolver = context.contentResolver
+ runInterruptible(Dispatchers.IO) {
+ val tempFile = File.createTempFile("backup_", ".tmp")
+ (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ BackupZipInput(tempFile)
+ }
+ }
+
+ val progress = MutableStateFlow(-1f)
+ val onRestoreDone = MutableEventFlow()
+
+ val availableEntries = MutableStateFlow>(emptyList())
+ val backupDate = MutableStateFlow(null)
+
+ init {
+ launchLoadingJob(Dispatchers.Default) {
+ val backup = backupInput.get()
+ val entries = backup.entries()
+ availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
+ if (entry == BackupEntry.Name.INDEX || entry !in entries) {
+ return@mapNotNull null
+ }
+ BackupEntryModel(
+ name = entry,
+ isChecked = true,
+ isEnabled = true,
+ )
+ }
+ backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX))
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ backupInput.peek()?.cleanupAsync()
+ }
+
+ fun onItemClick(item: BackupEntryModel) {
+ val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
+ map[item.name] = item.copy(isChecked = !item.isChecked)
+ map.validate()
+ availableEntries.value = map.values.sortedBy { it.name.ordinal }
+ }
+
+ fun restore() {
+ launchLoadingJob {
+ val backup = backupInput.get()
+ val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
+ if (it.isChecked) it.name else null
+ }
+ val result = CompositeResult()
+ val step = 1f / 5f
+
+ progress.value = 0f
+ if (BackupEntry.Name.HISTORY in checkedItems) {
+ backup.getEntry(BackupEntry.Name.HISTORY)?.let {
+ result += repository.restoreHistory(it)
+ }
+ }
+
+ progress.value += step
+ if (BackupEntry.Name.CATEGORIES in checkedItems) {
+ backup.getEntry(BackupEntry.Name.CATEGORIES)?.let {
+ result += repository.restoreCategories(it)
+ }
+ }
+
+ progress.value += step
+ if (BackupEntry.Name.FAVOURITES in checkedItems) {
+ backup.getEntry(BackupEntry.Name.FAVOURITES)?.let {
+ result += repository.restoreFavourites(it)
+ }
+ }
+
+ progress.value += step
+ if (BackupEntry.Name.BOOKMARKS in checkedItems) {
+ backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
+ result += repository.restoreBookmarks(it)
+ }
+ }
+
+ progress.value += step
+ if (BackupEntry.Name.SOURCES in checkedItems) {
+ backup.getEntry(BackupEntry.Name.SOURCES)?.let {
+ result += repository.restoreSources(it)
+ }
+ }
+
+ progress.value = 1f
+ onRestoreDone.call(result)
+ }
+ }
+
+ /**
+ * Check for inconsistent user selection
+ * Favorites cannot be restored without categories
+ */
+ private fun MutableMap.validate() {
+ val favorites = this[BackupEntry.Name.FAVOURITES] ?: return
+ val categories = this[BackupEntry.Name.CATEGORIES]
+ if (categories?.isChecked == true) {
+ if (!favorites.isEnabled) {
+ this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true)
+ }
+ } else {
+ if (favorites.isEnabled) {
+ this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt
new file mode 100644
index 0000000..b332d6d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt
@@ -0,0 +1,76 @@
+package org.xtimms.tokusho.sections.settings.network
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Dns
+import androidx.compose.material.icons.outlined.VpnLock
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.PreferenceItem
+import org.xtimms.tokusho.core.components.PreferenceSwitch
+import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.components.icons.ArrowDecisionOutline
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.SSL_BYPASS
+
+const val NETWORK_DESTINATION = "network"
+
+@Composable
+fun NetworkView(
+ navigateBack: () -> Unit,
+) {
+
+ var isSSLBypassEnabled by remember {
+ mutableStateOf(AppSettings.isSSLBypassEnabled())
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.network),
+ navigateBack = navigateBack
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.proxy),
+ description = "",
+ icon = Icons.Outlined.ArrowDecisionOutline
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.dns_over_https),
+ description = "",
+ icon = Icons.Outlined.Dns
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.ignore_ssl_errors),
+ description = stringResource(id = R.string.ignore_ssl_errors_desc),
+ icon = Icons.Outlined.VpnLock,
+ isChecked = isSSLBypassEnabled,
+ ) {
+ isSSLBypassEnabled = !isSSLBypassEnabled
+ AppSettings.updateValue(SSL_BYPASS, isSSLBypassEnabled)
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt
index e866e31..d2fd8b7 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt
@@ -8,16 +8,25 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Category
+import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.Update
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
+import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT
import org.xtimms.tokusho.sections.shelf.ShelfViewModel
const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
@@ -29,6 +38,12 @@ fun ShelfSettingsView(
navigateToCategories: () -> Unit
) {
+ val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
+
+ var isMangaCountInTabsEnabled by remember {
+ mutableStateOf(AppSettings.isMangaCountInTabsEnabled())
+ }
+
ScaffoldWithTopAppBar(
title = stringResource(R.string.nav_shelf),
navigateBack = navigateBack
@@ -47,8 +62,8 @@ fun ShelfSettingsView(
title = stringResource(id = R.string.edit_categories),
description = pluralStringResource(
id = R.plurals.categories_count,
- count = shelfViewModel.uiState.value.categories.size,
- shelfViewModel.uiState.value.categories.size
+ count = categories.size,
+ categories.size
),
icon = Icons.Outlined.Category,
onClick = {
@@ -56,6 +71,16 @@ fun ShelfSettingsView(
}
)
}
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.show_manga_count_in_tabs),
+ icon = Icons.Outlined.Numbers,
+ isChecked = isMangaCountInTabsEnabled,
+ onClick = {
+ isMangaCountInTabsEnabled = !isMangaCountInTabsEnabled
+ AppSettings.updateValue(TABS_MANGA_COUNT, isMangaCountInTabsEnabled)
+ })
+ }
item {
PreferenceSubtitle(text = stringResource(id = R.string.updates))
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt
new file mode 100644
index 0000000..07722c8
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt
@@ -0,0 +1,48 @@
+package org.xtimms.tokusho.sections.settings.shelf.categories
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.NewLabel
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.ConfirmButton
+import org.xtimms.tokusho.core.components.DismissButton
+
+@Composable
+fun AddCategoryDialog(onDismissRequest: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = { Text(stringResource(id = R.string.add_category)) },
+ icon = { Icon(Icons.Outlined.NewLabel, null) },
+ text = {
+ Column {
+ OutlinedTextField(
+ modifier = Modifier.padding(bottom = 8.dp),
+ value = "",
+ onValueChange = { },
+ label = {
+ Text(stringResource(id = R.string.name))
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+ )
+ }
+ }, confirmButton = {
+ ConfirmButton {
+ onDismissRequest()
+ }
+ }, dismissButton = {
+ DismissButton {
+ onDismissRequest()
+ }
+ })
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt
index 4194e2e..61e3eac 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt
@@ -1,35 +1,52 @@
package org.xtimms.tokusho.sections.settings.shelf.categories
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NewLabel
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
+import org.xtimms.tokusho.sections.shelf.ShelfViewModel
+import org.xtimms.tokusho.utils.system.plus
const val CATEGORIES_DESTINATION = "categories"
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CategoriesView(
+ shelfViewModel: ShelfViewModel = hiltViewModel(),
navigateBack: () -> Unit,
) {
+ val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
+ var showAddCategoryDialog by remember { mutableStateOf(false) }
+ val lazyListState = rememberLazyListState()
+
ScaffoldWithClassicTopAppBar(
title = stringResource(R.string.edit_categories),
floatingActionButton = {
ExtendedFloatingActionButton(
- onClick = { }
+ onClick = {
+ showAddCategoryDialog = true
+ }
) {
Icon(
imageVector = Icons.Outlined.NewLabel,
@@ -44,13 +61,30 @@ fun CategoriesView(
navigateBack = navigateBack
) { padding ->
LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = padding + PaddingValues(horizontal = 16.dp)
) {
-
+ itemsIndexed(
+ items = categories,
+ key = { _, category -> "category-${category.id}" },
+ ) { index, category ->
+ CategoryListItem(
+ modifier = Modifier.animateItemPlacement(),
+ category = category,
+ canMoveUp = index != 0,
+ canMoveDown = index != categories.lastIndex,
+ onMoveUp = { },
+ onMoveDown = { },
+ onRename = { },
+ onDelete = { },
+ )
+ }
}
}
+ if (showAddCategoryDialog) {
+ AddCategoryDialog(
+ onDismissRequest = { showAddCategoryDialog = false }
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt
new file mode 100644
index 0000000..d240771
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt
@@ -0,0 +1,85 @@
+package org.xtimms.tokusho.sections.settings.shelf.categories
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.Label
+import androidx.compose.material.icons.outlined.ArrowDropDown
+import androidx.compose.material.icons.outlined.ArrowDropUp
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material3.Card
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.sections.shelf.FavouriteTabModel
+
+@Composable
+fun CategoryListItem(
+ category: FavouriteTabModel,
+ canMoveUp: Boolean,
+ canMoveDown: Boolean,
+ onMoveUp: (FavouriteTabModel) -> Unit,
+ onMoveDown: (FavouriteTabModel) -> Unit,
+ onRename: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onRename() }
+ .padding(
+ start = 16.dp,
+ top = 16.dp,
+ end = 16.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
+ Text(
+ text = category.title,
+ modifier = Modifier
+ .padding(start = 16.dp),
+ )
+ }
+ Row {
+ IconButton(
+ onClick = { onMoveUp(category) },
+ enabled = canMoveUp,
+ ) {
+ Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null)
+ }
+ IconButton(
+ onClick = { onMoveDown(category) },
+ enabled = canMoveDown,
+ ) {
+ Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null)
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ IconButton(onClick = onRename) {
+ Icon(
+ imageVector = Icons.Outlined.Edit,
+ contentDescription = stringResource(R.string.action_rename_category),
+ )
+ }
+ IconButton(onClick = onDelete) {
+ Icon(imageVector = Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt
new file mode 100644
index 0000000..c905811
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt
@@ -0,0 +1,61 @@
+package org.xtimms.tokusho.sections.settings.shelf.categories.interactor
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.xtimms.tokusho.core.model.FavouriteCategory
+import org.xtimms.tokusho.data.repository.FavouritesRepository
+import org.xtimms.tokusho.utils.lang.processLifecycleScope
+import org.xtimms.tokusho.utils.lang.withNonCancellableContext
+import java.util.Collections
+
+class ReorderCategory(
+ private val favouritesRepository: FavouritesRepository,
+) {
+
+ private val mutex = Mutex()
+
+ suspend fun moveUp(category: FavouriteCategory): Result = await(category, MoveTo.UP)
+
+ suspend fun moveDown(category: FavouriteCategory): Result = await(category, MoveTo.DOWN)
+
+ private suspend fun await(category: FavouriteCategory, moveTo: MoveTo) = withNonCancellableContext {
+ mutex.withLock {
+ val categories = favouritesRepository.observeCategoriesForLibrary()
+ .map { it.toMutableList() }
+ .stateIn(processLifecycleScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()).value
+
+ val currentIndex = categories.indexOfFirst { it.id == category.id }
+ if (currentIndex == -1) {
+ return@withNonCancellableContext Result.Unchanged
+ }
+
+ val newPosition = when (moveTo) {
+ MoveTo.UP -> currentIndex - 1
+ MoveTo.DOWN -> currentIndex + 1
+ }.toInt()
+
+ try {
+ Collections.swap(categories, currentIndex, newPosition)
+ Result.Success
+ } catch (e: Exception) {
+ Result.InternalError(e)
+ }
+ }
+ }
+
+ sealed interface Result {
+ data object Success : Result
+ data object Unchanged : Result
+ data class InternalError(val error: Throwable) : Result
+ }
+
+ private enum class MoveTo {
+ UP,
+ DOWN,
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt
new file mode 100644
index 0000000..29d034e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt
@@ -0,0 +1,101 @@
+package org.xtimms.tokusho.sections.settings.sources
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Apps
+import androidx.compose.material.icons.outlined.NoAdultContent
+import androidx.compose.material.icons.outlined.SettingsApplications
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.PreferenceItem
+import org.xtimms.tokusho.core.components.PreferenceSwitch
+import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.core.prefs.NSFW
+
+const val SOURCES_DESTINATION = "sources"
+
+@Composable
+fun SourcesView(
+ viewModel: SourcesSettingsViewModel = hiltViewModel(),
+ navigateBack: () -> Unit,
+ navigateToSourcesCatalog: () -> Unit,
+ navigateToSourcesManagement: () -> Unit,
+) {
+
+ val context = LocalContext.current
+ val availableSourcesCount = viewModel.availableSourcesCount.collectAsState(-1).value
+ val enabledSourcesCount = viewModel.enabledSourcesCount.collectAsState(-1).value
+ val state by viewModel.viewStateFlow.collectAsStateWithLifecycle()
+
+ var isNSFWEnabled by remember {
+ mutableStateOf(AppSettings.isNSFWEnabled())
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.manga_sources),
+ navigateBack = navigateBack
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.manage_sources),
+ description = if (enabledSourcesCount >= 0) {
+ context.resources.getQuantityString(
+ R.plurals.items,
+ enabledSourcesCount,
+ enabledSourcesCount
+ )
+ } else {
+ null
+ },
+ icon = Icons.Outlined.SettingsApplications,
+ onClick = { navigateToSourcesManagement() }
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.sources_catalog),
+ description = if (availableSourcesCount >= 0) {
+ stringResource(R.string.available_d, availableSourcesCount)
+ } else {
+ null
+ },
+ icon = Icons.Outlined.Apps,
+ onClick = { navigateToSourcesCatalog() }
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.disable_nsfw),
+ description = stringResource(id = R.string.disable_nsfw_desc),
+ icon = Icons.Outlined.NoAdultContent,
+ isChecked = isNSFWEnabled
+ ) {
+ isNSFWEnabled = !isNSFWEnabled
+ AppSettings.updateValue(NSFW, isNSFWEnabled)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt
new file mode 100644
index 0000000..4184997
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt
@@ -0,0 +1,26 @@
+package org.xtimms.tokusho.sections.settings.sources
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.data.repository.MangaSourcesRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class SourcesSettingsViewModel @Inject constructor(
+ sourcesRepository: MangaSourcesRepository,
+) : KotatsuBaseViewModel() {
+
+ private val mutableViewStateFlow = MutableStateFlow(ViewState())
+ val viewStateFlow = mutableViewStateFlow.asStateFlow()
+
+ data class ViewState(
+ val enabledSourcesCount: Int = -1,
+ val availableSourcesCount: Int = -1,
+ )
+
+ val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount()
+
+ val availableSourcesCount = sourcesRepository.observeAvailableSourcesCount()
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt
new file mode 100644
index 0000000..70a0d97
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt
@@ -0,0 +1,44 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.Label
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SourceCatalogItem(
+ source: String,
+ modifier: Modifier = Modifier,
+) {
+
+ Card(
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = 16.dp,
+ top = 16.dp,
+ end = 16.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
+ Text(
+ text = source,
+ modifier = Modifier
+ .padding(start = 16.dp),
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt
new file mode 100644
index 0000000..277ab21
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt
@@ -0,0 +1,30 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.annotation.StringRes
+import androidx.compose.ui.graphics.vector.ImageVector
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.tokusho.core.model.ListModel
+
+sealed interface SourceCatalogItemModel : ListModel {
+
+ data class Source(
+ val source: MangaSource,
+ val showSummary: Boolean,
+ ) : SourceCatalogItemModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Source && other.source == source
+ }
+ }
+
+ data class Hint(
+ val icon: ImageVector,
+ @StringRes val title: Int,
+ @StringRes val text: Int,
+ ) : SourceCatalogItemModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Hint && other.title == title
+ }
+ }
+}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt
new file mode 100644
index 0000000..281bfac
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt
@@ -0,0 +1,14 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.xtimms.tokusho.core.model.ListModel
+
+data class SourceCatalogPage(
+ val type: ContentType,
+ val items: List,
+) : ListModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is SourceCatalogPage && other.type == type
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt
new file mode 100644
index 0000000..7d0721b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt
@@ -0,0 +1,108 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.SearchOff
+import androidx.room.InvalidationTracker
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.ViewModelLifecycle
+import dagger.hilt.android.lifecycle.RetainedLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.database.TABLE_SOURCES
+import org.xtimms.tokusho.core.database.TokushoDatabase
+import org.xtimms.tokusho.core.database.removeObserverAsync
+import org.xtimms.tokusho.data.repository.MangaSourcesRepository
+import org.xtimms.tokusho.utils.lang.lifecycleScope
+
+class SourcesCatalogListProducer @AssistedInject constructor(
+ @Assisted private val locale: String?,
+ @Assisted private val contentType: ContentType,
+ @Assisted lifecycle: ViewModelLifecycle,
+ private val repository: MangaSourcesRepository,
+ private val database: TokushoDatabase,
+) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
+
+ private val scope = lifecycle.lifecycleScope
+
+ private var query: String? = null
+ val list = MutableStateFlow(emptyList())
+
+ private var job = scope.launch(Dispatchers.Default) {
+ list.value = buildList()
+ }
+
+ init {
+ scope.launch(Dispatchers.Default) {
+ database.invalidationTracker.addObserver(this@SourcesCatalogListProducer)
+ }
+ lifecycle.addOnClearedListener(this)
+ }
+
+ override fun onCleared() {
+ database.invalidationTracker.removeObserverAsync(this)
+ }
+
+ override fun onInvalidated(tables: Set) {
+ val prevJob = job
+ job = scope.launch(Dispatchers.Default) {
+ prevJob.cancelAndJoin()
+ list.update { buildList() }
+ }
+ }
+
+ fun setQuery(value: String?) {
+ this.query = value
+ onInvalidated(emptySet())
+ }
+
+ private suspend fun buildList(): List {
+ val sources = repository.getDisabledSources().toMutableList()
+ when (val q = query) {
+ null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
+ "" -> return emptyList()
+ else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
+ }
+ return if (sources.isEmpty()) {
+ listOf(
+ if (query == null) {
+ SourceCatalogItemModel.Hint(
+ icon = Icons.Outlined.SearchOff,
+ title = R.string.no_manga_sources,
+ text = R.string.no_manga_sources_catalog_text,
+ )
+ } else {
+ SourceCatalogItemModel.Hint(
+ icon = Icons.Outlined.SearchOff,
+ title = R.string.nothing_found,
+ text = R.string.no_manga_sources_found,
+ )
+ },
+ )
+ } else {
+ sources.sortBy { it.title }
+ sources.map {
+ SourceCatalogItemModel.Source(
+ source = it,
+ showSummary = query != null,
+ )
+ }
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+
+ fun create(
+ locale: String?,
+ contentType: ContentType,
+ lifecycle: ViewModelLifecycle,
+ ): SourcesCatalogListProducer
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt
new file mode 100644
index 0000000..341fb9e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt
@@ -0,0 +1,59 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.TabText
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@Composable
+internal fun SourcesCatalogTabs(
+ categories: List,
+ pagerState: PagerState,
+ onTabItemClick: (Int) -> Unit,
+) {
+ Column(
+ modifier = Modifier.zIndex(1f),
+ ) {
+ PrimaryScrollableTabRow(
+ selectedTabIndex = pagerState.currentPage,
+ edgePadding = 0.dp,
+ // TODO: use default when width is fixed upstream
+ // https://issuetracker.google.com/issues/242879624
+ divider = {},
+ ) {
+ categories.forEachIndexed { index, category ->
+ Tab(
+ selected = pagerState.currentPage == index,
+ onClick = { onTabItemClick(index) },
+ text = {
+ TabText(
+ text = when (category.type) {
+ ContentType.MANGA -> stringResource(id = R.string.manga)
+ ContentType.COMICS -> stringResource(id = R.string.comics)
+ ContentType.HENTAI -> stringResource(id = R.string.hentai)
+ ContentType.OTHER -> stringResource(id = R.string.other)
+ else -> stringResource(id = R.string.unknown)
+ },
+ )
+ },
+ unselectedContentColor = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ HorizontalDivider()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt
new file mode 100644
index 0000000..e88f836
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt
@@ -0,0 +1,46 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
+
+const val CATALOG_DESTINATION = "catalog"
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SourcesCatalogView(
+ sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(),
+ navigateBack: () -> Unit,
+) {
+
+ val categories by sourcesCatalogViewModel.content.collectAsStateWithLifecycle(emptyList())
+
+ ScaffoldWithClassicTopAppBar(
+ title = stringResource(R.string.sources_catalog),
+ navigateBack = navigateBack
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding)
+ ) {
+ val pagerState = rememberPagerState(0) { categories.size }
+ val scope = rememberCoroutineScope()
+ if (categories.isNotEmpty()) {
+ SourcesCatalogTabs(
+ categories = categories,
+ pagerState = pagerState,
+ ) { scope.launch { pagerState.animateScrollToPage(it) } }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt
new file mode 100644
index 0000000..21782fe
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt
@@ -0,0 +1,87 @@
+package org.xtimms.tokusho.sections.settings.sources.catalog
+
+import androidx.annotation.MainThread
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.plus
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.xtimms.tokusho.R
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
+import org.xtimms.tokusho.core.prefs.AppSettings
+import org.xtimms.tokusho.data.repository.MangaSourcesRepository
+import org.xtimms.tokusho.utils.ReversibleAction
+import org.xtimms.tokusho.utils.lang.MutableEventFlow
+import org.xtimms.tokusho.utils.lang.call
+import java.util.EnumMap
+import java.util.EnumSet
+import java.util.Locale
+import javax.inject.Inject
+
+@HiltViewModel
+class SourcesCatalogViewModel @Inject constructor(
+ private val repository: MangaSourcesRepository,
+ private val listProducerFactory: SourcesCatalogListProducer.Factory,
+) : KotatsuBaseViewModel() {
+
+ private val lifecycle = RetainedLifecycleImpl()
+ private var searchQuery: String? = null
+ val onActionDone = MutableEventFlow()
+ val locales = repository.allMangaSources.mapToSet { it.locale }
+ val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
+
+ private val listProducers = locale.map { lc ->
+ createListProducers(lc)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value))
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val content: StateFlow> = listProducers.flatMapLatest {
+ val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } }
+ combine>(flows, Array::toList)
+ }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+
+ override fun onCleared() {
+ super.onCleared()
+ lifecycle.dispatchOnCleared()
+ }
+
+ fun performSearch(query: String?) {
+ searchQuery = query
+ listProducers.value.forEach { (_, v) -> v.setQuery(query) }
+ }
+
+ fun setLocale(value: String?) {
+ locale.value = value
+ }
+
+ fun addSource(source: MangaSource) {
+ launchJob(Dispatchers.Default) {
+ val rollback = repository.setSourceEnabled(source, true)
+ onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
+ }
+ }
+
+ @MainThread
+ private fun createListProducers(lc: String?): Map {
+ val types = EnumSet.allOf(ContentType::class.java)
+ if (AppSettings.isNSFWEnabled()) {
+ types.remove(ContentType.HENTAI)
+ }
+ return types.associateWithTo(EnumMap(ContentType::class.java)) { type ->
+ listProducerFactory.create(lc, type, lifecycle).also {
+ it.setQuery(searchQuery)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
index 1ff8224..765a463 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt
@@ -70,7 +70,7 @@ fun StorageView(
PreferenceStorageItem(
total = uiState.availableSpace.toFloat(),
title = stringResource(id = R.string.saved_manga),
- icon = Icons.Outlined.SdStorage
+ icon = Icons.Outlined.SdStorage,
)
}
item {
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt
new file mode 100644
index 0000000..ecbe3ac
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt
@@ -0,0 +1,14 @@
+package org.xtimms.tokusho.sections.shelf
+
+import org.xtimms.tokusho.core.model.ListModel
+
+data class FavouriteTabModel(
+ val id: Long,
+ val title: String,
+ val mangaCount: Int,
+) : ListModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is FavouriteTabModel && other.id == id
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt
new file mode 100644
index 0000000..32927a3
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt
@@ -0,0 +1,28 @@
+package org.xtimms.tokusho.sections.shelf
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyGridScope
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.xtimms.tokusho.utils.system.plus
+
+@Composable
+internal fun LazyShelfGrid(
+ modifier: Modifier = Modifier,
+ columns: Int,
+ contentPadding: PaddingValues,
+ content: LazyGridScope.() -> Unit,
+) {
+ LazyVerticalGrid(
+ columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns),
+ modifier = modifier,
+ contentPadding = contentPadding + PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = content,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt
new file mode 100644
index 0000000..b7a3d32
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt
@@ -0,0 +1,41 @@
+package org.xtimms.tokusho.sections.shelf
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.util.fastAny
+import coil.ImageLoader
+import org.xtimms.tokusho.core.components.MangaGridItem
+
+@Composable
+internal fun ShelfGrid(
+ coil: ImageLoader,
+ items: List,
+ columns: Int,
+ contentPadding: PaddingValues,
+ selection: List,
+ onClick: (ShelfManga) -> Unit,
+ onLongClick: (ShelfManga) -> Unit,
+) {
+ LazyShelfGrid(
+ modifier = Modifier.fillMaxSize(),
+ columns = columns,
+ contentPadding = contentPadding,
+ ) {
+ items(
+ items = items,
+ contentType = { "shelf_grid_item" },
+ ) { shelfItem ->
+ val manga = shelfItem.manga
+ MangaGridItem(
+ coil = coil,
+ manga = manga,
+ isSelected = selection.fastAny { it.id == shelfItem.id },
+ onLongClick = { onLongClick(shelfItem) },
+ onClick = { onClick(shelfItem) },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt
index f73d8e2..b770668 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt
@@ -4,21 +4,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class ShelfManga(
val manga: Manga,
- val category: Long,
- val totalChapters: Long,
- val readCount: Long,
- val bookmarkCount: Long,
- val latestUpload: Long,
- val chapterFetchedAt: Long,
- val lastRead: Long,
) {
val id: Long = manga.id
-
- val unreadCount
- get() = totalChapters - readCount
-
- val hasBookmarks
- get() = bookmarkCount > 0
-
- val hasStarted = readCount > 0
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt
index cda5902..a13e66e 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt
@@ -9,10 +9,13 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import coil.ImageLoader
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.utils.system.plus
@@ -20,12 +23,12 @@ import org.xtimms.tokusho.utils.system.plus
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShelfPager(
+ coil: ImageLoader,
state: PagerState,
contentPadding: PaddingValues,
- hasActiveFilters: Boolean,
searchQuery: String?,
- onGlobalSearchClicked: () -> Unit,
- getLibraryForPage: (Int) -> List,
+ getShelfForPage: (Int) -> List,
+ navigateToDetails: (ShelfManga) -> Unit,
) {
HorizontalPager(
modifier = Modifier.fillMaxSize(),
@@ -36,31 +39,34 @@ fun ShelfPager(
// To make sure only one offscreen page is being composed
return@HorizontalPager
}
- val library = getLibraryForPage(page)
-
+ val library = getShelfForPage(page)
if (library.isEmpty()) {
ShelfPagerEmptyScreen(
searchQuery = searchQuery,
- hasActiveFilters = hasActiveFilters,
contentPadding = contentPadding,
- onGlobalSearchClicked = onGlobalSearchClicked,
)
return@HorizontalPager
}
+ ShelfGrid(
+ coil = coil,
+ items = library,
+ columns = 2,
+ contentPadding = contentPadding,
+ selection = listOf(),
+ onClick = navigateToDetails,
+ onLongClick = { },
+ )
}
}
@Composable
private fun ShelfPagerEmptyScreen(
searchQuery: String?,
- hasActiveFilters: Boolean,
contentPadding: PaddingValues,
- onGlobalSearchClicked: () -> Unit,
) {
val msg = when {
!searchQuery.isNullOrEmpty() -> R.string.no_results_found
- hasActiveFilters -> R.string.error_no_match
else -> R.string.information_no_manga_category
}
@@ -71,7 +77,9 @@ private fun ShelfPagerEmptyScreen(
.verticalScroll(rememberScrollState()),
) {
EmptyScreen(
- title = msg,
+ icon = Icons.Outlined.Close,
+ title = R.string.empty_here,
+ description = msg,
modifier = Modifier.weight(1f),
)
}
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt
index 24424b6..b6a8569 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt
@@ -14,13 +14,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.xtimms.tokusho.core.components.TabText
import org.xtimms.tokusho.core.model.FavouriteCategory
+import org.xtimms.tokusho.core.prefs.AppSettings
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ShelfTabs(
- categories: List,
+ categories: List,
pagerState: PagerState,
- getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
onTabItemClick: (Int) -> Unit,
) {
Column(
@@ -40,14 +40,13 @@ internal fun ShelfTabs(
text = {
TabText(
text = category.title,
- badgeCount = getNumberOfMangaForCategory(category),
+ badgeCount = if (AppSettings.isMangaCountInTabsEnabled()) category.mangaCount else null
)
},
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
-
HorizontalDivider()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt
deleted file mode 100644
index 8466d85..0000000
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.xtimms.tokusho.sections.shelf
-
-import org.xtimms.tokusho.core.base.state.UiState
-import org.xtimms.tokusho.core.model.FavouriteCategory
-
-data class ShelfUiState(
- val categories: List = emptyList(),
- override val isLoading: Boolean = false,
- override val message: String? = null,
-) : UiState() {
-
- override fun setLoading(value: Boolean) = copy(isLoading = value)
- override fun setMessage(value: String?) = copy(message = value)
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt
index a2515ce..919528e 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt
@@ -11,55 +11,64 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.ImageLoader
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.collapsable
+import org.xtimms.tokusho.core.components.PullRefresh
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ShelfCategory
+import kotlin.time.Duration.Companion.seconds
const val SHELF_DESTINATION = "shelf"
@Composable
fun ShelfView(
+ coil: ImageLoader,
currentPage: () -> Int,
showPageTabs: Boolean,
- getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
- getLibraryForPage: (Int) -> List,
topBarHeightPx: Float,
padding: PaddingValues,
+ navigateToDetails: (Long) -> Unit,
+ onRefresh: (FavouriteTabModel?) -> Boolean,
) {
- val viewModel: ShelfViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ShelfViewContent(
- uiState = uiState,
+ coil = coil,
currentPage = currentPage,
showPageTabs = showPageTabs,
- getNumberOfMangaForCategory = getNumberOfMangaForCategory,
- getLibraryForPage = getLibraryForPage,
topBarHeightPx = topBarHeightPx,
- padding = padding
+ padding = padding,
+ navigateToDetails = navigateToDetails,
+ onRefresh = onRefresh
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShelfViewContent(
- uiState: ShelfUiState,
+ coil: ImageLoader,
+ viewModel: ShelfViewModel = hiltViewModel(),
currentPage: () -> Int,
showPageTabs: Boolean,
- getNumberOfMangaForCategory: (FavouriteCategory) -> Int?,
- getLibraryForPage: (Int) -> List,
topBarHeightPx: Float,
topBarOffsetY: Animatable = Animatable(0f),
padding: PaddingValues,
+ navigateToDetails: (Long) -> Unit,
+ onRefresh: (FavouriteTabModel?) -> Boolean,
) {
val scrollState = rememberScrollState()
+ val categories by viewModel.categories.collectAsStateWithLifecycle(emptyList())
+ val mangas by viewModel.mangas.collectAsStateWithLifecycle(emptyList())
+
Column(
modifier = Modifier
.collapsable(
@@ -69,31 +78,46 @@ fun ShelfViewContent(
)
.padding(padding)
) {
- val coercedCurrentPage = remember { currentPage().coerceAtMost(uiState.categories.lastIndex) }
- val pagerState = rememberPagerState(coercedCurrentPage) { uiState.categories.size }
+ val pagerState = rememberPagerState(0) { categories.size }
val scope = rememberCoroutineScope()
- if (showPageTabs && uiState.categories.size > 1) {
- LaunchedEffect(uiState.categories) {
- if (uiState.categories.size <= pagerState.currentPage) {
- pagerState.scrollToPage(uiState.categories.size - 1)
- }
+
+ var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
+
+ if (categories.isNotEmpty()) {
+ if (showPageTabs) {
+ ShelfTabs(
+ categories = categories,
+ pagerState = pagerState,
+ ) { scope.launch { pagerState.animateScrollToPage(it) } }
}
- ShelfTabs(
- categories = uiState.categories,
- pagerState = pagerState,
- getNumberOfMangaForCategory = getNumberOfMangaForCategory,
- ) { scope.launch { pagerState.animateScrollToPage(it) } }
}
- ShelfPager(
- state = pagerState,
- contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
- hasActiveFilters = false,
- searchQuery = "",
- onGlobalSearchClicked = { },
- getLibraryForPage = getLibraryForPage,
- )
- }
-}
+ val onClickManga = { manga: ShelfManga ->
+ navigateToDetails(manga.id)
+ }
-typealias ShelfMap = Map>
\ No newline at end of file
+ PullRefresh(
+ refreshing = isRefreshing,
+ onRefresh = {
+ val started = onRefresh(categories[currentPage()])
+ if (!started) return@PullRefresh
+ scope.launch {
+ // Fake refresh status but hide it after a second as it's a long running task
+ isRefreshing = true
+ delay(1.seconds)
+ isRefreshing = false
+ }
+ },
+ enabled = { true }
+ ) {
+ ShelfPager(
+ coil = coil,
+ state = pagerState,
+ contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
+ searchQuery = "",
+ getShelfForPage = { mangas },
+ navigateToDetails = onClickManga
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt
index 8e78633..1e37a65 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt
@@ -5,36 +5,50 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
+import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
import org.xtimms.tokusho.data.repository.FavouritesRepository
+import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
@HiltViewModel
class ShelfViewModel @Inject constructor(
- private val favouritesRepository: FavouritesRepository,
-) : BaseViewModel() {
+ favouritesRepository: FavouritesRepository,
+) : KotatsuBaseViewModel() {
+
+ private val mangasStateFlow = favouritesRepository.observeAll(1)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+
private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+ val mangaCount = favouritesRepository.observeMangaCount()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+
+ val categories = categoriesStateFlow
+ .filterNotNull()
+ .mapItems { FavouriteTabModel(it.id, it.title, mangaCount.value ?: 0) }
+ .distinctUntilChanged()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+
+ val mangas = mangasStateFlow
+ .filterNotNull()
+ .mapItems { ShelfManga(it) }
+ .distinctUntilChanged()
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+
val isEmpty = categoriesStateFlow.map {
it?.isEmpty() == true
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
- init {
- launchJob(Dispatchers.Default) {
- mutableUiState.update {
- it.copy(
- categories = categoriesStateFlow.value ?: emptyList()
- )
- }
- }
- }
-
- override val mutableUiState = MutableStateFlow(ShelfUiState())
-
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt
deleted file mode 100644
index 6ae9d81..0000000
--- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.xtimms.tokusho.sections.shelf.ext
-
-import android.content.Context
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import org.xtimms.tokusho.R
-import org.xtimms.tokusho.core.model.ShelfCategory
-
-val ShelfCategory.visualName: String
- @Composable
- get() = when {
- isSystemCategory -> stringResource(R.string.label_default)
- else -> name
- }
-
-fun ShelfCategory.visualName(context: Context): String =
- when {
- isSystemCategory -> context.getString(R.string.label_default)
- else -> name
- }
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt b/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt
index 1b2c353..29e0c14 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt
@@ -19,8 +19,8 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shiki.ui.theme.colorMax
-import org.xtimms.shiki.ui.theme.colorMin
+import org.xtimms.tokusho.ui.theme.colorMax
+import org.xtimms.tokusho.ui.theme.colorMin
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.material.combineColors
import org.xtimms.tokusho.utils.material.harmonize
diff --git a/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt b/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt
index 9b4b499..bcf169b 100644
--- a/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt
+++ b/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
-import androidx.compose.material.icons.outlined.Label
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -22,13 +21,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shiki.ui.theme.colorMax
-import org.xtimms.shiki.ui.theme.colorMin
+import org.xtimms.tokusho.ui.theme.colorMax
+import org.xtimms.tokusho.ui.theme.colorMin
import org.xtimms.tokusho.R
import org.xtimms.tokusho.utils.material.combineColors
import org.xtimms.tokusho.utils.material.harmonize
import org.xtimms.tokusho.utils.material.toPalette
-import java.math.BigDecimal
@Composable
fun MinMaxReadCard(
diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt
index 57d0697..a3967d4 100644
--- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt
+++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt
@@ -1,4 +1,4 @@
-package org.xtimms.shiki.ui.theme
+package org.xtimms.tokusho.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt
index 280b1a0..1551ae6 100644
--- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt
+++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt
@@ -11,6 +11,7 @@ import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextDirection
@@ -19,6 +20,10 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.android.material.color.MaterialColors
import org.xtimms.tokusho.ui.monet.dynamicColorScheme
+fun Color.disabledIconOpacity(): Color {
+ return this.copy(alpha = 0.38f)
+}
+
fun Color.applyOpacity(enabled: Boolean): Color {
return if (enabled) this else this.copy(alpha = 0.62f)
}
@@ -41,7 +46,6 @@ private tailrec fun Context.findWindow(): Window? =
@Composable
fun TokushoTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
isHighContrastModeEnabled: Boolean = false,
isDynamicColorEnabled: Boolean = false,
content: @Composable () -> Unit
@@ -71,7 +75,7 @@ fun TokushoTheme(
) {
MaterialTheme(
colorScheme = colorScheme,
- typography = Typography,
+ typography = Typography(LocalContext.current),
content = content
)
}
@@ -83,7 +87,7 @@ fun PreviewThemeLight(
) {
MaterialTheme(
colorScheme = dynamicColorScheme(),
- typography = Typography,
+ typography = Typography(LocalContext.current),
content = content
)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt
index 0740336..9d9edea 100644
--- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt
+++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt
@@ -1,5 +1,6 @@
package org.xtimms.tokusho.ui.theme
+import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -12,30 +13,89 @@ import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-// Set of Material typography styles to start with
-val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
+fun Typography(context: Context): Typography {
+
+ fun getFont(weight: Int) = FontFamily(
+ Font(
+ "font/manrope_variable.ttf", context.assets,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(weight),
+ ),
+ )
)
-)
-val preferenceTitle = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 20.sp, lineHeight = 24.sp,
- lineBreak = LineBreak.Paragraph,
-)
+ return Typography(
+ displayLarge = TextStyle(
+ fontFamily = getFont(650),
+ fontSize = 57.sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = getFont(900),
+ fontSize = 45.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = getFont(800),
+ fontSize = 22.sp
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = getFont(800),
+ fontSize = 36.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = getFont(700),
+ fontSize = 28.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = getFont(650),
+ fontSize = 24.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = getFont(700),
+ fontSize = 22.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = getFont(600),
+ fontSize = 16.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = getFont(650),
+ fontSize = 14.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = getFont(700),
+ fontSize = 16.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = getFont(600),
+ fontSize = 14.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = getFont(500),
+ fontSize = 14.sp
+ ),
+ labelLarge = TextStyle(
+ fontFamily = getFont(700),
+ fontSize = 14.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = getFont(700),
+ fontSize = 12.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = getFont(600),
+ fontSize = 11.sp
+ )
+ )
+}
@Composable
fun FontCard(family: String, size: String, style: TextStyle) {
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt b/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt
new file mode 100644
index 0000000..40c794c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt
@@ -0,0 +1,63 @@
+package org.xtimms.tokusho.utils
+
+class AlphanumComparator : Comparator {
+
+ override fun compare(s1: String?, s2: String?): Int {
+ if (s1 == null || s2 == null) {
+ return 0
+ }
+ var thisMarker = 0
+ var thatMarker = 0
+ val s1Length = s1.length
+ val s2Length = s2.length
+ while (thisMarker < s1Length && thatMarker < s2Length) {
+ val thisChunk = getChunk(s1, s1Length, thisMarker)
+ thisMarker += thisChunk.length
+ val thatChunk = getChunk(s2, s2Length, thatMarker)
+ thatMarker += thatChunk.length
+ // If both chunks contain numeric characters, sort them numerically
+ var result: Int
+ if (thisChunk[0].isDigit() && thatChunk[0].isDigit()) { // Simple chunk comparison by length.
+ val thisChunkLength = thisChunk.length
+ result = thisChunkLength - thatChunk.length
+ // If equal, the first different number counts
+ if (result == 0) {
+ for (i in 0 until thisChunkLength) {
+ result = thisChunk[i] - thatChunk[i]
+ if (result != 0) {
+ return result
+ }
+ }
+ }
+ } else {
+ result = thisChunk.compareTo(thatChunk)
+ }
+ if (result != 0) return result
+ }
+ return s1Length - s2Length
+ }
+
+ private fun getChunk(s: String, slength: Int, cmarker: Int): String {
+ var marker = cmarker
+ val chunk = StringBuilder()
+ var c = s[marker]
+ chunk.append(c)
+ marker++
+ if (c.isDigit()) {
+ while (marker < slength) {
+ c = s[marker]
+ if (!c.isDigit()) break
+ chunk.append(c)
+ marker++
+ }
+ } else {
+ while (marker < slength) {
+ c = s[marker]
+ if (c.isDigit()) break
+ chunk.append(c)
+ marker++
+ }
+ }
+ return chunk.toString()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt
new file mode 100644
index 0000000..b7c2223
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt
@@ -0,0 +1,58 @@
+package org.xtimms.tokusho.utils
+
+import androidx.collection.ArrayMap
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.coroutines.coroutineContext
+
+@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2"))
+class CompositeMutex : Set {
+
+ private val state = ArrayMap>()
+ private val mutex = Mutex()
+
+ override val size: Int
+ get() = state.size
+
+ override fun contains(element: T): Boolean {
+ return state.containsKey(element)
+ }
+
+ override fun containsAll(elements: Collection): Boolean {
+ return elements.all { x -> state.containsKey(x) }
+ }
+
+ override fun isEmpty(): Boolean {
+ return state.isEmpty()
+ }
+
+ override fun iterator(): Iterator {
+ return state.keys.iterator()
+ }
+
+ suspend fun lock(element: T) {
+ while (coroutineContext.isActive) {
+ waitForRemoval(element)
+ mutex.withLock {
+ if (state[element] == null) {
+ state[element] = MutableStateFlow(false)
+ return
+ }
+ }
+ }
+ }
+
+ fun unlock(element: T) {
+ checkNotNull(state.remove(element)) {
+ "CompositeMutex is not locked for $element"
+ }.value = true
+ }
+
+ private suspend fun waitForRemoval(element: T) {
+ val flow = state[element] ?: return
+ flow.first { it }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt
new file mode 100644
index 0000000..c20edfe
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt
@@ -0,0 +1,43 @@
+package org.xtimms.tokusho.utils
+
+import androidx.collection.ArrayMap
+import kotlinx.coroutines.sync.Mutex
+
+class CompositeMutex2 : Set {
+
+ private val delegates = ArrayMap()
+
+ override val size: Int
+ get() = delegates.size
+
+ override fun contains(element: T): Boolean {
+ return delegates.containsKey(element)
+ }
+
+ override fun containsAll(elements: Collection): Boolean {
+ return elements.all { x -> delegates.containsKey(x) }
+ }
+
+ override fun isEmpty(): Boolean {
+ return delegates.isEmpty()
+ }
+
+ override fun iterator(): Iterator {
+ return delegates.keys.iterator()
+ }
+
+ suspend fun lock(element: T) {
+ val mutex = synchronized(delegates) {
+ delegates.getOrPut(element) {
+ Mutex()
+ }
+ }
+ mutex.lock()
+ }
+
+ fun unlock(element: T) {
+ synchronized(delegates) {
+ delegates.remove(element)?.unlock()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt
index a80de87..91c8dbf 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt
@@ -2,9 +2,10 @@ package org.xtimms.tokusho.utils
import android.content.Context
import android.os.Build
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.utils.lang.withNonCancellableContext
-import org.xtimms.tokusho.utils.lang.withUIContext
import org.xtimms.tokusho.utils.system.createFileInCacheDir
import org.xtimms.tokusho.utils.system.getUriCompat
import org.xtimms.tokusho.utils.system.toShareIntent
@@ -25,7 +26,7 @@ class CrashLogUtil(
val uri = file.getUriCompat(context)
context.startActivity(uri.toShareIntent(context, "text/plain"))
} catch (e: Throwable) {
- withUIContext { context.toast("Failed to get logs") }
+ withContext(Dispatchers.IO) { context.toast("Failed to get logs") }
}
}
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt b/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt
new file mode 100644
index 0000000..b4ed81d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt
@@ -0,0 +1,15 @@
+package org.xtimms.tokusho.utils
+
+import org.xtimms.tokusho.utils.iterator.CloseableIterator
+import org.xtimms.tokusho.utils.iterator.MappingIterator
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+
+class FileSequence(private val dir: File) : Sequence {
+
+ override fun iterator(): Iterator {
+ val stream = Files.newDirectoryStream(dir.toPath())
+ return CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt b/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt
new file mode 100644
index 0000000..e96eddd
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.utils
+
+import java.io.File
+
+fun hasImageExtension(string: String): Boolean {
+ val ext = string.substringAfterLast('.', "")
+ return ext.equals("png", ignoreCase = true) || ext.equals("jpg", ignoreCase = true)
+ || ext.equals("jpeg", ignoreCase = true) || ext.equals("webp", ignoreCase = true)
+}
+
+fun hasImageExtension(file: File) = hasImageExtension(file.name)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt b/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt
new file mode 100644
index 0000000..01be172
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt
@@ -0,0 +1,23 @@
+package org.xtimms.tokusho.utils
+
+import dagger.hilt.android.lifecycle.RetainedLifecycle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlin.coroutines.CoroutineContext
+
+class RetainedLifecycleCoroutineScope(
+ val lifecycle: RetainedLifecycle,
+) : CoroutineScope, RetainedLifecycle.OnClearedListener {
+
+ override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
+
+ init {
+ lifecycle.addOnClearedListener(this)
+ }
+
+ override fun onCleared() {
+ coroutineContext.cancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt b/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt
new file mode 100644
index 0000000..bd9a67f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt
@@ -0,0 +1,8 @@
+package org.xtimms.tokusho.utils
+
+import androidx.annotation.StringRes
+
+class ReversibleAction(
+ @StringRes val stringResId: Int,
+ val handle: ReversibleHandle?,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt b/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt
new file mode 100644
index 0000000..ddc91ad
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt
@@ -0,0 +1,21 @@
+package org.xtimms.tokusho.utils
+
+import android.os.Bundle
+import androidx.navigation.NavType
+import kotlinx.serialization.json.Json
+
+object StringArrayNavType : NavType>(isNullableAllowed = false) {
+
+ override fun get(bundle: Bundle, key: String): Array? {
+ return bundle.getStringArray(key)
+ }
+
+ override fun parseValue(value: String): Array {
+ return Json.decodeFromString(value)
+ }
+
+ override fun put(bundle: Bundle, key: String, value: Array) {
+ bundle.putStringArray(key, value)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt
new file mode 100644
index 0000000..0fbf111
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt
@@ -0,0 +1,21 @@
+package org.xtimms.tokusho.utils.composable
+
+import android.content.res.Resources
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmap
+
+@Composable
+fun rememberResourceBitmapPainter(@DrawableRes id: Int): BitmapPainter {
+ val context = LocalContext.current
+ return remember(id) {
+ val drawable = ContextCompat.getDrawable(context, id)
+ ?: throw Resources.NotFoundException()
+ BitmapPainter(drawable.toBitmap().asImageBitmap())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt
index 2fe6b9e..811aa20 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt
@@ -5,7 +5,10 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
/**
@@ -68,4 +71,60 @@ fun LazyGridState.onBottomReached(
snapshotFlow { shouldLoadMore.value }
.collect { if (it) onLoadMore() }
}
+}
+
+@Composable
+fun LazyListState.isScrolledToStart(): Boolean {
+ return remember {
+ derivedStateOf {
+ val firstItem = layoutInfo.visibleItemsInfo.firstOrNull()
+ firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset
+ }
+ }.value
+}
+
+@Composable
+fun LazyListState.isScrolledToEnd(): Boolean {
+ return remember {
+ derivedStateOf {
+ val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
+ lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
+ }
+ }.value
+}
+
+@Composable
+fun LazyListState.isScrollingUp(): Boolean {
+ var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) }
+ var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) }
+ return remember {
+ derivedStateOf {
+ if (previousIndex != firstVisibleItemIndex) {
+ previousIndex > firstVisibleItemIndex
+ } else {
+ previousScrollOffset >= firstVisibleItemScrollOffset
+ }.also {
+ previousIndex = firstVisibleItemIndex
+ previousScrollOffset = firstVisibleItemScrollOffset
+ }
+ }
+ }.value
+}
+
+@Composable
+fun LazyListState.isScrollingDown(): Boolean {
+ var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) }
+ var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) }
+ return remember {
+ derivedStateOf {
+ if (previousIndex != firstVisibleItemIndex) {
+ previousIndex < firstVisibleItemIndex
+ } else {
+ previousScrollOffset <= firstVisibleItemScrollOffset
+ }.also {
+ previousIndex = firstVisibleItemIndex
+ previousScrollOffset = firstVisibleItemScrollOffset
+ }
+ }
+ }.value
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt
index 3a342d2..a6de146 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt
@@ -3,12 +3,27 @@ package org.xtimms.tokusho.utils.composable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.drawBehind
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
+fun Modifier.selectedBackground(isSelected: Boolean): Modifier = if (isSelected) {
+ composed {
+ val alpha = if (isSystemInDarkTheme()) 0.16f else 0.22f
+ val color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
+ Modifier.drawBehind {
+ drawRect(color)
+ }
+ }
+} else {
+ this
+}
+
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
@OptIn(ExperimentalFoundationApi::class)
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt b/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt
new file mode 100644
index 0000000..8e3dec1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt
@@ -0,0 +1,36 @@
+package org.xtimms.tokusho.utils.iterator
+
+import okhttp3.internal.closeQuietly
+import okio.Closeable
+
+class CloseableIterator(
+ private val upstream: Iterator,
+ private val closeable: Closeable,
+) : Iterator, Closeable {
+
+ private var isClosed = false
+
+ override fun hasNext(): Boolean {
+ val result = upstream.hasNext()
+ if (!result) {
+ close()
+ }
+ return result
+ }
+
+ override fun next(): T {
+ try {
+ return upstream.next()
+ } catch (e: NoSuchElementException) {
+ close()
+ throw e
+ }
+ }
+
+ override fun close() {
+ if (!isClosed) {
+ closeable.closeQuietly()
+ isClosed = true
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt b/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt
new file mode 100644
index 0000000..98659db
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.utils.iterator
+
+class MappingIterator(
+ private val upstream: Iterator,
+ private val mapper: (T) -> R,
+) : Iterator {
+
+ override fun hasNext(): Boolean = upstream.hasNext()
+
+ override fun next(): R = mapper(upstream.next())
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt
new file mode 100644
index 0000000..7526919
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt
@@ -0,0 +1,16 @@
+package org.xtimms.tokusho.utils.lang
+
+import android.net.Uri
+import androidx.activity.result.ActivityResultLauncher
+import androidx.core.app.ActivityOptionsCompat
+
+fun ActivityResultLauncher.tryLaunch(
+ input: I,
+ options: ActivityOptionsCompat? = null,
+): Boolean = runCatching {
+ launch(input, options)
+}.onFailure { e ->
+ e.printStackTrace()
+}.isSuccess
+
+fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt
new file mode 100644
index 0000000..d4dbf9f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.utils.lang
+
+fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) {
+ this as ArrayList
+} else {
+ ArrayList(this)
+}
+
+fun Sequence.toListSorted(comparator: Comparator): List {
+ return toMutableList().apply { sortWith(comparator) }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt
index 865b627..722da55 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt
@@ -3,70 +3,35 @@ package org.xtimms.tokusho.utils.lang
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-
-/**
- * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
- *
- * **Possible replacements**
- * - suspend function
- * - custom scope like view or presenter scope
- */
-@DelicateCoroutinesApi
-fun launchUI(block: suspend CoroutineScope.() -> Unit): Job =
- GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block)
-
-/**
- * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
- *
- * **Possible replacements**
- * - suspend function
- * - custom scope like view or presenter scope
- */
-@DelicateCoroutinesApi
-fun launchIO(block: suspend CoroutineScope.() -> Unit): Job =
- GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block)
-
-/**
- * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used.
- *
- * **Possible replacements**
- * - suspend function
- * - custom scope like view or presenter scope
- */
-@DelicateCoroutinesApi
-fun launchNow(block: suspend CoroutineScope.() -> Unit): Job =
- GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block)
-
-fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
- launch(Dispatchers.Main, block = block)
-
-fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
- launch(Dispatchers.IO, block = block)
-
-fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job =
- launchIO { withContext(NonCancellable, block) }
-
-suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(
- Dispatchers.Main,
- block,
-)
-
-suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(
- Dispatchers.IO,
- block,
-)
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope
suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) =
withContext(NonCancellable, block)
val processLifecycleScope: LifecycleCoroutineScope
- inline get() = ProcessLifecycleOwner.get().lifecycleScope
\ No newline at end of file
+ inline get() = ProcessLifecycleOwner.get().lifecycleScope
+
+val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope
+ inline get() = RetainedLifecycleCoroutineScope(this)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+fun Deferred.peek(): T? = if (isCompleted) {
+ runCatchingCancellable {
+ getCompleted()
+ }.getOrNull()
+} else {
+ null
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt
new file mode 100644
index 0000000..02e285f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt
@@ -0,0 +1,37 @@
+package org.xtimms.tokusho.utils.lang
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.xtimms.tokusho.utils.Event
+
+fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) {
+ val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT
+ owner.lifecycleScope.launch(start = start) {
+ collect(collector)
+ }
+}
+
+fun Flow.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector) {
+ owner.lifecycleScope.launch {
+ owner.lifecycle.repeatOnLifecycle(minState) {
+ collect(collector)
+ }
+ }
+}
+
+fun Flow?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector) {
+ owner.lifecycleScope.launch {
+ owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ collect {
+ it?.consume(collector)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt
index ce71ae3..1a60d97 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt
@@ -19,4 +19,7 @@ inline val String.stringState
inline val String.intState
@Composable get() = remember {
mutableIntStateOf(this.getInt())
- }
\ No newline at end of file
+ }
+
+// clamp(3.5f, 6.7f) > [0.0f, 1.0f]
+fun Float.clamp(min: Float, max: Float): Float = (1f - ((this.coerceIn(min, max) - min) / (max - min)))
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt
index 39efea2..e1f2001 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt
@@ -25,4 +25,9 @@ fun CharSequence.sanitize(): CharSequence {
return filterNot { c -> c.isReplacement() }
}
-fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
\ No newline at end of file
+fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF'
+
+fun Float?.toStringPositiveValueOrUnknown() =
+ if (this == 0f) "─" else this.toStringOrUnknown()
+
+fun Float?.toStringOrUnknown() = this?.toString() ?: "─"
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt
new file mode 100644
index 0000000..68f091a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt
@@ -0,0 +1,11 @@
+package org.xtimms.tokusho.utils.lang
+
+import android.annotation.SuppressLint
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.await
+
+@SuppressLint("RestrictedApi")
+suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List {
+ return getWorkInfosForUniqueWork(name).await().orEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
index ef662af..e343b21 100644
--- a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
+++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt
@@ -2,12 +2,21 @@ package org.xtimms.tokusho.utils.system
import android.content.Context
import android.net.Uri
+import android.os.Build
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.withContext
import org.xtimms.tokusho.BuildConfig
+import org.xtimms.tokusho.utils.FileSequence
import java.io.File
+import java.io.FileFilter
+import java.nio.file.attribute.BasicFileAttributes
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
+import kotlin.io.path.PathWalkOption
+import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also {
@@ -21,10 +30,34 @@ fun File.getUriCompat(context: Context): Uri {
fun Context.getFileProvider() = "$packageName.provider"
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
- walkCompat().sumOf { it.length() }
+ walkCompat(includeDirectories = false).sumOf { it.length() }
}
@OptIn(ExperimentalPathApi::class)
-fun File.walkCompat() =
+fun File.walkCompat(includeDirectories: Boolean): Sequence {
// Use lazy loading on Android 8.0 and later
- toPath().walk().map { it.toFile() }
+ val walk = if (includeDirectories) {
+ toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES)
+ } else {
+ toPath().walk()
+ }
+ return walk.map { it.toFile() }
+}
+
+fun File.children() = FileSequence(this)
+
+suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
+ delete() || deleteRecursively()
+}
+
+val File.creationTime
+ get() = toPath().readAttributes().creationTime().toMillis()
+
+fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use {
+ it.readText()
+}
+
+fun Sequence.filterWith(filter: FileFilter): Sequence