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 = filter { f -> filter.accept(f) } + +fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } +fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt index 301e31c..5208d0c 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt @@ -43,4 +43,23 @@ private const val RUSSIAN = 2 // Sorted alphabetically val languageMap: Map = mapOf( RUSSIAN to "ru", -) \ No newline at end of file +) + +operator fun LocaleListCompat.iterator(): ListIterator = LocaleListCompatIterator(this) + +private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { + + private var index = 0 + + override fun hasNext() = index < list.size() + + override fun hasPrevious() = index > 0 + + override fun next() = list.get(index++) ?: throw NoSuchElementException() + + override fun nextIndex() = index + + override fun previous() = list.get(--index) ?: throw NoSuchElementException() + + override fun previousIndex() = index - 1 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt new file mode 100644 index 0000000..f55c270 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt @@ -0,0 +1,4 @@ +package org.xtimms.tokusho.utils.system + +const val URI_SCHEME_FILE = "file" +const val URI_SCHEME_ZIP = "file+zip" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt b/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt new file mode 100644 index 0000000..fc7711b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt @@ -0,0 +1,10 @@ +package org.xtimms.tokusho.work + +interface PeriodicWorkScheduler { + + suspend fun schedule() + + suspend fun unschedule() + + suspend fun isScheduled(): Boolean +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_error_outline_24.xml b/app/src/main/res/drawable/baseline_error_outline_24.xml new file mode 100644 index 0000000..7816afd --- /dev/null +++ b/app/src/main/res/drawable/baseline_error_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/cover_error.xml b/app/src/main/res/drawable/cover_error.xml new file mode 100644 index 0000000..cc25202 --- /dev/null +++ b/app/src/main/res/drawable/cover_error.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cover_loading.xml b/app/src/main/res/drawable/cover_loading.xml new file mode 100644 index 0000000..ae7ad1a --- /dev/null +++ b/app/src/main/res/drawable/cover_loading.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/roboto_flex_regular.ttf b/app/src/main/res/font/roboto_flex_regular.ttf new file mode 100644 index 0000000..f857ae9 Binary files /dev/null and b/app/src/main/res/font/roboto_flex_regular.ttf differ diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..fd1786e --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..08622b7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,4 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF + #1F888888 \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 36efbd3..64b1623 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -1,5 +1,9 @@ + + %1$d item + %1$d items + %1$d hour %1$d hours diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23ba5a7..a2767e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,4 +124,84 @@ Edit categories Updates Add + Category name + Add category + Name + Network + Proxy, DNS over HTTPS + Proxy + DNS over HTTPS + You have no categories + Delete + Rename category + Reading + Completed + Dropped + Popular + Manual + Disable NSFW + Disable NSFW sources and hide adult manga from list if possible + Manga sources + TODO + Backup and restore + Enable periodic backups + Backup creation frequency + Backup output directory + Actions + General + Create data backup + Restore from backup + You can create backup of your history and favourites and restore it + Restore previously created backup + You should keep copies of backups in other places as well. Backups may contain sensitive data, be careful if sharing. + Operation is not supported + Storage permission issue + Directories outside Download and Documents are not supported + History + Remote sources + Select backup file + No file selected + Backup or restore may not function properly if MIUI Optimization is disabled + Restore + The date the backup was created + Create backup file + Backup saved + Supports Kotatsu backups + Tokusho can handle Kotatsu backups as well + Open source licenses + No manga sources + Enable manga sources to read manga online + Catalog + %1$d of %2$d on + Sources catalog + Available: %1$d + What you read will be displayed here + Find what to read in the \"Explore\" section + Nothing found + Try to reformulate the query + It\'s kind of empty here + Manage sources + Ignore SSL errors + It may help in some cases + Show the number of manga in categories + Clear all + Description + Source enabled + There are no sources available in this section, or all of it might have been already added.\nStay tuned + No available manga sources found by your query + Bookmarked + Unread + Score + Read + Not in favourites + Manga + Comics + Hentai + Other + Related manga + Chapters + An error has occurred + In favourites + In shelf + Add to shelf \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index df42be7..ef81eb2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,10 @@ - \ No newline at end of file diff --git a/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt b/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt new file mode 100644 index 0000000..3101589 --- /dev/null +++ b/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt @@ -0,0 +1,98 @@ +package org.xtimms.tokusho.data.repository.backup + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.SortOrder +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.TagEntity +import java.util.concurrent.TimeUnit + +class JsonSerializerTest { + + @Test + fun toFavouriteEntity() { + val entity = FavouriteEntity( + mangaId = 40, + categoryId = 20, + sortKey = 1, + createdAt = System.currentTimeMillis(), + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toFavouriteEntity() + assertEquals(entity, result) + } + + @Test + fun toMangaEntity() { + val entity = MangaEntity( + id = 231, + title = "Lorem Ipsum", + altTitle = "Lorem Ispum 2", + url = "erw", + publicUrl = "hthth", + rating = 0.78f, + isNsfw = true, + coverUrl = "5345", + largeCoverUrl = null, + state = MangaState.FINISHED.name, + author = "RERE", + source = MangaSource.DUMMY.name, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toMangaEntity() + assertEquals(entity, result) + } + + @Test + fun toTagEntity() { + val entity = TagEntity( + id = 934023534, + title = "Adventure", + key = "adventure", + source = MangaSource.DUMMY.name, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toTagEntity() + assertEquals(entity, result) + } + + @Test + fun toHistoryEntity() { + val entity = HistoryEntity( + mangaId = 304135341, + createdAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6), + updatedAt = System.currentTimeMillis(), + chapterId = 29014843034, + page = 35, + scroll = 24.0f, + percent = 0.6f, + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toHistoryEntity() + assertEquals(entity, result) + } + + @Test + fun toFavouriteCategoryEntity() { + val entity = FavouriteCategoryEntity( + categoryId = 142, + createdAt = System.currentTimeMillis(), + sortKey = 14, + title = "Read later", + order = SortOrder.RATING.name, + track = false, + isVisibleInLibrary = true, + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toFavouriteCategoryEntity() + assertEquals(entity, result) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a1c2009..2ebca75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("org.jetbrains.kotlin.android") version "1.9.0" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" apply false id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false + id("com.mikepenz.aboutlibraries.plugin") version "10.10.0" apply false } buildscript {