diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
index 23b2e1f..b444d48 100644
--- a/.idea/appInsightsSettings.xml
+++ b/.idea/appInsightsSettings.xml
@@ -1,6 +1,41 @@
-
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
index 094329c..0c0c338 100644
--- a/.idea/deploymentTargetDropDown.xml
+++ b/.idea/deploymentTargetDropDown.xml
@@ -2,13 +2,7 @@
-
-
-
-
-
-
-
+
diff --git a/.idea/other.xml b/.idea/other.xml
index f3d4a2e..d95a0f7 100644
--- a/.idea/other.xml
+++ b/.idea/other.xml
@@ -3,4 +3,280 @@
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index d2e906a..c481351 100644
--- a/README.md
+++ b/README.md
@@ -15,16 +15,17 @@ Shirizu (シリーズ, from Japanese - series) - An attempt to write an Android
No, nothing works.
## Screens
-| Shelf | Details | History |
-|:--------------------------------------------:|:------------------------------------------------:|:------------------------------------------------:|
-|  |  |  |
-|  |  |  |
+| Library | Explore | Search |
+|:------------------------------------------:|:--------------------------------------------:|:-------------------------------------------:|
+|  |  |  |
## Acknowledgements
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu) - UI, parsers, under the hood
+- [Mihon](https://github.com/mihonapp/mihon) - UI, under the hood
- [Seal](https://github.com/JunkFood02/Seal) - UI
-- [MoeList](https://github.com/axiel7/MoeList) - Under the hood
+- [Tivi](https://github.com/chrisbanes/tivi) - UI
+- [Buckwheat](https://github.com/danilkinkin/buckwheat) - UI
## License
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1d75f59..56035c9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -20,6 +20,11 @@ val acraAuthLogin: String =
val acraAuthPassword: String =
gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
+val shikimoriClientId: String =
+ gradleLocalProperties(rootDir).getProperty("shikimoriClientId") ?: "\"shikimori\""
+val shikimoriClientSecret: String =
+ gradleLocalProperties(rootDir).getProperty("shikimoriClientSecret") ?: "\"shikimori\""
+
android {
namespace = "org.xtimms.shirizu"
compileSdk = 34
@@ -39,6 +44,9 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
+ buildConfigField("String", "SHIKIMORI_CLIENT_ID", shikimoriClientId)
+ buildConfigField("String", "SHIKIMORI_CLIENT_SECRET", shikimoriClientSecret)
+
testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
@@ -107,32 +115,52 @@ android {
androidResources {
generateLocaleConfig = true
}
+ kapt {
+ correctErrorTypes = true
+ }
}
dependencies {
+
+ // AndroidX
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("dev.chrisbanes.compose:compose-bom:2024.03.00-alpha01"))
+ implementation(platform("dev.chrisbanes.compose:compose-bom:2024.05.00-alpha02"))
implementation("androidx.compose.animation:animation-graphics")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-text-google-fonts")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.5")
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.paging:paging-runtime-ktx:3.2.1")
+ implementation("androidx.paging:paging-compose:3.2.1")
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")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.room:room-testing:2.6.1")
+ implementation("androidx.webkit:webkit:1.11.0")
ksp("androidx.room:room-compiler:2.6.1")
+
+ // Navigation
+ implementation("cafe.adriel.voyager:voyager-navigator:1.1.0-alpha04")
+ implementation("cafe.adriel.voyager:voyager-hilt:1.1.0-alpha04")
+ implementation("cafe.adriel.voyager:voyager-screenmodel:1.1.0-alpha04")
+ implementation("cafe.adriel.voyager:voyager-tab-navigator:1.1.0-alpha04")
+ implementation("cafe.adriel.voyager:voyager-transitions:1.1.0-alpha04")
+
+ // Motion
+ implementation("io.github.fornewid:material-motion-compose-core:1.2.0")
+
implementation("ch.acra:acra-http:5.9.7")
implementation("com.github.solkin:disk-lru-cache:1.4")
implementation("com.google.android.material:material:1.11.0")
@@ -141,11 +169,11 @@ dependencies {
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
- implementation("com.google.dagger:hilt-android:2.51")
- kapt("com.google.dagger:hilt-compiler:2.51")
+ implementation("com.google.dagger:hilt-android:2.51.1")
+ kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler:1.2.0")
- implementation("com.github.KotatsuApp:kotatsu-parsers:fec60955ed") {
+ implementation("com.github.KotatsuApp:kotatsu-parsers:7d2f5696f5") {
exclude(group = "org.json", module = "json")
}
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
@@ -164,10 +192,11 @@ dependencies {
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")
+ androidTestImplementation("com.google.dagger:hilt-android-testing:2.51.1")
+ kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
+ debugImplementation("com.github.koitharu:workinspector:5778dd1747")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
@@ -178,6 +207,11 @@ androidComponents {
variantBuilder.enable = variantBuilder.productFlavors.containsAll(listOf("default" to "dev"))
}
}
+ onVariants(selector().withFlavor("default" to "standard")) {
+ // Only excluding in standard flavor because this breaks
+ // Layout Inspector's Compose tree
+ it.packaging.resources.excludes.add("META-INF/*.version")
+ }
}
// Git is needed in your system PATH for these commands to work.
diff --git a/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/2.json b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/2.json
new file mode 100644
index 0000000..6b59bf7
--- /dev/null
+++ b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/2.json
@@ -0,0 +1,793 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "37a2caa74779de4fe989f19a8cb1eaf6",
+ "entities": [
+ {
+ "tableName": "manga",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altTitle",
+ "columnName": "alt_title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicUrl",
+ "columnName": "public_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isNsfw",
+ "columnName": "nsfw",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "largeCoverUrl",
+ "columnName": "large_cover_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tag_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "manga_tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "tag_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_manga_tags_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_manga_tags_tag_id",
+ "unique": false,
+ "columnNames": [
+ "tag_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "tags",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "tag_id"
+ ],
+ "referencedColumns": [
+ "tag_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sources",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
+ "fields": [
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sources_sort_key",
+ "unique": false,
+ "columnNames": [
+ "sort_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "category_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favourites_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_favourites_category_id",
+ "unique": false,
+ "columnNames": [
+ "category_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "favourite_categories",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "category_id"
+ ],
+ "referencedColumns": [
+ "category_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourite_categories",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isVisibleInLibrary",
+ "columnName": "show_in_lib",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "category_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "bookmarks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageId",
+ "columnName": "page_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "page_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_bookmarks_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_bookmarks_page_id",
+ "unique": false,
+ "columnNames": [
+ "page_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "suggestions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relevance",
+ "columnName": "relevance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_suggestions_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `chapters_total` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check` INTEGER NOT NULL, `last_notified_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalChapters",
+ "columnName": "chapters_total",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChapterId",
+ "columnName": "last_chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "newChapters",
+ "columnName": "chapters_new",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastCheck",
+ "columnName": "last_check",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastNotifiedChapterId",
+ "columnName": "last_notified_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "track_logs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapters",
+ "columnName": "chapters",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_track_logs_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "stats",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startedAt",
+ "columnName": "started_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pages",
+ "columnName": "pages",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "started_at"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "history",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37a2caa74779de4fe989f19a8cb1eaf6')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/3.json b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/3.json
new file mode 100644
index 0000000..b77a9b4
--- /dev/null
+++ b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/3.json
@@ -0,0 +1,857 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081",
+ "entities": [
+ {
+ "tableName": "manga",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altTitle",
+ "columnName": "alt_title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicUrl",
+ "columnName": "public_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isNsfw",
+ "columnName": "nsfw",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "largeCoverUrl",
+ "columnName": "large_cover_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tag_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "manga_tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "tag_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_manga_tags_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_manga_tags_tag_id",
+ "unique": false,
+ "columnNames": [
+ "tag_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "tags",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "tag_id"
+ ],
+ "referencedColumns": [
+ "tag_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sources",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
+ "fields": [
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sources_sort_key",
+ "unique": false,
+ "columnNames": [
+ "sort_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "category_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favourites_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_favourites_category_id",
+ "unique": false,
+ "columnNames": [
+ "category_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "favourite_categories",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "category_id"
+ ],
+ "referencedColumns": [
+ "category_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourite_categories",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isVisibleInLibrary",
+ "columnName": "show_in_lib",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "category_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "bookmarks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageId",
+ "columnName": "page_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "page_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_bookmarks_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_bookmarks_page_id",
+ "unique": false,
+ "columnNames": [
+ "page_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "suggestions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relevance",
+ "columnName": "relevance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_suggestions_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChapterId",
+ "columnName": "last_chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "newChapters",
+ "columnName": "chapters_new",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastCheckTime",
+ "columnName": "last_check_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChapterDate",
+ "columnName": "last_chapter_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastResult",
+ "columnName": "last_result",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "track_logs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapters",
+ "columnName": "chapters",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_track_logs_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "stats",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startedAt",
+ "columnName": "started_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pages",
+ "columnName": "pages",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "started_at"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "history",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "scrobblings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scrobbler` INTEGER NOT NULL, `id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `target_id` INTEGER NOT NULL, `status` TEXT, `chapter` INTEGER NOT NULL, `comment` TEXT, `rating` REAL NOT NULL, PRIMARY KEY(`scrobbler`, `id`, `manga_id`))",
+ "fields": [
+ {
+ "fieldPath": "scrobbler",
+ "columnName": "scrobbler",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "target_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "chapter",
+ "columnName": "chapter",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "comment",
+ "columnName": "comment",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "scrobbler",
+ "id",
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbe1dcac0f49c5ae2ac88d88aa280081')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/4.json b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/4.json
new file mode 100644
index 0000000..60e68c2
--- /dev/null
+++ b/app/schemas/org.xtimms.shirizu.core.database.ShirizuDatabase/4.json
@@ -0,0 +1,857 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 4,
+ "identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081",
+ "entities": [
+ {
+ "tableName": "manga",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "altTitle",
+ "columnName": "alt_title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicUrl",
+ "columnName": "public_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isNsfw",
+ "columnName": "nsfw",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "largeCoverUrl",
+ "columnName": "large_cover_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "state",
+ "columnName": "state",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tag_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "manga_tags",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tagId",
+ "columnName": "tag_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "tag_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_manga_tags_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_manga_tags_tag_id",
+ "unique": false,
+ "columnNames": [
+ "tag_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "tags",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "tag_id"
+ ],
+ "referencedColumns": [
+ "tag_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "sources",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
+ "fields": [
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isEnabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sources_sort_key",
+ "unique": false,
+ "columnNames": [
+ "sort_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "category_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favourites_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_favourites_category_id",
+ "unique": false,
+ "columnNames": [
+ "category_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ },
+ {
+ "table": "favourite_categories",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "category_id"
+ ],
+ "referencedColumns": [
+ "category_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "favourite_categories",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortKey",
+ "columnName": "sort_key",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "order",
+ "columnName": "order",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isVisibleInLibrary",
+ "columnName": "show_in_lib",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deletedAt",
+ "columnName": "deleted_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "category_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "bookmarks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageId",
+ "columnName": "page_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapterId",
+ "columnName": "chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "page",
+ "columnName": "page",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "scroll",
+ "columnName": "scroll",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "percent",
+ "columnName": "percent",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "page_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_bookmarks_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ },
+ {
+ "name": "index_bookmarks_page_id",
+ "unique": false,
+ "columnNames": [
+ "page_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "suggestions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "relevance",
+ "columnName": "relevance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_suggestions_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChapterId",
+ "columnName": "last_chapter_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "newChapters",
+ "columnName": "chapters_new",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastCheckTime",
+ "columnName": "last_check_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChapterDate",
+ "columnName": "last_chapter_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastResult",
+ "columnName": "last_result",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "track_logs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chapters",
+ "columnName": "chapters",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_track_logs_manga_id",
+ "unique": false,
+ "columnNames": [
+ "manga_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "manga",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "stats",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startedAt",
+ "columnName": "started_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pages",
+ "columnName": "pages",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "manga_id",
+ "started_at"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "history",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "manga_id"
+ ],
+ "referencedColumns": [
+ "manga_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "scrobblings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scrobbler` INTEGER NOT NULL, `id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `target_id` INTEGER NOT NULL, `status` TEXT, `chapter` INTEGER NOT NULL, `comment` TEXT, `rating` REAL NOT NULL, PRIMARY KEY(`scrobbler`, `id`, `manga_id`))",
+ "fields": [
+ {
+ "fieldPath": "scrobbler",
+ "columnName": "scrobbler",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mangaId",
+ "columnName": "manga_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "target_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "chapter",
+ "columnName": "chapter",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "comment",
+ "columnName": "comment",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "rating",
+ "columnName": "rating",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "scrobbler",
+ "id",
+ "manga_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbe1dcac0f49c5ae2ac88d88aa280081')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 95550c2..7f8e92a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
@@ -35,6 +36,7 @@
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
+ android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/Theme.Shirizu"
tools:targetApi="tiramisu">
@@ -50,11 +52,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @Inject
+ lateinit var imageLoader: ImageLoader
+
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
diff --git a/app/src/main/java/org/xtimms/shirizu/CompositionLocals.kt b/app/src/main/java/org/xtimms/shirizu/CompositionLocals.kt
index 3a41765..afc8a50 100644
--- a/app/src/main/java/org/xtimms/shirizu/CompositionLocals.kt
+++ b/app/src/main/java/org/xtimms/shirizu/CompositionLocals.kt
@@ -13,10 +13,14 @@ 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 coil.ImageLoader
+import org.xtimms.shirizu.core.logs.FileLogger
import org.xtimms.shirizu.ui.theme.SEED
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.DarkThemePreference
import org.xtimms.shirizu.core.prefs.paletteStyles
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
import org.xtimms.shirizu.ui.monet.LocalTonalPalettes
import org.xtimms.shirizu.ui.monet.PaletteStyle
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
@@ -32,9 +36,14 @@ val LocalDynamicColorSwitch = compositionLocalOf { false }
val LocalPaletteStyleIndex = compositionLocalOf { 0 }
val LocalWindowInsets = compositionLocalOf { PaddingValues(0.dp) }
val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact }
+val LocalImageLoader = compositionLocalOf { error("No ImageLoader provided") }
+val LocalLoggers = compositionLocalOf> { error("No file loggers provided") }
+
+val LocalKitsuRepository = compositionLocalOf { error("No KitsuRepository provided") }
+val LocalShikimoriRepository = compositionLocalOf { error("No ShikimoriRepository provided") }
@Composable
-fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) {
+fun SettingsProvider(content: @Composable () -> Unit) {
AppSettings.AppSettingsStateFlow.collectAsState().value.run {
CompositionLocalProvider(
LocalDarkTheme provides darkTheme,
@@ -46,7 +55,6 @@ fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Compo
else Color(seedColor).toTonalPalettes(
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
),
- LocalWindowWidthState provides windowWidthSizeClass,
LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content
)
diff --git a/app/src/main/java/org/xtimms/shirizu/MainActivity.kt b/app/src/main/java/org/xtimms/shirizu/MainActivity.kt
index df15f7d..2df5ef6 100644
--- a/app/src/main/java/org/xtimms/shirizu/MainActivity.kt
+++ b/app/src/main/java/org/xtimms/shirizu/MainActivity.kt
@@ -7,84 +7,120 @@ import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
-import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.calculateEndPadding
-import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.layout.systemBars
-import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.RssFeed
+import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.surfaceColorAtElevation
-import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
-import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
-import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLayoutDirection
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.util.fastForEach
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
+import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
+import cafe.adriel.voyager.navigator.tab.TabNavigator
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import org.xtimms.shirizu.core.Navigation
-import org.xtimms.shirizu.core.components.BottomNavBar
-import org.xtimms.shirizu.core.components.ContinueReadingButton
-import org.xtimms.shirizu.core.components.NavigationRail
-import org.xtimms.shirizu.core.components.TopAppBar
+import org.xtimms.shirizu.core.components.AppBar
+import org.xtimms.shirizu.core.components.AppBarActions
+import org.xtimms.shirizu.core.components.AppToolbar
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.icons.Creation
import org.xtimms.shirizu.core.logs.FileLogger
+import org.xtimms.shirizu.core.onboarding.OnboardingScreen
import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
import org.xtimms.shirizu.core.ui.dialogs.UpdateDialogImpl
import org.xtimms.shirizu.core.updates.Updater
+import org.xtimms.shirizu.sections.explore.ExploreTab
+import org.xtimms.shirizu.sections.feed.FeedScreen
+import org.xtimms.shirizu.sections.history.HistoryTab
+import org.xtimms.shirizu.sections.library.LibraryTab
+import org.xtimms.shirizu.sections.onboarding.OnboardingScreen
+import org.xtimms.shirizu.sections.search.SearchTab
+import org.xtimms.shirizu.sections.settings.SettingsScreen
+import org.xtimms.shirizu.sections.shelf.ShelfTab
+import org.xtimms.shirizu.sections.suggestions.SuggestionsScreen
import org.xtimms.shirizu.ui.theme.ShirizuTheme
+import org.xtimms.shirizu.utils.lang.DefaultNavigatorScreenTransition
+import org.xtimms.shirizu.utils.lang.NoLiftingAppBarScreen
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.isTabletUi
+import org.xtimms.shirizu.utils.lang.materialSharedAxisX
import org.xtimms.shirizu.utils.system.setLanguage
import org.xtimms.shirizu.utils.system.suspendToast
import javax.inject.Inject
-@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val isReady: MutableState = mutableStateOf(false)
private val isDone: MutableState = mutableStateOf(false)
+ private var navigator: Navigator? = null
+
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
+ @Inject
+ lateinit var shikimoriRepository: ShikimoriRepository
+
+ @Inject
+ lateinit var kitsuRepository: KitsuRepository
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !isDone.value }
enableEdgeToEdge()
@@ -110,10 +146,6 @@ class MainActivity : ComponentActivity() {
var showUpdateDialog by rememberSaveable { mutableStateOf(false) }
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) }
- val navController = rememberNavController()
- val windowSizeClass = calculateWindowSizeClass(this)
- val isCompactScreen = LocalWindowWidthState.current == WindowWidthSizeClass.Compact
-
val settings =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
Updater.installLatestApk(context)
@@ -141,63 +173,97 @@ class MainActivity : ComponentActivity() {
isReady.value = true
}
if (isReady.value) {
- SettingsProvider(windowSizeClass.widthSizeClass) {
- ShirizuTheme(
- 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
- }
- LaunchedEffect(Unit) {
- if (!AppSettings.isAutoUpdateEnabled())
- return@LaunchedEffect
- launch(Dispatchers.IO) {
- runCatching {
- Updater.checkForUpdate(context)?.let {
- latestRelease = it
- showUpdateDialog = true
+ CompositionLocalProvider(
+ LocalImageLoader provides coil,
+ LocalLoggers provides loggers,
+ LocalShikimoriRepository provides shikimoriRepository,
+ LocalKitsuRepository provides kitsuRepository
+ ) {
+ SettingsProvider {
+ ShirizuTheme(
+ darkTheme = LocalDarkTheme.current.isDarkTheme(),
+ isDynamicColorEnabled = LocalDynamicColorSwitch.current,
+ isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
+ ) {
+ Navigator(
+ screen = MainScreen,
+ disposeBehavior = NavigatorDisposeBehavior(
+ disposeNestedNavigators = false,
+ disposeSteps = true
+ ),
+ ) { navigator ->
+ LaunchedEffect(navigator) {
+ this@MainActivity.navigator = navigator
+ }
+
+ val scaffoldInsets =
+ WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
+ Scaffold(
+ contentWindowInsets = scaffoldInsets,
+ ) { contentPadding ->
+ // Consume insets already used by app state banners
+ Box(
+ modifier = Modifier
+ .padding(contentPadding)
+ .consumeWindowInsets(contentPadding),
+ ) {
+ // Shows current screen
+ DefaultNavigatorScreenTransition(navigator = navigator)
}
- }.onFailure {
- it.printStackTrace()
}
+
+ // ShowOnboarding()
}
- }
- if (showUpdateDialog) {
- UpdateDialogImpl(
- onDismissRequest = {
- showUpdateDialog = false
- updateJob?.cancel()
- },
- title = latestRelease.name.toString(),
- onConfirmUpdate = {
- updateJob = scope.launch(Dispatchers.IO) {
- runCatching {
- Updater.downloadApk(context, latestRelease)
- .collect { downloadStatus ->
- currentDownloadStatus = downloadStatus
- if (downloadStatus is Updater.DownloadStatus.Finished) {
- launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
- }
- }
- }.onFailure {
- it.printStackTrace()
- currentDownloadStatus = Updater.DownloadStatus.NotYet
- context.suspendToast(R.string.app_update_failed)
- return@launch
+
+ LaunchedEffect(Unit) {
+ isDone.value = true
+ }
+
+ LaunchedEffect(Unit) {
+ if (!AppSettings.isAutoUpdateEnabled())
+ return@LaunchedEffect
+ launch(Dispatchers.IO) {
+ runCatching {
+ Updater.checkForUpdate(context)?.let {
+ latestRelease = it
+ showUpdateDialog = true
}
+ }.onFailure {
+ it.printStackTrace()
}
- },
- releaseNote = latestRelease.body.toString(),
- downloadStatus = currentDownloadStatus
- )
+ }
+ }
+
+ if (showUpdateDialog) {
+ UpdateDialogImpl(
+ onDismissRequest = {
+ showUpdateDialog = false
+ updateJob?.cancel()
+ },
+ title = latestRelease.name.toString(),
+ onConfirmUpdate = {
+ updateJob = scope.launch(Dispatchers.IO) {
+ runCatching {
+ Updater.downloadApk(context, latestRelease)
+ .collect { downloadStatus ->
+ currentDownloadStatus = downloadStatus
+ if (downloadStatus is Updater.DownloadStatus.Finished) {
+ launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
+ }
+ }
+ }.onFailure {
+ it.printStackTrace()
+ currentDownloadStatus =
+ Updater.DownloadStatus.NotYet
+ context.suspendToast(R.string.app_update_failed)
+ return@launch
+ }
+ }
+ },
+ releaseNote = latestRelease.body.toString(),
+ downloadStatus = currentDownloadStatus
+ )
+ }
}
}
}
@@ -206,7 +272,18 @@ class MainActivity : ComponentActivity() {
putDataToExtras(intent)
}
- override fun onNewIntent(intent: Intent?) {
+ @Composable
+ private fun ShowOnboarding() {
+ val navigator = LocalNavigator.currentOrThrow
+
+ LaunchedEffect(Unit) {
+ if (navigator.lastItem !is OnboardingScreen) {
+ navigator.push(OnboardingScreen())
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
putDataToExtras(intent)
super.onNewIntent(intent)
}
@@ -222,93 +299,214 @@ class MainActivity : ComponentActivity() {
}
@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun MainView(
- coil: ImageLoader,
- loggers: Set,
- isCompactScreen: Boolean,
- navController: NavHostController,
-) {
- val bottomBarState = remember { mutableStateOf(true) }
- val topBarOffsetY = remember { Animatable(0f) }
-
- val scroll = rememberLazyListState()
-
- Scaffold(
- topBar = {
- if (isCompactScreen) {
- val isScrolled by remember {
- derivedStateOf { scroll.firstVisibleItemScrollOffset > 0 }
+object MainScreen : Screen() {
+
+ private val librarySearchEvent = Channel()
+ private val openTabEvent = Channel()
+ private val showBottomNavEvent = Channel()
+
+ private const val TabNavigatorKey = "HomeTabs"
+
+ private val tabs = listOf(
+ LibraryTab(),
+ // ShelfTab,
+ // HistoryTab,
+ ExploreTab(),
+ SearchTab
+ )
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ TabNavigator(
+ tab = LibraryTab(),
+ key = TabNavigatorKey,
+ ) { tabNavigator ->
+ // Provide usable navigator to content screen
+ CompositionLocalProvider(LocalNavigator provides navigator) {
+ Scaffold(
+ topBar = { scrollBehavior ->
+ if (!isTabletUi()) {
+ AppToolbar(
+ actions = {
+ AppBarActions(
+ persistentListOf(
+ AppBar.Action(
+ title = stringResource(R.string.suggestions),
+ icon = Icons.Outlined.Creation,
+ onClick = {
+ navigator.push(SuggestionsScreen)
+ },
+ ),
+ AppBar.Action(
+ title = stringResource(R.string.feed),
+ icon = Icons.Outlined.RssFeed,
+ onClick = {
+ navigator.push(FeedScreen)
+ },
+ ),
+ AppBar.Action(
+ title = stringResource(R.string.settings),
+ icon = Icons.Outlined.Settings,
+ onClick = {
+ navigator.push(SettingsScreen)
+ },
+ ),
+ ),
+ )
+ },
+ scrollBehavior = if (tabNavigator.current is NoLiftingAppBarScreen) {
+ null
+ } else scrollBehavior
+ )
+ }
+ },
+ startBar = {
+ if (isTabletUi()) {
+ NavigationRail {
+ tabs.fastForEach {
+ NavigationRailItem(it)
+ }
+ }
+ }
+ },
+ bottomBar = {
+ if (!isTabletUi()) {
+ val bottomNavVisible by produceState(initialValue = true) {
+ showBottomNavEvent.receiveAsFlow().collectLatest { value = it }
+ }
+ AnimatedVisibility(
+ visible = bottomNavVisible,
+ enter = expandVertically(),
+ exit = shrinkVertically(),
+ ) {
+ NavigationBar {
+ tabs.fastForEach {
+ NavigationBarItem(it)
+ }
+ }
+ }
+ }
+ },
+ contentWindowInsets = WindowInsets(0),
+ ) { contentPadding ->
+ Box(
+ modifier = Modifier
+ .padding(contentPadding)
+ .consumeWindowInsets(contentPadding),
+ ) {
+ AnimatedContent(
+ targetState = tabNavigator.current,
+ transitionSpec = {
+ materialSharedAxisX(forward = true)
+ },
+ label = "tabContent",
+ ) {
+ tabNavigator.saveableState(key = "currentTab", it) {
+ it.Content()
+ }
+ }
+ }
}
- val animatedBgAlpha by animateFloatAsState(
- if (isScrolled) 1f else 0f,
- label = "Top Bar Background",
- )
- val animatedSearchBarColor by animateColorAsState(
- if (isScrolled) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
- label = "Top Bar Background",
- )
- TopAppBar(
- navController = navController,
- modifier = Modifier
- .statusBarsPadding()
- .padding(0.dp, 16.dp),
- backgroundAlphaProvider = { animatedBgAlpha },
- searchBarColorProvider = { animatedSearchBarColor }
- )
}
- },
- bottomBar = {
- if (isCompactScreen) {
- BottomNavBar(
- navController = navController,
- bottomBarState = bottomBarState,
- topBarOffsetY = topBarOffsetY,
- )
+
+ val goToLibraryTab = { tabNavigator.current = LibraryTab() }
+ BackHandler(
+ enabled = tabNavigator.current != LibraryTab(),
+ onBack = goToLibraryTab,
+ )
+
+ LaunchedEffect(Unit) {
+ launch {
+ openTabEvent.receiveAsFlow().collectLatest {
+ tabNavigator.current = when (it) {
+ is Tab.Library -> LibraryTab()
+ // is Tab.Shelf -> ShelfTab
+ // is Tab.History -> HistoryTab
+ is Tab.Explore -> ExploreTab()
+ is Tab.Search -> SearchTab
+ }
+ }
+ }
}
- },
- floatingActionButton = {
- ContinueReadingButton(navController = navController)
- },
- contentWindowInsets = WindowInsets.systemBars
- .only(WindowInsetsSides.Horizontal)
- ) { padding ->
- if (!isCompactScreen) {
- val systemBarsPadding = WindowInsets.systemBars.asPaddingValues()
- Row(
- modifier = Modifier.padding(padding)
- ) {
- NavigationRail(
- navController = navController
+ }
+ }
+
+ @Composable
+ private fun RowScope.NavigationBarItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
+ val tabNavigator = LocalTabNavigator.current
+ val navigator = LocalNavigator.currentOrThrow
+ val scope = rememberCoroutineScope()
+ val selected = tabNavigator.current::class == tab::class
+ NavigationBarItem(
+ selected = selected,
+ onClick = {
+ if (!selected) {
+ tabNavigator.current = tab
+ } else {
+ scope.launch { tab.onReselect(navigator) }
+ }
+ },
+ icon = { NavigationIconItem(tab) },
+ label = {
+ Text(
+ text = tab.options.title,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
- Navigation(
- coil = coil,
- loggers = loggers,
- navController = navController,
- isCompactScreen = false,
- modifier = Modifier.nestedScroll(TopAppBarDefaults.pinnedScrollBehavior().nestedScrollConnection),
- padding = PaddingValues(
- start = padding.calculateStartPadding(LocalLayoutDirection.current),
- top = systemBarsPadding.calculateTopPadding(),
- end = padding.calculateEndPadding(LocalLayoutDirection.current),
- bottom = systemBarsPadding.calculateBottomPadding()
- ),
- listState = scroll
+ },
+ alwaysShowLabel = true,
+ )
+ }
+
+ @Composable
+ fun NavigationRailItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
+ val tabNavigator = LocalTabNavigator.current
+ val navigator = LocalNavigator.currentOrThrow
+ val scope = rememberCoroutineScope()
+ val selected = tabNavigator.current::class == tab::class
+ NavigationRailItem(
+ selected = selected,
+ onClick = {
+ if (!selected) {
+ tabNavigator.current = tab
+ } else {
+ scope.launch { tab.onReselect(navigator) }
+ }
+ },
+ icon = { NavigationIconItem(tab) },
+ label = {
+ Text(
+ text = tab.options.title,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
- }
- } else {
- Navigation(
- coil = coil,
- loggers = loggers,
- navController = navController,
- isCompactScreen = true,
- modifier = Modifier.padding(
- start = padding.calculateStartPadding(LocalLayoutDirection.current),
- end = padding.calculateEndPadding(LocalLayoutDirection.current),
- ),
- padding = padding,
- listState = scroll
- )
- }
+ },
+ alwaysShowLabel = true,
+ )
+ }
+
+ @Composable
+ private fun NavigationIconItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
+ Icon(
+ painter = tab.options.icon!!,
+ contentDescription = tab.options.title,
+ // TODO: https://issuetracker.google.com/u/0/issues/316327367
+ tint = LocalContentColor.current,
+ )
+ }
+
+ suspend fun showBottomNav(show: Boolean) {
+ showBottomNavEvent.send(show)
+ }
+
+ sealed interface Tab {
+ data object Library : Tab
+ // data object Shelf : Tab
+ // data object History : Tab
+ data object Explore : Tab
+ data object Search : Tab
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/AsyncImageImpl.kt b/app/src/main/java/org/xtimms/shirizu/core/AsyncImageImpl.kt
deleted file mode 100644
index 3f3236e..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/AsyncImageImpl.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.xtimms.shirizu.core
-
-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.shirizu.R
-import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
-
-@Composable
-fun AsyncImageImpl(
- coil: ImageLoader,
- model: Any? = null,
- contentDescription: String?,
- modifier: Modifier = Modifier,
- transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
- onState: ((AsyncImagePainter.State) -> Unit)? = null,
- alignment: Alignment = Alignment.Center,
- contentScale: ContentScale = ContentScale.Crop,
- colorFilter: ColorFilter? = null,
- filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
- isPreview: Boolean = false,
-) {
- if (isPreview) Image(
- painter = painterResource(R.drawable.sample),
- contentDescription = contentDescription,
- modifier = modifier,
- alignment = alignment,
- contentScale = contentScale,
- colorFilter = colorFilter,
- )
- else AsyncImage(
- imageLoader = coil,
- model = model,
- placeholder = ColorPainter(Color(0x1F888888)),
- error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
- fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
- modifier = modifier,
- contentScale = contentScale,
- contentDescription = contentDescription
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/BottomNavDestination.kt b/app/src/main/java/org/xtimms/shirizu/core/BottomNavDestination.kt
deleted file mode 100644
index 1d5af18..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/BottomNavDestination.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.xtimms.shirizu.core
-
-import androidx.annotation.StringRes
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Explore
-import androidx.compose.material.icons.filled.History
-import androidx.compose.material.icons.filled.LocalLibrary
-import androidx.compose.material.icons.outlined.Explore
-import androidx.compose.material.icons.outlined.History
-import androidx.compose.material.icons.outlined.LocalLibrary
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
-import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
-import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
-
-sealed class BottomNavDestination(
- val value: String,
- val route: String,
- @StringRes val title: Int,
- val icon: ImageVector,
- val iconSelected: ImageVector,
-) {
- data object Shelf : BottomNavDestination(
- value = "shelf",
- route = SHELF_DESTINATION,
- title = R.string.nav_shelf,
- icon = Icons.Outlined.LocalLibrary,
- iconSelected = Icons.Filled.LocalLibrary
- )
-
- data object History : BottomNavDestination(
- value = "history",
- route = HISTORY_DESTINATION,
- title = R.string.nav_history,
- icon = Icons.Outlined.History,
- iconSelected = Icons.Filled.History
- )
-
- data object Explore : BottomNavDestination(
- value = "explore",
- route = EXPLORE_DESTINATION,
- title = R.string.nav_explore,
- icon = Icons.Outlined.Explore,
- iconSelected = Icons.Filled.Explore
- )
-
- companion object {
- val values = listOf(Shelf, History, Explore)
-
- val railValues = listOf(Shelf, History, Explore)
-
- val routes = values.map { it.route }
-
- fun String.toBottomDestinationIndex() = when (this) {
- Shelf.value -> 0
- History.value -> 1
- Explore.value -> 2
- else -> null
- }
-
- @Composable
- fun BottomNavDestination.Icon(selected: Boolean) {
- androidx.compose.material3.Icon(
- imageVector = if (selected) iconSelected else icon,
- contentDescription = stringResource(title)
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/ModifierCollapsable.kt b/app/src/main/java/org/xtimms/shirizu/core/ModifierCollapsable.kt
deleted file mode 100644
index 74836bb..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/ModifierCollapsable.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package org.xtimms.shirizu.core
-
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationVector1D
-import androidx.compose.foundation.gestures.ScrollableState
-import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
-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 kotlinx.coroutines.launch
-import kotlin.math.abs
-
-fun Modifier.collapsable(
- state: ScrollableState,
- topBarHeightPx: Float,
- topBarOffsetY: Animatable,
-) = composed {
- val scope = rememberCoroutineScope()
-
- LaunchedEffect(key1 = state.isScrollInProgress) {
- if (!state.isScrollInProgress && topBarOffsetY.value != 0f && topBarOffsetY.value != -topBarHeightPx) {
- val half = topBarHeightPx / 2
- val oldOffsetY = topBarOffsetY.value
-
- val targetOffsetY = when {
- abs(topBarOffsetY.value) >= half -> -topBarHeightPx
- else -> 0f
- }
-
- launch {
- state.animateScrollBy(oldOffsetY - targetOffsetY)
- }
-
- launch {
- topBarOffsetY.animateTo(targetOffsetY)
- }
- }
- }
-
- nestedScroll(
- object : NestedScrollConnection {
- override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
- scope.launch {
- if (state.canScrollForward) {
- topBarOffsetY.snapTo(
- targetValue = (topBarOffsetY.value + available.y).coerceIn(
- minimumValue = -topBarHeightPx,
- maximumValue = 0f,
- )
- )
- }
- }
-
- return Offset.Zero
- }
- }
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/Navigation.kt b/app/src/main/java/org/xtimms/shirizu/core/Navigation.kt
deleted file mode 100644
index eaf4223..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/Navigation.kt
+++ /dev/null
@@ -1,466 +0,0 @@
-package org.xtimms.shirizu.core
-
-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.animation.slideInHorizontally
-import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.IntOffset
-import androidx.navigation.NavHostController
-import androidx.navigation.NavType
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import coil.ImageLoader
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shirizu.core.logs.FileLogger
-import org.xtimms.shirizu.sections.details.DETAILS_DESTINATION
-import org.xtimms.shirizu.sections.details.DetailsView
-import org.xtimms.shirizu.sections.details.FULL_POSTER_DESTINATION
-import org.xtimms.shirizu.sections.details.FullImageView
-import org.xtimms.shirizu.sections.details.MANGA_ID_ARGUMENT
-import org.xtimms.shirizu.sections.details.PICTURES_ARGUMENT
-import org.xtimms.shirizu.sections.explore.ExploreView
-import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
-import org.xtimms.shirizu.sections.feed.FeedView
-import org.xtimms.shirizu.sections.history.HistoryView
-import org.xtimms.shirizu.sections.list.LIST_DESTINATION
-import org.xtimms.shirizu.sections.list.MangaListView
-import org.xtimms.shirizu.sections.list.PROVIDER_ARGUMENT
-import org.xtimms.shirizu.sections.reader.READER_DESTINATION
-import org.xtimms.shirizu.sections.reader.ReaderView
-import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
-import org.xtimms.shirizu.sections.search.SearchHostView
-import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
-import org.xtimms.shirizu.sections.settings.SettingsView
-import org.xtimms.shirizu.sections.settings.about.ABOUT_DESTINATION
-import org.xtimms.shirizu.sections.settings.about.AboutView
-import org.xtimms.shirizu.sections.settings.about.LICENSES_DESTINATION
-import org.xtimms.shirizu.sections.settings.about.LICENSE_CONTENT_ARGUMENT
-import org.xtimms.shirizu.sections.settings.about.LICENSE_DESTINATION
-import org.xtimms.shirizu.sections.settings.about.LICENSE_NAME_ARGUMENT
-import org.xtimms.shirizu.sections.settings.about.LICENSE_WEBSITE_ARGUMENT
-import org.xtimms.shirizu.sections.settings.about.LicenseView
-import org.xtimms.shirizu.sections.settings.about.OpenSourceLicensesView
-import org.xtimms.shirizu.sections.settings.about.UPDATES_DESTINATION
-import org.xtimms.shirizu.sections.settings.about.UpdateView
-import org.xtimms.shirizu.sections.settings.advanced.ADVANCED_DESTINATION
-import org.xtimms.shirizu.sections.settings.advanced.AdvancedView
-import org.xtimms.shirizu.sections.settings.appearance.APPEARANCE_DESTINATION
-import org.xtimms.shirizu.sections.settings.appearance.AppearanceView
-import org.xtimms.shirizu.sections.settings.appearance.DARK_THEME_DESTINATION
-import org.xtimms.shirizu.sections.settings.appearance.DarkThemeView
-import org.xtimms.shirizu.sections.settings.appearance.LANGUAGES_DESTINATION
-import org.xtimms.shirizu.sections.settings.appearance.LanguagesView
-import org.xtimms.shirizu.sections.settings.backup.BACKUP_RESTORE_DESTINATION
-import org.xtimms.shirizu.sections.settings.backup.BackupRestoreView
-import org.xtimms.shirizu.sections.settings.backup.RESTORE_ARGUMENT
-import org.xtimms.shirizu.sections.settings.backup.RESTORE_DESTINATION
-import org.xtimms.shirizu.sections.settings.backup.RestoreItemsView
-import org.xtimms.shirizu.sections.settings.network.NETWORK_DESTINATION
-import org.xtimms.shirizu.sections.settings.network.NetworkView
-import org.xtimms.shirizu.sections.settings.services.SERVICES_DESTINATION
-import org.xtimms.shirizu.sections.settings.services.ServicesView
-import org.xtimms.shirizu.sections.settings.services.suggestions.SUGGESTIONS_SETTINGS_DESTINATION
-import org.xtimms.shirizu.sections.settings.services.suggestions.SuggestionsSettingsView
-import org.xtimms.shirizu.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
-import org.xtimms.shirizu.sections.settings.shelf.ShelfSettingsView
-import org.xtimms.shirizu.sections.settings.shelf.categories.CATEGORIES_DESTINATION
-import org.xtimms.shirizu.sections.settings.shelf.categories.CategoriesView
-import org.xtimms.shirizu.sections.settings.sources.SOURCES_DESTINATION
-import org.xtimms.shirizu.sections.settings.sources.SourcesView
-import org.xtimms.shirizu.sections.settings.sources.catalog.CATALOG_DESTINATION
-import org.xtimms.shirizu.sections.settings.sources.catalog.SourcesCatalogView
-import org.xtimms.shirizu.sections.settings.storage.STORAGE_DESTINATION
-import org.xtimms.shirizu.sections.settings.storage.StorageView
-import org.xtimms.shirizu.sections.shelf.ShelfView
-import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
-import org.xtimms.shirizu.sections.stats.StatsView
-import org.xtimms.shirizu.sections.suggestions.SUGGESTIONS_DESTINATION
-import org.xtimms.shirizu.sections.suggestions.SuggestionsView
-import org.xtimms.shirizu.utils.StringArrayNavType
-import org.xtimms.shirizu.utils.lang.removeFirstAndLast
-
-const val DURATION_ENTER = 400
-const val DURATION_EXIT = 200
-const val initialOffset = 0.10f
-
-fun PathInterpolator.toEasing(): Easing {
- return Easing { f -> this.getInterpolation(f) }
-}
-
-@Composable
-fun Navigation(
- coil: ImageLoader,
- loggers: Set,
- navController: NavHostController,
- isCompactScreen: Boolean,
- modifier: Modifier,
- padding: PaddingValues,
- listState: LazyListState,
-) {
-
- val navigateBack: () -> Unit = { navController.popBackStack() }
-
- val navigateToDetails: (Long) -> Unit = {
- navController.navigate(
- DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
- )
- }
-
- 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)
- cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
- }
-
- val emphasizePathInterpolator = PathInterpolator(path)
- val emphasizeEasing = emphasizePathInterpolator.toEasing()
-
- val enterTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
- val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
- val fadeTween = tween(durationMillis = DURATION_EXIT)
-
- NavHost(
- navController = navController,
- startDestination = BottomNavDestination.Shelf.route,
- modifier = modifier,
- enterTransition = {
- slideInHorizontally(
- enterTween,
- initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn(fadeTween)
- },
- exitTransition = {
- slideOutHorizontally(
- exitTween,
- targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
- },
- popEnterTransition = {
- slideInHorizontally(
- enterTween,
- initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween)
- },
- popExitTransition = {
- slideOutHorizontally(
- exitTween,
- targetOffsetX = { (it * initialOffset).toInt() }) + fadeOut(fadeTween)
- }
- ) {
-
- composable(BottomNavDestination.Shelf.route) {
- ShelfView(
- coil = coil,
- currentPage = { 2 },
- showPageTabs = true,
- padding = padding,
- navigateToDetails = navigateToDetails,
- onRefresh = { true },
- )
- }
-
- composable(BottomNavDestination.History.route) {
- HistoryView(
- coil = coil,
- padding = padding,
- navigateToDetails = navigateToDetails,
- navigateToReader = { navController.navigate(READER_DESTINATION) },
- listState = listState
- )
- }
-
- composable(BottomNavDestination.Explore.route) {
- ExploreView(
- coil = coil,
- navigateToDetails = navigateToDetails,
- navigateToSource = {
- navController.navigate(
- LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
- )
- },
- navigateToSuggestions = { navController.navigate(SUGGESTIONS_DESTINATION) },
- padding = padding,
- listState = listState
- )
- }
-
- composable(SEARCH_DESTINATION) {
- SearchHostView(
- isCompactScreen = isCompactScreen,
- padding = if (isCompactScreen) PaddingValues() else padding,
- navigateBack = navigateBack,
- )
- }
-
- composable(FEED_DESTINATION) {
- FeedView(
- coil = coil,
- navigateBack = navigateBack,
- navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
- )
- }
-
- composable(SUGGESTIONS_DESTINATION) {
- SuggestionsView(
- coil = coil,
- navigateBack = navigateBack,
- navigateToDetails = navigateToDetails
- )
- }
-
- 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) },
- navigateToServicesSettings = { navController.navigate(SERVICES_DESTINATION) },
- navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
- navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
- )
- }
-
- composable(APPEARANCE_DESTINATION) {
- AppearanceView(
- navigateBack = navigateBack,
- navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
- navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
- )
- }
-
- composable(DARK_THEME_DESTINATION) {
- DarkThemeView(
- navigateBack = navigateBack
- )
- }
-
- composable(LANGUAGES_DESTINATION) {
- LanguagesView(
- navigateBack = navigateBack
- )
- }
-
- composable(SOURCES_DESTINATION) {
- SourcesView(
- navigateBack = navigateBack,
- navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) },
- navigateToSourcesManagement = { /*TODO*/ }
- )
- }
-
- composable(CATALOG_DESTINATION) {
- SourcesCatalogView(
- coil = coil,
- 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,
- navigateToCategories = { navController.navigate(CATEGORIES_DESTINATION) }
- )
- }
-
- composable(CATEGORIES_DESTINATION) {
- CategoriesView(
- navigateBack = navigateBack,
- )
- }
-
- composable(SERVICES_DESTINATION) {
- ServicesView(
- navigateBack = navigateBack,
- navigateToSuggestionsSettings = { navController.navigate(SUGGESTIONS_SETTINGS_DESTINATION) },
- navigateToStatistics = { navController.navigate(STATS_DESTINATION) }
- )
- }
-
- composable(SUGGESTIONS_SETTINGS_DESTINATION) {
- SuggestionsSettingsView(
- navigateBack = navigateBack
- )
- }
-
- composable(NETWORK_DESTINATION) {
- NetworkView(
- navigateBack = navigateBack,
- )
- }
-
- composable(STORAGE_DESTINATION) {
- StorageView(
- navigateBack = navigateBack,
- )
- }
-
- composable(ADVANCED_DESTINATION) {
- AdvancedView(
- loggers = loggers,
- navigateBack = navigateBack,
- navigateToStats = { navController.navigate(STATS_DESTINATION) }
- )
- }
-
- composable(STATS_DESTINATION) {
- StatsView(
- navigateBack = navigateBack
- )
- }
-
- composable(
- route = LIST_DESTINATION,
- arguments = listOf(
- navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) {
- type = NavType.StringType
- }
- )
- ) { navEntry ->
- MangaListView(
- coil = coil,
- source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
- ?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
- navigateBack = navigateBack,
- navigateToDetails = navigateToDetails
- )
- }
-
- 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,
- )
- }
-
- composable(
- route = DETAILS_DESTINATION,
- arguments = listOf(
- 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 = navigateToDetails,
- navigateToSource = {
- navController.navigate(
- LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
- )
- },
- navigateToReader = { navController.navigate(READER_DESTINATION) }
- )
- }
-
- composable(READER_DESTINATION) {
- ReaderView(
- navigateBack = navigateBack
- )
- }
-
- 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/shirizu/core/ShirizuAsyncImage.kt b/app/src/main/java/org/xtimms/shirizu/core/ShirizuAsyncImage.kt
new file mode 100644
index 0000000..30cfeb7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/ShirizuAsyncImage.kt
@@ -0,0 +1,30 @@
+package org.xtimms.shirizu.core
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.layout.ContentScale
+import coil.compose.AsyncImage
+import org.xtimms.shirizu.LocalImageLoader
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
+
+@Composable
+fun ShirizuAsyncImage(
+ model: Any? = null,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Crop,
+) {
+ AsyncImage(
+ imageLoader = LocalImageLoader.current,
+ model = model,
+ placeholder = ColorPainter(Color(0x1F888888)),
+ error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
+ fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
+ modifier = modifier,
+ contentScale = contentScale,
+ contentDescription = contentDescription
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/base/BaseActivity.kt b/app/src/main/java/org/xtimms/shirizu/core/base/BaseActivity.kt
index a31dafc..3867da6 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/base/BaseActivity.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/base/BaseActivity.kt
@@ -24,7 +24,7 @@ abstract class BaseActivity :
WindowCompat.setDecorFitsSystemWindows(window, false)
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
putDataToExtras(intent)
super.onNewIntent(intent)
}
diff --git a/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/BaseStateScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/BaseStateScreenModel.kt
new file mode 100644
index 0000000..127609b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/BaseStateScreenModel.kt
@@ -0,0 +1,90 @@
+package org.xtimms.shirizu.core.base.viewmodel
+
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+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.asStateFlow
+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.shirizu.utils.lang.EventFlow
+import org.xtimms.shirizu.utils.lang.MutableEventFlow
+import org.xtimms.shirizu.utils.lang.call
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
+
+abstract class BaseStateScreenModel(initialState: S) : ScreenModel {
+
+ protected val mutableState: MutableStateFlow = MutableStateFlow(initialState)
+ public val state: StateFlow = mutableState.asStateFlow()
+
+ @JvmField
+ protected val loadingCounter = MutableStateFlow(0)
+
+ @JvmField
+ protected val errorEvent = MutableEventFlow()
+
+ val onError: EventFlow
+ get() = errorEvent
+
+ val isLoading: StateFlow = loadingCounter.map { it > 0 }
+ .stateIn(screenModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
+
+ protected fun launchJob(
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+ block: suspend CoroutineScope.() -> Unit
+ ): Job = screenModelScope.launch(context + createErrorHandler(), start, block)
+
+ protected fun launchLoadingJob(
+ context: CoroutineContext = EmptyCoroutineContext,
+ start: CoroutineStart = CoroutineStart.DEFAULT,
+ block: suspend CoroutineScope.() -> Unit
+ ): Job = screenModelScope.launch(context + createErrorHandler(), start) {
+ loadingCounter.increment()
+ try {
+ block()
+ } finally {
+ loadingCounter.decrement()
+ }
+ }
+
+ protected fun Flow.withLoading() = onStart {
+ loadingCounter.increment()
+ }.onCompletion {
+ loadingCounter.decrement()
+ }
+
+ protected suspend inline fun withLoading(block: () -> T): T = try {
+ loadingCounter.increment()
+ block()
+ } finally {
+ 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/shirizu/core/base/viewmodel/KotatsuBaseViewModel.kt b/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/KotatsuBaseViewModel.kt
index a9f75f9..f9d2716 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/KotatsuBaseViewModel.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/base/viewmodel/KotatsuBaseViewModel.kt
@@ -63,7 +63,7 @@ abstract class KotatsuBaseViewModel : ViewModel() {
loadingCounter.decrement()
}
- protected inline suspend fun withLoading(block: () -> T): T = try {
+ protected suspend inline fun withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/AutoSizedCircularProgressIndicator.kt b/app/src/main/java/org/xtimms/shirizu/core/components/AutoSizedCircularProgressIndicator.kt
new file mode 100644
index 0000000..edfa4ba
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/AutoSizedCircularProgressIndicator.kt
@@ -0,0 +1,42 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.min
+import kotlin.math.roundToInt
+
+@Composable
+fun AutoSizedCircularProgressIndicator(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ BoxWithConstraints(modifier) {
+ val diameter = with(LocalDensity.current) {
+ // We need to minus the padding added within CircularProgressIndicator
+ min(constraints.maxWidth.toDp(), constraints.maxHeight.toDp()) - InternalPadding
+ }
+ CircularProgressIndicator(
+ strokeWidth = (diameter.value * StrokeDiameterFraction)
+ .roundToInt().dp
+ .coerceAtLeast(2.dp),
+ color = color,
+ )
+ }
+}
+
+// Default stroke size
+private val DefaultStrokeWidth = 4.dp
+
+// Preferred diameter for CircularProgressIndicator
+private val DefaultDiameter = 40.dp
+
+// Internal padding added by CircularProgressIndicator
+private val InternalPadding = 4.dp
+
+private val StrokeDiameterFraction = DefaultStrokeWidth / DefaultDiameter
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/BottomActionMenu.kt b/app/src/main/java/org/xtimms/shirizu/core/components/BottomActionMenu.kt
new file mode 100644
index 0000000..5ec21d5
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/BottomActionMenu.kt
@@ -0,0 +1,183 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.ZeroCornerSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.Label
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.DoneAll
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Favorite
+import androidx.compose.material.icons.outlined.RemoveDone
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun RowScope.Button(
+ title: String,
+ icon: ImageVector,
+ toConfirm: Boolean,
+ onLongClick: () -> Unit,
+ onClick: () -> Unit,
+ content: (@Composable () -> Unit)? = null,
+) {
+ val animatedWeight by animateFloatAsState(
+ targetValue = if (toConfirm) 2f else 1f,
+ label = "weight",
+ )
+ Column(
+ modifier = Modifier
+ .size(48.dp)
+ .weight(animatedWeight)
+ .combinedClickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = ripple(bounded = false),
+ onLongClick = onLongClick,
+ onClick = onClick,
+ ),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = title,
+ )
+ AnimatedVisibility(
+ visible = toConfirm,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
+ ) {
+ Text(
+ text = title,
+ overflow = TextOverflow.Visible,
+ maxLines = 1,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ content?.invoke()
+ }
+}
+
+@Composable
+fun LibraryBottomActionMenu(
+ visible: Boolean,
+ onDeleteClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = expandVertically(animationSpec = tween(delayMillis = 300)),
+ exit = shrinkVertically(animationSpec = tween()),
+ ) {
+ val scope = rememberCoroutineScope()
+ Surface(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
+ tonalElevation = 3.dp,
+ ) {
+ val haptic = LocalHapticFeedback.current
+ val confirm = remember { mutableStateListOf(false, false, false, false, false) }
+ var resetJob: Job? = remember { null }
+ val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ (0..<5).forEach { i -> confirm[i] = i == toConfirmIndex }
+ resetJob?.cancel()
+ resetJob = scope.launch {
+ delay(1.seconds)
+ if (isActive) confirm[toConfirmIndex] = false
+ }
+ }
+ Row(
+ modifier = Modifier
+ .windowInsetsPadding(
+ WindowInsets.navigationBars
+ .only(WindowInsetsSides.Bottom),
+ )
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ ) {
+ Button(
+ title = stringResource(R.string.action_share),
+ icon = Icons.Outlined.Share,
+ toConfirm = confirm[0],
+ onLongClick = { onLongClickItem(0) },
+ onClick = { },
+ )
+ Button(
+ title = stringResource(R.string.action_delete),
+ icon = Icons.Outlined.Delete,
+ toConfirm = confirm[1],
+ onLongClick = { onLongClickItem(1) },
+ onClick = onDeleteClicked,
+ )
+ Button(
+ title = stringResource(R.string.action_save),
+ icon = Icons.Outlined.Save,
+ toConfirm = confirm[2],
+ onLongClick = { onLongClickItem(2) },
+ onClick = { },
+ )
+ Button(
+ title = stringResource(R.string.add_to_shelf),
+ icon = Icons.Outlined.Favorite,
+ toConfirm = confirm[3],
+ onLongClick = { onLongClickItem(3) },
+ onClick = { },
+ )
+ Button(
+ title = stringResource(R.string.action_mark_as_completed),
+ icon = Icons.Outlined.DoneAll,
+ toConfirm = confirm[4],
+ onLongClick = { onLongClickItem(4) },
+ onClick = { },
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/BottomNavBar.kt b/app/src/main/java/org/xtimms/shirizu/core/components/BottomNavBar.kt
deleted file mode 100644
index 7bbf32d..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/components/BottomNavBar.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.xtimms.shirizu.core.components
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.AnimationVector1D
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.res.stringResource
-import androidx.navigation.NavController
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.compose.currentBackStackEntryAsState
-import kotlinx.coroutines.launch
-import org.xtimms.shirizu.core.BottomNavDestination
-import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
-import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
-import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
-import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
-
-@Composable
-fun BottomNavBar(
- navController: NavController,
- bottomBarState: State,
- topBarOffsetY: Animatable,
-) {
- val scope = rememberCoroutineScope()
-
- val navBackStackEntry by navController.currentBackStackEntryAsState()
- val isVisible by remember {
- derivedStateOf {
- when (navBackStackEntry?.destination?.route) {
- SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value
-
- else -> false
- }
- }
- }
-
- AnimatedVisibility(
- visible = isVisible,
- enter = slideInVertically(initialOffsetY = { it }),
- exit = slideOutVertically(targetOffsetY = { it })
- ) {
- NavigationBar {
- BottomNavDestination.values.forEachIndexed { _, dest ->
- val isSelected = navBackStackEntry?.destination?.route == dest.route
- NavigationBarItem(
- selected = isSelected,
- onClick = {
- scope.launch {
- topBarOffsetY.animateTo(0f)
- }
-
- navController.navigate(dest.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- },
- icon = { dest.Icon(selected = isSelected) },
- label = { Text(text = stringResource(dest.title)) }
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/Carousel.kt b/app/src/main/java/org/xtimms/shirizu/core/components/Carousel.kt
new file mode 100644
index 0000000..627b667
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/Carousel.kt
@@ -0,0 +1,282 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
+import androidx.compose.foundation.gestures.snapping.SnapPosition
+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.IntrinsicSize
+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.fillMaxSize
+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.width
+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.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.FirstBaseline
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.shirizu.LocalImageLoader
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.icons.Creation
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.ui.theme.ShirizuTheme
+import kotlin.math.pow
+import kotlin.math.roundToInt
+
+@Composable
+fun MangaCarouselWithHeader(
+ items: List,
+ title: String,
+ refreshing: Boolean,
+ onItemClick: (Manga) -> Unit,
+ onMoreClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ if (refreshing || items.isNotEmpty()) {
+ Header(
+ title = title,
+ loading = refreshing,
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ ) {
+ TextButton(
+ onClick = onMoreClick,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.secondary,
+ ),
+ modifier = Modifier.alignBy(FirstBaseline),
+ ) {
+ Text(text = stringResource(id = R.string.more))
+ }
+ }
+ }
+ if (items.isNotEmpty()) {
+ MangaCarousel(
+ items = items,
+ onItemClick = onItemClick,
+ modifier = Modifier
+ .testTag("search_carousel")
+ .fillMaxWidth(),
+ )
+ } else {
+ Card(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.extraLarge)
+ ) {
+ EmptyScreen(
+ icon = Icons.Outlined.Creation,
+ title = R.string.nothing_here,
+ description = R.string.empty_carousel_hint,
+ modifier = Modifier.height(IntrinsicSize.Min)
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun MangaCarousel(
+ items: List,
+ onItemClick: (Manga) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val lazyListState = rememberLazyListState()
+
+ LazyRow(
+ state = lazyListState,
+ modifier = modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clip(MaterialTheme.shapes.extraLarge),
+ flingBehavior = rememberSnapFlingBehavior(
+ snapLayoutInfoProvider = remember(lazyListState) {
+ SnapLayoutInfoProvider(
+ lazyListState = lazyListState,
+ snapPosition = SnapPosition.Start,
+ )
+ },
+ ),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(
+ items = items,
+ key = { it.id },
+ ) { item ->
+ BackdropCard(
+ manga = item,
+ onClick = { onItemClick(item) },
+ alignment = remember {
+ ParallaxAlignment(
+ horizontalBias = {
+ val layoutInfo = lazyListState.layoutInfo
+ val itemInfo = layoutInfo.visibleItemsInfo.first {
+ it.key == item.id
+ }
+
+ val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
+ (adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)
+ },
+ )
+ },
+ modifier = Modifier
+ .testTag("search_carousel_item")
+ .animateItem()
+ .width(156.dp)
+ .aspectRatio(2 / 3f),
+ )
+ }
+ }
+}
+
+@Composable
+fun BackdropCard(
+ manga: Manga,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ alignment: Alignment = Alignment.Center,
+) {
+ Card(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.extraLarge,
+ modifier = modifier,
+ ) {
+ BackdropCardContent(
+ manga = manga,
+ alignment = alignment,
+ )
+ }
+}
+
+@Composable
+private fun BackdropCardContent(
+ manga: Manga,
+ alignment: Alignment = Alignment.Center,
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ AsyncImage(
+ imageLoader = LocalImageLoader.current,
+ model = manga.largeCoverUrl ?: manga.coverUrl,
+ contentDescription = null,
+ modifier = Modifier.matchParentSize(),
+ contentScale = ContentScale.Crop,
+ alignment = alignment,
+ )
+
+ Spacer(
+ Modifier
+ .matchParentSize()
+ .drawForegroundGradientScrim(MaterialTheme.colorScheme.surfaceDim),
+ )
+
+ Text(
+ text = manga.title,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(16.dp)
+ .align(Alignment.BottomStart),
+ )
+ }
+}
+
+@Stable
+class ParallaxAlignment(
+ private val horizontalBias: () -> Float = { 0f },
+ private val verticalBias: () -> Float = { 0f },
+) : Alignment {
+ override fun align(
+ size: IntSize,
+ space: IntSize,
+ layoutDirection: LayoutDirection,
+ ): IntOffset {
+ // Convert to Px first and only round at the end, to avoid rounding twice while calculating
+ // the new positions
+ val centerX = (space.width - size.width).toFloat() / 2f
+ val centerY = (space.height - size.height).toFloat() / 2f
+ val resolvedHorizontalBias = if (layoutDirection == LayoutDirection.Ltr) {
+ horizontalBias()
+ } else {
+ -1 * horizontalBias()
+ }
+
+ val x = centerX * (1 + resolvedHorizontalBias)
+ val y = centerY * (1 + verticalBias())
+ return IntOffset(x.roundToInt(), y.roundToInt())
+ }
+}
+
+/**
+ * Draws a vertical gradient scrim in the foreground.
+ *
+ * @param color The color of the gradient scrim.
+ * @param decay The exponential decay to apply to the gradient. Defaults to `3.0f` which is
+ * a cubic decay.
+ * @param numStops The number of color stops to draw in the gradient. Higher numbers result in
+ * the higher visual quality at the cost of draw performance. Defaults to `16`.
+ */
+fun Modifier.drawForegroundGradientScrim(
+ color: Color,
+ decay: Float = 1.0f,
+ numStops: Int = 16,
+ startY: Float = 0f,
+ endY: Float = 1f,
+): Modifier = composed {
+ val colors = remember(color, numStops) {
+ val baseAlpha = color.alpha
+ List(numStops) { i ->
+ val x = i * 1f / (numStops - 1)
+ val opacity = x.pow(decay)
+ color.copy(alpha = baseAlpha * opacity)
+ }
+ }
+
+ drawWithContent {
+ drawContent()
+ drawRect(
+ topLeft = Offset(x = 0f, y = startY * size.height),
+ size = size.copy(height = (endY - startY) * size.height),
+ brush = Brush.verticalGradient(colors = colors),
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/ContinueReadingButton.kt b/app/src/main/java/org/xtimms/shirizu/core/components/ContinueReadingButton.kt
deleted file mode 100644
index 4508d9e..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/components/ContinueReadingButton.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package org.xtimms.shirizu.core.components
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.LocalLibrary
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.FloatingActionButtonElevation
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import androidx.navigation.compose.currentBackStackEntryAsState
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
-import org.xtimms.shirizu.sections.reader.READER_DESTINATION
-
-@Composable
-fun ContinueReadingButton(
- navController: NavController,
-) {
- val navBackStackEntry by navController.currentBackStackEntryAsState()
-
- val isVisible by remember {
- derivedStateOf {
- when (navBackStackEntry?.destination?.route) {
- HISTORY_DESTINATION, null -> true
- else -> false
- }
- }
- }
-
- val fabScale by animateFloatAsState(
- targetValue = when (navBackStackEntry?.destination?.route) {
- HISTORY_DESTINATION, null -> 1f
- else -> 0f
- },
- animationSpec = tween(150), label = "elevation"
- )
-
- AnimatedVisibility(
- visible = isVisible,
- enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) +
- scaleIn(
- initialScale = 0.92f,
- animationSpec = tween(300, delayMillis = 150)
- ),
- exit = fadeOut(animationSpec = tween(0))
- ) {
- androidx.compose.material3.ExtendedFloatingActionButton(
- onClick = {
- navController.navigate(
- READER_DESTINATION
- )
- },
- modifier = Modifier.padding(8.dp),
- elevation = FloatingActionButtonDefaults.elevation(
- defaultElevation = 4.dp
- )
- ) {
- Icon(
- imageVector = Icons.Outlined.LocalLibrary,
- contentDescription = null
- )
- Text(
- text = stringResource(R.string.continue_reading),
- modifier = Modifier.padding(start = 16.dp, end = 8.dp)
- )
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/DetailsToolbar.kt b/app/src/main/java/org/xtimms/shirizu/core/components/DetailsToolbar.kt
index e594eaf..40781b4 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/DetailsToolbar.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/DetailsToolbar.kt
@@ -120,7 +120,6 @@ fun ClassicDetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
navigateBack: () -> Unit,
- navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
@@ -165,16 +164,6 @@ fun ClassicDetailsToolbar(
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(
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/Dialogs.kt b/app/src/main/java/org/xtimms/shirizu/core/components/Dialogs.kt
index 5697aa6..ff3f6d5 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/Dialogs.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/Dialogs.kt
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
@@ -65,7 +66,7 @@ fun ShirizuDialog(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
- AlertDialog(
+ BasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties
@@ -158,7 +159,7 @@ fun ShirizuDialogButtonVariant(
text: String,
onClick: () -> Unit
) {
- Box() {
+ Box {
Surface(
modifier = modifier
.clickable(onClick = onClick)
@@ -251,7 +252,7 @@ fun ShirizuDialogVariant(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
- AlertDialog(
+ BasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/ExploreButton.kt b/app/src/main/java/org/xtimms/shirizu/core/components/ExploreButton.kt
index 534cb73..f20c123 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/ExploreButton.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/ExploreButton.kt
@@ -32,7 +32,7 @@ fun ExploreButton(
Card(
onClick = onClick,
modifier = modifier.padding(start = 8.dp, end = 8.dp),
- shape = RoundedCornerShape(50),
+ shape = RoundedCornerShape(25),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/FilterSortPanel.kt b/app/src/main/java/org/xtimms/shirizu/core/components/FilterSortPanel.kt
new file mode 100644
index 0000000..d75b4a0
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/FilterSortPanel.kt
@@ -0,0 +1,39 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun FilterSortPanel(
+ filterExpanded: Boolean,
+ filterIcon: @Composable () -> Unit,
+ filterTextField: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ AnimatedVisibility(!filterExpanded) {
+ filterIcon()
+ }
+
+ content()
+ }
+
+ AnimatedVisibility(visible = filterExpanded) {
+ filterTextField()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/LazyGrid.kt b/app/src/main/java/org/xtimms/shirizu/core/components/LazyGrid.kt
new file mode 100644
index 0000000..575df25
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/LazyGrid.kt
@@ -0,0 +1,58 @@
+package org.xtimms.shirizu.core.components
+
+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.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun FastScrollLazyVerticalGrid(
+ columns: GridCells,
+ modifier: Modifier = Modifier,
+ state: LazyGridState = rememberLazyGridState(),
+ thumbAllowed: () -> Boolean = { true },
+ thumbColor: Color = MaterialTheme.colorScheme.primary,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ topContentPadding: Dp = Dp.Hairline,
+ bottomContentPadding: Dp = Dp.Hairline,
+ endContentPadding: Dp = Dp.Hairline,
+ reverseLayout: Boolean = false,
+ verticalArrangement: Arrangement.Vertical =
+ if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
+ userScrollEnabled: Boolean = true,
+ content: LazyGridScope.() -> Unit,
+) {
+ VerticalGridFastScroller(
+ state = state,
+ columns = columns,
+ arrangement = horizontalArrangement,
+ contentPadding = contentPadding,
+ modifier = modifier,
+ thumbAllowed = thumbAllowed,
+ thumbColor = thumbColor,
+ topContentPadding = topContentPadding,
+ bottomContentPadding = bottomContentPadding,
+ endContentPadding = endContentPadding,
+ ) {
+ LazyVerticalGrid(
+ columns = columns,
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ verticalArrangement = verticalArrangement,
+ horizontalArrangement = horizontalArrangement,
+ userScrollEnabled = userScrollEnabled,
+ content = content,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/LazyList.kt b/app/src/main/java/org/xtimms/shirizu/core/components/LazyList.kt
new file mode 100644
index 0000000..68267b6
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/LazyList.kt
@@ -0,0 +1,87 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.utils.composable.drawVerticalScrollbar
+
+/**
+ * LazyColumn with scrollbar.
+ */
+@Composable
+fun ScrollbarLazyColumn(
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ reverseLayout: Boolean = false,
+ verticalArrangement: Arrangement.Vertical =
+ if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ userScrollEnabled: Boolean = true,
+ content: LazyListScope.() -> Unit,
+) {
+ val direction = LocalLayoutDirection.current
+ val density = LocalDensity.current
+ val positionOffset = remember(contentPadding) {
+ with(density) { contentPadding.calculateEndPadding(direction).toPx() }
+ }
+ LazyColumn(
+ modifier = modifier
+ .drawVerticalScrollbar(
+ state = state,
+ reverseScrolling = reverseLayout,
+ positionOffsetPx = positionOffset,
+ ),
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ userScrollEnabled = userScrollEnabled,
+ content = content,
+ )
+}
+
+/**
+ * LazyColumn with fast scroller.
+ */
+@Composable
+fun FastScrollLazyColumn(
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ reverseLayout: Boolean = false,
+ verticalArrangement: Arrangement.Vertical =
+ if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ userScrollEnabled: Boolean = true,
+ content: LazyListScope.() -> Unit,
+) {
+ VerticalFastScroller(
+ listState = state,
+ modifier = modifier,
+ topContentPadding = contentPadding.calculateTopPadding(),
+ endContentPadding = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
+ ) {
+ LazyColumn(
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ userScrollEnabled = userScrollEnabled,
+ content = content,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/ListGroupHeader.kt b/app/src/main/java/org/xtimms/shirizu/core/components/ListGroupHeader.kt
index 2b1c3b1..09a4c0d 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/ListGroupHeader.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/ListGroupHeader.kt
@@ -1,13 +1,48 @@
package org.xtimms.shirizu.core.components
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
+@Composable
+fun Header(
+ title: String,
+ modifier: Modifier = Modifier,
+ loading: Boolean = false,
+ content: @Composable RowScope.() -> Unit = {},
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+
+ Spacer(Modifier.weight(1f))
+
+ AnimatedVisibility(visible = loading) {
+ AutoSizedCircularProgressIndicator(
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+
+ content()
+ }
+}
+
@Composable
fun ListGroupHeader(
text: String,
@@ -18,7 +53,7 @@ fun ListGroupHeader(
modifier = modifier
.padding(
horizontal = 16.dp,
- vertical = 4.dp,
+ vertical = 8.dp,
),
style = MaterialTheme.typography.bodyLarge,
)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/MangaCover.kt b/app/src/main/java/org/xtimms/shirizu/core/components/MangaCover.kt
index d4a5591..da659cb 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/MangaCover.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/MangaCover.kt
@@ -1,6 +1,5 @@
package org.xtimms.shirizu.core.components
-import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
@@ -9,12 +8,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import coil.ImageLoader
import coil.compose.AsyncImage
-import org.xtimms.shirizu.core.AsyncImageImpl
+import coil.request.ImageRequest
+import org.xtimms.shirizu.LocalImageLoader
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
enum class MangaCover(val ratio: Float) {
Square(1f / 1f),
@@ -23,16 +26,17 @@ enum class MangaCover(val ratio: Float) {
@Composable
operator fun invoke(
- coil: ImageLoader,
- data: String,
+ data: Any?,
modifier: Modifier = Modifier,
contentDescription: String = "",
shape: Shape = MaterialTheme.shapes.small,
onClick: (() -> Unit)? = null,
) {
- AsyncImageImpl(
- coil = coil,
+ AsyncImage(
+ imageLoader = LocalImageLoader.current,
model = data,
+ placeholder = ColorPainter(CoverPlaceholderColor),
+ error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = contentDescription,
modifier = modifier
.aspectRatio(ratio)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/MangaGridItem.kt b/app/src/main/java/org/xtimms/shirizu/core/components/MangaGridItem.kt
index 428bd96..4dd6121 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/MangaGridItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/MangaGridItem.kt
@@ -8,6 +8,7 @@ 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.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
@@ -33,47 +35,44 @@ 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.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
private const val GridSelectedCoverAlpha = 0.76f
@Composable
fun MangaGridItem(
- coil: ImageLoader,
manga: Manga,
- onClick: (Manga) -> Unit,
+ title: String,
+ onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
+ titleMaxLines: Int = 2,
+ coverAlpha: Float = 1f,
) {
GridItemSelectable(
- manga = manga,
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
- Column(
- modifier = Modifier
- .fillMaxWidth(),
- horizontalAlignment = Alignment.Start
- ) {
- Box {
- AsyncImageImpl(
- modifier = Modifier
- .fillMaxWidth()
- .padding(4.dp)
- .clip(MaterialTheme.shapes.medium)
- .aspectRatio(10F / 16F),
- coil = coil,
- model = manga.largeCoverUrl ?: manga.coverUrl,
- contentDescription = null
- )
- }
- Text(
- text = manga.title,
+ Column {
+ MangaGridCover(
+ cover = {
+ MangaCover.Book(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
+ data = manga.largeCoverUrl ?: manga.coverUrl,
+ )
+ },
+ )
+ GridItemTitle(
modifier = Modifier.padding(4.dp),
- overflow = TextOverflow.Ellipsis,
- maxLines = 2,
+ title = title,
style = MaterialTheme.typography.titleSmall,
+ minLines = 2,
+ maxLines = titleMaxLines,
)
}
}
@@ -81,14 +80,12 @@ fun MangaGridItem(
@Composable
fun MangaHorizontalItem(
- coil: ImageLoader,
manga: Manga,
onClick: (Manga) -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
) {
GridItemSelectable(
- manga = manga,
isSelected = isSelected,
onClick = { onClick(manga) },
onLongClick = onLongClick,
@@ -98,14 +95,13 @@ fun MangaHorizontalItem(
horizontalAlignment = Alignment.Start
) {
Box {
- AsyncImageImpl(
+ ShirizuAsyncImage(
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
)
@@ -128,7 +124,6 @@ fun MangaHorizontalItem(
private fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
- content: @Composable (BoxScope.() -> Unit)? = null,
) {
Box(
modifier = modifier
@@ -136,7 +131,6 @@ private fun MangaGridCover(
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
- content?.invoke(this)
}
}
@@ -193,8 +187,6 @@ private fun GridItemTitle(
Text(
modifier = modifier,
text = title,
- fontSize = 12.sp,
- lineHeight = 18.sp,
minLines = minLines,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
@@ -208,9 +200,8 @@ private fun GridItemTitle(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun GridItemSelectable(
- manga: Manga,
isSelected: Boolean,
- onClick: (Manga) -> Unit,
+ onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
@@ -219,7 +210,7 @@ private fun GridItemSelectable(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.combinedClickable(
- onClick = { onClick(manga) },
+ onClick = onClick,
onLongClick = onLongClick,
)
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/ModalBottomSheet.kt b/app/src/main/java/org/xtimms/shirizu/core/components/ModalBottomSheet.kt
index 55efde8..b8029d1 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/ModalBottomSheet.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/ModalBottomSheet.kt
@@ -41,7 +41,7 @@ fun ShirizuModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismissRequest,
sheetState = sheetState,
- windowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
+ contentWindowInsets = { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) },
) {
Column(modifier = Modifier.padding(paddingValues = horizontalPadding)) {
content()
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/NavigationRail.kt b/app/src/main/java/org/xtimms/shirizu/core/components/NavigationRail.kt
deleted file mode 100644
index 7b030c7..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/components/NavigationRail.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.xtimms.shirizu.core.components
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Search
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationRail
-import androidx.compose.material3.NavigationRailItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.navigation.NavController
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.compose.currentBackStackEntryAsState
-import org.xtimms.shirizu.core.BottomNavDestination
-import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
-import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
-
-@Composable
-fun NavigationRail(
- navController: NavController,
-) {
- val navBackStackEntry by navController.currentBackStackEntryAsState()
- NavigationRail(
- header = {
- FloatingActionButton(
- onClick = {
- navController.navigate(SEARCH_DESTINATION) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- }
- ) {
- Icon(
- imageVector = Icons.Outlined.Search,
- contentDescription = null
- )
- }
- }
- ) {
- Column(
- modifier = Modifier
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.Bottom
- ) {
- BottomNavDestination.railValues.forEachIndexed { index, dest ->
- val isSelected = navBackStackEntry?.destination?.route == dest.route
- NavigationRailItem(
- selected = isSelected,
- onClick = {
- navController.navigate(dest.route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
- }
- launchSingleTop = true
- restoreState = true
- }
- },
- icon = { dest.Icon(selected = isSelected) },
- label = { Text(text = stringResource(dest.title)) }
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/shirizu/core/components/PreferenceItem.kt
index 75b3b8b..3b2a4a5 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/PreferenceItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/PreferenceItem.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Check
@@ -67,6 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.xtimms.shirizu.ui.theme.FixedAccentColors
import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.ui.monet.LocalTonalPalettes
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.shirizu.ui.theme.PreviewThemeLight
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/PullRefresh.kt b/app/src/main/java/org/xtimms/shirizu/core/components/PullRefresh.kt
deleted file mode 100644
index 82a8fbd..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/components/PullRefresh.kt
+++ /dev/null
@@ -1,290 +0,0 @@
-package org.xtimms.shirizu.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/shirizu/core/components/Scaffold.kt b/app/src/main/java/org/xtimms/shirizu/core/components/Scaffold.kt
new file mode 100644
index 0000000..50d2844
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/Scaffold.kt
@@ -0,0 +1,333 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.MutableWindowInsets
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.exclude
+import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FabPosition
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ScaffoldDefaults
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.contentColorFor
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxBy
+import kotlin.math.max
+
+/**
+ * From Mihon
+ *
+ * Material Design layout.
+ *
+ * Scaffold implements the basic material design visual layout structure.
+ *
+ * This component provides API to put together several material components to construct your
+ * screen, by ensuring proper layout strategy for them and collecting necessary data so these
+ * components will work together correctly.
+ *
+ * Simple example of a Scaffold with [SmallTopAppBar], [FloatingActionButton]:
+ *
+ * @sample androidx.compose.material3.samples.SimpleScaffoldWithTopBar
+ *
+ * To show a [Snackbar], use [SnackbarHostState.showSnackbar].
+ *
+ * @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
+ *
+ * @param modifier the [Modifier] to be applied to this scaffold
+ * @param topBar top app bar of the screen, typically a [SmallTopAppBar]
+ * @param startBar side bar on the start of the screen, typically a [NavigationRail]
+ * @param bottomBar bottom bar of the screen, typically a [NavigationBar]
+ * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
+ * [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
+ * @param floatingActionButton Main action button of the screen, typically a [FloatingActionButton]
+ * @param floatingActionButtonPosition position of the FAB on the screen. See [FabPosition].
+ * @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
+ * to have no color.
+ * @param contentColor the preferred color for content inside this scaffold. Defaults to either the
+ * matching content color for [containerColor], or to the current [LocalContentColor] if
+ * [containerColor] is not a color from the theme.
+ * @param contentWindowInsets window insets to be passed to content slot via PaddingValues params.
+ * Scaffold will take the insets into account from the top/bottom only if the topBar/ bottomBar
+ * are not present, as the scaffold expect topBar/bottomBar to handle insets instead
+ * @param content content of the screen. The lambda receives a [PaddingValues] that should be
+ * applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ * properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ * the child of the scroll, and not on the scroll itself.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@ExperimentalMaterial3Api
+@Composable
+fun Scaffold(
+ modifier: Modifier = Modifier,
+ topBarScrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
+ rememberTopAppBarState(),
+ ),
+ topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {},
+ bottomBar: @Composable () -> Unit = {},
+ startBar: @Composable () -> Unit = {},
+ snackbarHost: @Composable () -> Unit = {},
+ floatingActionButton: @Composable () -> Unit = {},
+ floatingActionButtonPosition: FabPosition = FabPosition.End,
+ containerColor: Color = MaterialTheme.colorScheme.background,
+ contentColor: Color = contentColorFor(containerColor),
+ contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
+ content: @Composable (PaddingValues) -> Unit,
+) {
+ val remainingWindowInsets = remember { MutableWindowInsets() }
+ androidx.compose.material3.Surface(
+ modifier = Modifier
+ .nestedScroll(topBarScrollBehavior.nestedScrollConnection)
+ .onConsumedWindowInsetsChanged {
+ remainingWindowInsets.insets = contentWindowInsets.exclude(
+ it,
+ )
+ }
+ .then(modifier),
+ color = containerColor,
+ contentColor = contentColor,
+ ) {
+ ScaffoldLayout(
+ fabPosition = floatingActionButtonPosition,
+ topBar = { topBar(topBarScrollBehavior) },
+ startBar = startBar,
+ bottomBar = bottomBar,
+ content = content,
+ snackbar = snackbarHost,
+ contentWindowInsets = remainingWindowInsets,
+ fab = floatingActionButton,
+ )
+ }
+}
+
+/**
+ * Layout for a [Scaffold]'s content.
+ *
+ * @param fabPosition [FabPosition] for the FAB (if present)
+ * @param topBar the content to place at the top of the [Scaffold], typically a [SmallTopAppBar]
+ * @param content the main 'body' of the [Scaffold]
+ * @param snackbar the [Snackbar] displayed on top of the [content]
+ * @param fab the [FloatingActionButton] displayed on top of the [content], below the [snackbar]
+ * and above the [bottomBar]
+ * @param bottomBar the content to place at the bottom of the [Scaffold], on top of the
+ * [content], typically a [NavigationBar].
+ */
+@Suppress("CyclomaticComplexMethod")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ScaffoldLayout(
+ fabPosition: FabPosition,
+ topBar: @Composable () -> Unit,
+ startBar: @Composable () -> Unit,
+ content: @Composable (PaddingValues) -> Unit,
+ snackbar: @Composable () -> Unit,
+ fab: @Composable () -> Unit,
+ contentWindowInsets: WindowInsets,
+ bottomBar: @Composable () -> Unit,
+) {
+ SubcomposeLayout { constraints ->
+ val layoutWidth = constraints.maxWidth
+ val layoutHeight = constraints.maxHeight
+
+ val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+ val topBarConstraints = looseConstraints.copy(maxHeight = Constraints.Infinity)
+
+ layout(layoutWidth, layoutHeight) {
+ val leftInset = contentWindowInsets.getLeft(this@SubcomposeLayout, layoutDirection)
+ val rightInset = contentWindowInsets.getRight(this@SubcomposeLayout, layoutDirection)
+ val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
+
+ val startBarPlaceables = subcompose(ScaffoldLayoutContent.StartBar, startBar).fastMap {
+ it.measure(looseConstraints)
+ }
+ val startBarWidth = startBarPlaceables.fastMaxBy { it.width }?.width ?: 0
+
+ val insetLayoutWidth = layoutWidth - leftInset - rightInset - startBarWidth
+
+ val topBarPlaceables = subcompose(ScaffoldLayoutContent.TopBar, topBar).fastMap {
+ it.measure(topBarConstraints)
+ }
+
+ val topBarHeight = topBarPlaceables.fastMaxBy { it.height }?.height ?: 0
+
+ val snackbarPlaceables = subcompose(ScaffoldLayoutContent.Snackbar, snackbar).fastMap {
+ it.measure(looseConstraints)
+ }
+
+ val snackbarHeight = snackbarPlaceables.fastMaxBy { it.height }?.height ?: 0
+ val snackbarWidth = snackbarPlaceables.fastMaxBy { it.width }?.width ?: 0
+
+ val snackbarLeft = if (snackbarPlaceables.isNotEmpty()) {
+ (insetLayoutWidth - snackbarWidth) / 2 + leftInset
+ } else {
+ 0
+ }
+
+ val fabPlaceables =
+ subcompose(ScaffoldLayoutContent.Fab, fab).fastMap { measurable ->
+ measurable.measure(looseConstraints)
+ }
+
+ val fabWidth = fabPlaceables.fastMaxBy { it.width }?.width ?: 0
+ val fabHeight = fabPlaceables.fastMaxBy { it.height }?.height ?: 0
+
+ val fabPlacement = if (fabPlaceables.isNotEmpty() && fabWidth != 0 && fabHeight != 0) {
+ // FAB distance from the left of the layout, taking into account LTR / RTL
+ val fabLeftOffset = if (fabPosition == FabPosition.End) {
+ if (layoutDirection == LayoutDirection.Ltr) {
+ layoutWidth - FabSpacing.roundToPx() - fabWidth - rightInset
+ } else {
+ FabSpacing.roundToPx() + leftInset
+ }
+ } else {
+ leftInset + ((insetLayoutWidth - fabWidth) / 2)
+ }
+
+ FabPlacement(
+ left = fabLeftOffset,
+ width = fabWidth,
+ height = fabHeight,
+ )
+ } else {
+ null
+ }
+
+ val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
+ bottomBar()
+ }.fastMap { it.measure(looseConstraints) }
+
+ val bottomBarHeight = bottomBarPlaceables
+ .fastMaxBy { it.height }
+ ?.height
+ ?.takeIf { it != 0 }
+ val fabOffsetFromBottom = fabPlacement?.let {
+ max(bottomBarHeight ?: 0, bottomInset) + it.height + FabSpacing.roundToPx()
+ }
+
+ val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
+ snackbarHeight + (fabOffsetFromBottom ?: max(bottomBarHeight ?: 0, bottomInset))
+ } else {
+ 0
+ }
+
+ val bodyContentPlaceables = subcompose(ScaffoldLayoutContent.MainContent) {
+ val insets = contentWindowInsets.asPaddingValues(this@SubcomposeLayout)
+ val fabOffsetDp = fabOffsetFromBottom?.toDp() ?: 0.dp
+ val bottomBarHeightPx = bottomBarHeight ?: 0
+ val innerPadding = PaddingValues(
+ top =
+ if (topBarPlaceables.isEmpty()) {
+ insets.calculateTopPadding()
+ } else {
+ topBarHeight.toDp()
+ },
+ bottom =
+ if (bottomBarPlaceables.isEmpty() || bottomBarHeightPx == 0) {
+ max(insets.calculateBottomPadding(), fabOffsetDp)
+ } else {
+ max(bottomBarHeightPx.toDp(), fabOffsetDp)
+ },
+ start = max(
+ insets.calculateStartPadding((this@SubcomposeLayout).layoutDirection),
+ startBarWidth.toDp(),
+ ),
+ end = insets.calculateEndPadding((this@SubcomposeLayout).layoutDirection),
+ )
+ content(innerPadding)
+ }.fastMap { it.measure(looseConstraints) }
+
+ // Placing to control drawing order to match default elevation of each placeable
+
+ bodyContentPlaceables.fastForEach {
+ it.place(0, 0)
+ }
+ startBarPlaceables.fastForEach {
+ it.placeRelative(0, 0)
+ }
+ topBarPlaceables.fastForEach {
+ it.place(0, 0)
+ }
+ snackbarPlaceables.fastForEach {
+ it.place(
+ snackbarLeft,
+ layoutHeight - snackbarOffsetFromBottom,
+ )
+ }
+ // The bottom bar is always at the bottom of the layout
+ bottomBarPlaceables.fastForEach {
+ it.place(0, layoutHeight - (bottomBarHeight ?: 0))
+ }
+ // Explicitly not using placeRelative here as `leftOffset` already accounts for RTL
+ fabPlaceables.fastForEach {
+ it.place(fabPlacement?.left ?: 0, layoutHeight - (fabOffsetFromBottom ?: 0))
+ }
+ }
+ }
+}
+
+/**
+ * The possible positions for a [FloatingActionButton] attached to a [Scaffold].
+ */
+@ExperimentalMaterial3Api
+@JvmInline
+value class FabPosition internal constructor(@Suppress("unused") private val value: Int) {
+ companion object {
+ /**
+ * Position FAB at the bottom of the screen in the center, above the [NavigationBar] (if it
+ * exists)
+ */
+ val Center = FabPosition(0)
+
+ /**
+ * Position FAB at the bottom of the screen at the end, above the [NavigationBar] (if it
+ * exists)
+ */
+ val End = FabPosition(1)
+ }
+
+ override fun toString(): String {
+ return when (this) {
+ Center -> "FabPosition.Center"
+ else -> "FabPosition.End"
+ }
+ }
+}
+
+/**
+ * Placement information for a [FloatingActionButton] inside a [Scaffold].
+ *
+ * @property left the FAB's offset from the left edge of the bottom bar, already adjusted for RTL
+ * support
+ * @property width the width of the FAB
+ * @property height the height of the FAB
+ */
+@Immutable
+internal class FabPlacement(
+ val left: Int,
+ val width: Int,
+ val height: Int,
+)
+
+// FAB spacing above the bottom bar / bottom of the Scaffold
+private val FabSpacing = 16.dp
+
+private enum class ScaffoldLayoutContent { TopBar, MainContent, Snackbar, Fab, BottomBar, StartBar }
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/shirizu/core/components/ScaffoldWithTopAppBar.kt
index 6aa4a54..3c3e361 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/ScaffoldWithTopAppBar.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/ScaffoldWithTopAppBar.kt
@@ -93,18 +93,11 @@ fun ScaffoldWithClassicTopAppBar(
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
- val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
- rememberTopAppBarState(),
- canScroll = { true }
- )
- Scaffold(
- modifier = Modifier
- .fillMaxSize()
- .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
+ org.xtimms.shirizu.core.components.Scaffold(
topBar = {
ClassicTopAppBar(
title = title,
- scrollBehavior = topAppBarScrollBehavior,
+ scrollBehavior = it,
actions = actions,
navigateBack = navigateBack
)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/SearchTextField.kt b/app/src/main/java/org/xtimms/shirizu/core/components/SearchTextField.kt
new file mode 100644
index 0000000..8d4d8b4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/SearchTextField.kt
@@ -0,0 +1,63 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.TextFieldValue
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun SearchTextField(
+ value: TextFieldValue,
+ onValueChange: (TextFieldValue) -> Unit,
+ hint: String,
+ modifier: Modifier = Modifier,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions(),
+ onCleared: (() -> Unit) = { onValueChange(TextFieldValue()) },
+) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null, // decorative
+ )
+ },
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ onCleared()
+ // This is mostly for iOS, otherwise there is no way to dismiss the iOS
+ // keyboard once opened.
+ keyboardController?.hide()
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = null,
+ )
+ }
+ },
+ placeholder = { Text(text = hint) },
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ maxLines = 1,
+ singleLine = true,
+ modifier = modifier,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/SortChip.kt b/app/src/main/java/org/xtimms/shirizu/core/components/SortChip.kt
new file mode 100644
index 0000000..a3c4e13
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/SortChip.kt
@@ -0,0 +1,72 @@
+package org.xtimms.shirizu.core.components
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Sort
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.FilterChip
+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.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.sections.library.history.SortOption
+
+@Composable
+fun SortChip(
+ sortOptions: List,
+ currentSortOption: SortOption,
+ modifier: Modifier = Modifier,
+ onSortSelected: (SortOption) -> Unit,
+) {
+ Box(modifier) {
+ var expanded by remember { mutableStateOf(false) }
+
+ FilterChip(
+ selected = true,
+ onClick = { expanded = true },
+ label = {
+ Text(
+ text = currentSortOption.label(LocalContext.current.resources),
+ modifier = Modifier.animateContentSize(),
+ )
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Sort,
+ contentDescription = null, // decorative
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = null, // decorative
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ )
+
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ SortDropdownMenuContent(
+ sortOptions = sortOptions,
+ currentSortOption = currentSortOption,
+ onItemClick = {
+ onSortSelected(it)
+ expanded = false
+ },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/SortMenuPopup.kt b/app/src/main/java/org/xtimms/shirizu/core/components/SortMenuPopup.kt
new file mode 100644
index 0000000..d69dcb5
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/SortMenuPopup.kt
@@ -0,0 +1,39 @@
+package org.xtimms.shirizu.core.components
+
+import android.content.res.Resources
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.sections.library.history.SortOption
+
+@Composable
+internal fun ColumnScope.SortDropdownMenuContent(
+ sortOptions: List,
+ onItemClick: (SortOption) -> Unit,
+ modifier: Modifier = Modifier,
+ currentSortOption: SortOption? = null,
+) {
+ val resources = LocalContext.current.resources
+ for (sort in sortOptions) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = sort.label(resources),
+ fontWeight = if (sort == currentSortOption) FontWeight.Bold else null,
+ )
+ },
+ onClick = { onItemClick(sort) },
+ modifier = modifier,
+ )
+ }
+}
+
+internal fun SortOption.label(resources: Resources): String = when (this) {
+ SortOption.ALPHABETICAL -> resources.getString(R.string.sort_alphabetically)
+ SortOption.DATE_ADDED -> resources.getString(R.string.sort_date_added)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/SourceItem.kt b/app/src/main/java/org/xtimms/shirizu/core/components/SourceItem.kt
index cdc7b22..bfd9f92 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/SourceItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/SourceItem.kt
@@ -24,12 +24,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import coil.ImageLoader
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable
fun SourceItem(
- coil: ImageLoader,
faviconUrl: Uri,
title: String,
modifier: Modifier = Modifier,
@@ -50,8 +49,7 @@ fun SourceItem(
.clip(MaterialTheme.shapes.large)
.aspectRatio(1f)
) {
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
@@ -79,7 +77,6 @@ fun SourceItem(
fun SourceItemPreview() {
ShirizuTheme {
SourceItem(
- coil = ImageLoader(LocalContext.current),
faviconUrl = "".toUri(),
title = "Test",
onClick = { }
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/TopAppBar.kt b/app/src/main/java/org/xtimms/shirizu/core/components/TopAppBar.kt
index 592b7be..7cc252e 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/TopAppBar.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/TopAppBar.kt
@@ -2,14 +2,8 @@ package org.xtimms.shirizu.core.components
import android.graphics.Path
import android.view.animation.PathInterpolator
-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.slideInHorizontally
-import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -18,10 +12,18 @@ 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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material.icons.outlined.RssFeed
@@ -36,210 +38,252 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.compose.ui.unit.sp
+import kotlinx.collections.immutable.ImmutableList
import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.DURATION_ENTER
-import org.xtimms.shirizu.core.DURATION_EXIT
-import org.xtimms.shirizu.core.initialOffset
+import org.xtimms.shirizu.core.components.icons.Shirizu
import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.toEasing
-import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
-import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
-import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
-import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
-import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
-import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
-import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
+import org.xtimms.shirizu.ui.theme.ShirizuTheme
+import org.xtimms.shirizu.utils.composable.secondaryItemAlpha
import java.time.LocalDate
import java.time.format.DateTimeFormatter
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun TopAppBar(
- navController: NavController,
- modifier: Modifier = Modifier,
- backgroundAlphaProvider: () -> Float,
- searchBarColorProvider: () -> Color,
-) {
- val navBackStackEntry by navController.currentBackStackEntryAsState()
- var expanded by remember { mutableStateOf(false) }
+fun AppBar(
+ title: String?,
- val isVisible by remember {
- derivedStateOf {
- when (navBackStackEntry?.destination?.route) {
- SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION,
- null -> true
+ modifier: Modifier = Modifier,
+ backgroundColor: Color? = null,
+ // Text
+ subtitle: String? = null,
+ // Up button
+ navigateUp: (() -> Unit)? = null,
+ navigationIcon: ImageVector? = null,
+ // Menu
+ actions: @Composable RowScope.() -> Unit = {},
+ // Action mode
+ actionModeCounter: Int = 0,
+ onCancelActionMode: () -> Unit = {},
+ actionModeActions: @Composable RowScope.() -> Unit = {},
- else -> false
- }
- }
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ val isActionMode by remember(actionModeCounter) {
+ derivedStateOf { actionModeCounter > 0 }
}
- val aprilFoolsDay = LocalDate.of(LocalDate.now().year, 4, 1)
- val dtStart = aprilFoolsDay.format(DateTimeFormatter.ISO_DATE)
- val currentDt = LocalDate.now()
+ AppBar(
+ modifier = modifier,
+ backgroundColor = backgroundColor,
+ titleContent = {
+ if (isActionMode) {
+ AppBarTitle(actionModeCounter.toString())
+ } else {
+ AppBarTitle(title, subtitle = subtitle)
+ }
+ },
+ navigateUp = navigateUp,
+ navigationIcon = navigationIcon,
+ actions = {
+ if (isActionMode) {
+ actionModeActions()
+ } else {
+ actions()
+ }
+ },
+ isActionMode = isActionMode,
+ onCancelActionMode = onCancelActionMode,
+ scrollBehavior = scrollBehavior,
+ )
+}
- val isAprilFoolsDay = currentDt.format(DateTimeFormatter.ISO_DATE).equals(dtStart)
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppBar(
+ // Title
+ titleContent: @Composable () -> Unit,
- val path = Path().apply {
- moveTo(0f, 0f)
- cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
- cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
- }
+ modifier: Modifier = Modifier,
+ backgroundColor: Color? = null,
+ // Up button
+ navigateUp: (() -> Unit)? = null,
+ navigationIcon: ImageVector? = null,
+ // Menu
+ actions: @Composable RowScope.() -> Unit = {},
+ // Action mode
+ isActionMode: Boolean = false,
+ onCancelActionMode: () -> Unit = {},
- val emphasizePathInterpolator = PathInterpolator(path)
- val emphasizeEasing = emphasizePathInterpolator.toEasing()
-
- val enterTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
- val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
- val fadeTween = tween(durationMillis = DURATION_EXIT)
-
- AnimatedVisibility(
- visible = isVisible,
- enter = slideInHorizontally(
- enterTween,
- initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
- exit = slideOutHorizontally(
- exitTween,
- targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ Column(
+ modifier = modifier,
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- MaterialTheme.colorScheme
- .surfaceColorAtElevation(3.dp)
- .copy(
- alpha = backgroundAlphaProvider()
- )
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Card(
- onClick = { navController.navigate(SEARCH_DESTINATION) },
- modifier = modifier
- .weight(1f)
- .height(56.dp)
- .padding(start = 16.dp),
- shape = RoundedCornerShape(50),
- colors = CardDefaults.cardColors().copy(containerColor = searchBarColorProvider()),
- ) {
- Row(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxHeight(),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = if (isAprilFoolsDay) Icons.Outlined.SentimentSatisfiedAlt else Icons.Outlined.Search,
- contentDescription = "search",
- tint = MaterialTheme.colorScheme.outline
- )
- Text(
- text = stringResource(R.string.search),
- modifier = Modifier.weight(1f),
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- Row(
- modifier = modifier.padding(end = 16.dp),
- ) {
- if (AppSettings.isTrackerEnabled()) {
- IconButton(
- onClick = { navController.navigate(FEED_DESTINATION) },
- modifier = Modifier.padding(0.dp),
- ) {
- Icon(
- Icons.Outlined.RssFeed,
- contentDescription = stringResource(id = R.string.feed),
- tint = MaterialTheme.colorScheme.outline
- )
- }
- IconButton(
- onClick = { expanded = true },
- modifier = Modifier.padding(0.dp),
- ) {
+ androidx.compose.material3.TopAppBar(
+ navigationIcon = {
+ if (isActionMode) {
+ IconButton(onClick = onCancelActionMode) {
Icon(
- Icons.Outlined.MoreVert,
- contentDescription = stringResource(id = R.string.open_menu),
- tint = MaterialTheme.colorScheme.outline
- )
- }
- DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
- DropdownMenuItem(
- text = { Text(text = stringResource(id = R.string.statistics)) },
- onClick = {
- navController.navigate(STATS_DESTINATION)
- expanded = false
- },
- leadingIcon = {
- Icon(
- imageVector = Icons.Outlined.QueryStats,
- contentDescription = stringResource(id = R.string.statistics)
- )
- }
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(id = R.string.settings)) },
- onClick = {
- navController.navigate(SETTINGS_DESTINATION)
- expanded = false
- },
- leadingIcon = {
- Icon(
- imageVector = Icons.Outlined.Settings,
- contentDescription = stringResource(id = R.string.settings)
- )
- }
+ imageVector = Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.cancel),
)
}
} else {
- IconButton(
- onClick = { navController.navigate(STATS_DESTINATION) },
- modifier = Modifier.padding(0.dp),
- ) {
- Icon(
- Icons.Outlined.QueryStats,
- contentDescription = stringResource(id = R.string.statistics),
- tint = MaterialTheme.colorScheme.outline
- )
- }
- IconButton(
- onClick = { navController.navigate(SETTINGS_DESTINATION) },
- modifier = Modifier.padding(0.dp),
- ) {
- Icon(
- Icons.Outlined.Settings,
- contentDescription = stringResource(id = R.string.settings),
- tint = MaterialTheme.colorScheme.outline
- )
+ navigateUp?.let {
+ IconButton(onClick = it) {
+ UpIcon(navigationIcon = navigationIcon)
+ }
}
}
+ },
+ title = titleContent,
+ actions = actions,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = backgroundColor
+ ?: MaterialTheme.colorScheme.surfaceColorAtElevation(
+ elevation = if (isActionMode) 3.dp else 0.dp,
+ ),
+ ),
+ scrollBehavior = scrollBehavior,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppToolbar(
+ modifier: Modifier = Modifier,
+ actions: @Composable RowScope.() -> Unit = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ AppBar(
+ modifier = modifier,
+ titleContent = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(22.dp),
+ imageVector = Icons.Filled.Shirizu,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = stringResource(id = R.string.app_name),
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight(900)
+ )
+ }
+ },
+ actions = { key("actions") { actions() } },
+ isActionMode = false,
+ scrollBehavior = scrollBehavior,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppBarActions(
+ actions: ImmutableList,
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ actions.filterIsInstance().map {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(it.title)
+ }
+ },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(
+ onClick = it.onClick,
+ enabled = it.enabled,
+ ) {
+ Icon(
+ imageVector = it.icon,
+ tint = it.iconTint ?: LocalContentColor.current,
+ contentDescription = it.title,
+ )
+ }
+ }
+ }
+
+ val overflowActions = actions.filterIsInstance()
+ if (overflowActions.isNotEmpty()) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(stringResource(R.string.action_menu_overflow_description))
+ }
+ },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(
+ onClick = { showMenu = !showMenu },
+ ) {
+ Icon(
+ Icons.Outlined.MoreVert,
+ contentDescription = stringResource(R.string.action_menu_overflow_description),
+ )
+ }
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ ) {
+ overflowActions.map {
+ DropdownMenuItem(
+ onClick = {
+ it.onClick()
+ showMenu = false
+ },
+ text = { Text(it.title, fontWeight = FontWeight.Normal) },
+ )
}
}
}
@@ -297,10 +341,10 @@ fun SmallTopAppBarWithChips(
}
private val path = Path().apply {
- moveTo(0f,0f)
+ moveTo(0f, 0f)
lineTo(0.7f, 0.1f)
cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F)
- moveTo(1f,1f)
+ moveTo(1f, 1f)
}
val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) }
@@ -374,4 +418,177 @@ fun AppBarTitle(
)
}
}
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
+@Composable
+fun SearchToolbar(
+ searchQuery: String?,
+ onChangeSearchQuery: (String?) -> Unit,
+ modifier: Modifier = Modifier,
+ titleContent: @Composable () -> Unit = {},
+ navigateUp: (() -> Unit)? = null,
+ searchEnabled: Boolean = true,
+ placeholderText: String? = null,
+ onSearch: (String) -> Unit = {},
+ onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) },
+ actions: @Composable RowScope.() -> Unit = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ AppBar(
+ modifier = modifier,
+ titleContent = {
+ if (searchQuery == null) return@AppBar titleContent()
+
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val focusManager = LocalFocusManager.current
+
+ val searchAndClearFocus: () -> Unit = f@{
+ if (searchQuery.isBlank()) return@f
+ onSearch(searchQuery)
+ focusManager.clearFocus()
+ keyboardController?.hide()
+ }
+
+ BasicTextField(
+ value = searchQuery,
+ onValueChange = onChangeSearchQuery,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ textStyle = MaterialTheme.typography.titleMedium.copy(
+ color = MaterialTheme.colorScheme.onBackground,
+ fontWeight = FontWeight.Normal,
+ fontSize = 18.sp,
+ ),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
+ singleLine = true,
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
+ visualTransformation = visualTransformation,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ TextFieldDefaults.TextFieldDecorationBox(
+ value = searchQuery,
+ innerTextField = innerTextField,
+ enabled = true,
+ singleLine = true,
+ visualTransformation = visualTransformation,
+ interactionSource = interactionSource,
+ placeholder = {
+ Text(
+ modifier = Modifier.secondaryItemAlpha(),
+ text = (placeholderText ?: stringResource(R.string.search)),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Normal,
+ ),
+ )
+ },
+ )
+ },
+ )
+ },
+ navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch,
+ actions = {
+ key("search") {
+ val onClick = { onChangeSearchQuery("") }
+
+ if (!searchEnabled) {
+ // Don't show search action
+ } else if (searchQuery == null) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(stringResource(R.string.search))
+ }
+ },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(
+ onClick = onClick,
+ ) {
+ Icon(
+ Icons.Outlined.Search,
+ contentDescription = stringResource(R.string.search),
+ )
+ }
+ }
+ } else if (searchQuery.isNotEmpty()) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
+ tooltip = {
+ PlainTooltip {
+ Text(stringResource(R.string.action_reset))
+ }
+ },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(
+ onClick = {
+ onClick()
+ focusRequester.requestFocus()
+ },
+ ) {
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.action_reset),
+ )
+ }
+ }
+ }
+ }
+
+ key("actions") { actions() }
+ },
+ isActionMode = false,
+ scrollBehavior = scrollBehavior,
+ )
+}
+
+@Composable
+fun UpIcon(
+ modifier: Modifier = Modifier,
+ navigationIcon: ImageVector? = null,
+) {
+ val icon = navigationIcon
+ ?: Icons.AutoMirrored.Outlined.ArrowBack
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = modifier,
+ )
+}
+
+sealed interface AppBar {
+ sealed interface AppBarAction
+
+ data class Action(
+ val title: String,
+ val icon: ImageVector,
+ val iconTint: Color? = null,
+ val onClick: () -> Unit,
+ val enabled: Boolean = true,
+ ) : AppBarAction
+
+ data class OverflowAction(
+ val title: String,
+ val onClick: () -> Unit,
+ ) : AppBarAction
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+fun SearchToolbarPreview() {
+ ShirizuTheme {
+ AppToolbar()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/VerticalFastScroller.kt b/app/src/main/java/org/xtimms/shirizu/core/components/VerticalFastScroller.kt
new file mode 100644
index 0000000..ce6e12e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/VerticalFastScroller.kt
@@ -0,0 +1,451 @@
+package org.xtimms.shirizu.core.components
+
+import android.view.ViewConfiguration
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsDraggedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyListItemInfo
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.systemGestureExclusion
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMaxBy
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.sample
+import org.xtimms.shirizu.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+/**
+ * Draws vertical fast scroller to a lazy list
+ *
+ * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
+ */
+@Composable
+fun VerticalFastScroller(
+ listState: LazyListState,
+ modifier: Modifier = Modifier,
+ thumbAllowed: () -> Boolean = { true },
+ thumbColor: Color = MaterialTheme.colorScheme.primary,
+ topContentPadding: Dp = Dp.Hairline,
+ bottomContentPadding: Dp = Dp.Hairline,
+ endContentPadding: Dp = Dp.Hairline,
+ content: @Composable () -> Unit,
+) {
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
+ val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
+ val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val scrollerPlaceable = subcompose("scroller") {
+ val layoutInfo = listState.layoutInfo
+ val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
+ if (!showScroller) return@subcompose
+
+ val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
+ var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
+
+ val dragInteractionSource = remember { MutableInteractionSource() }
+ val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
+ val scrolled = remember {
+ MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ }
+
+ val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
+ val heightPx = contentHeight.toFloat() -
+ thumbTopPadding -
+ thumbBottomPadding -
+ listState.layoutInfo.afterContentPadding
+ val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
+ val trackHeightPx = heightPx - thumbHeightPx
+
+ // When thumb dragged
+ LaunchedEffect(thumbOffsetY) {
+ if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
+ val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
+ val scrollItem = layoutInfo.totalItemsCount * scrollRatio
+ val scrollItemRounded = scrollItem.roundToInt()
+ val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
+ val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
+ listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
+ scrolled.tryEmit(Unit)
+ }
+
+ // When list scrolled
+ LaunchedEffect(listState.firstVisibleItemScrollOffset) {
+ if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
+ val scrollOffset = computeScrollOffset(state = listState)
+ val scrollRange = computeScrollRange(state = listState)
+ val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
+ thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
+ scrolled.tryEmit(Unit)
+ }
+
+ // Thumb alpha
+ val alpha = remember { Animatable(0f) }
+ val isThumbVisible = alpha.value > 0f
+ LaunchedEffect(scrolled, alpha) {
+ scrolled
+ .sample(100)
+ .collectLatest {
+ if (thumbAllowed()) {
+ alpha.snapTo(1f)
+ alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
+ } else {
+ alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
+ .then(
+ // Recompose opts
+ if (isThumbVisible && !listState.isScrollInProgress) {
+ Modifier.draggable(
+ interactionSource = dragInteractionSource,
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { delta ->
+ val newOffsetY = thumbOffsetY + delta
+ thumbOffsetY = newOffsetY.coerceIn(
+ thumbTopPadding,
+ thumbTopPadding + trackHeightPx,
+ )
+ },
+ )
+ } else {
+ Modifier
+ },
+ )
+ .then(
+ // Exclude thumb from gesture area only when needed
+ if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
+ Modifier.systemGestureExclusion()
+ } else {
+ Modifier
+ },
+ )
+ .height(ThumbLength)
+ .padding(horizontal = 8.dp)
+ .padding(end = endContentPadding)
+ .width(ThumbThickness)
+ .alpha(alpha.value)
+ .background(color = thumbColor, shape = ThumbShape),
+ )
+ }.map { it.measure(scrollerConstraints) }
+ val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ layout(contentWidth, contentHeight) {
+ contentPlaceable.fastForEach {
+ it.place(0, 0)
+ }
+ scrollerPlaceable.fastForEach {
+ it.placeRelative(contentWidth - scrollerWidth, 0)
+ }
+ }
+ }
+}
+
+@Composable
+private fun rememberColumnWidthSums(
+ columns: GridCells,
+ horizontalArrangement: Arrangement.Horizontal,
+ contentPadding: PaddingValues,
+) = remember List>(
+ columns,
+ horizontalArrangement,
+ contentPadding,
+) {
+ {
+ constraints ->
+ require(constraints.maxWidth != Constraints.Infinity) {
+ "LazyVerticalGrid's width should be bound by parent"
+ }
+ val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
+ contentPadding.calculateEndPadding(LayoutDirection.Ltr)
+ val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
+ with(columns) {
+ calculateCrossAxisCellSizes(
+ gridWidth,
+ horizontalArrangement.spacing.roundToPx(),
+ ).toMutableList().apply {
+ for (i in 1.. Boolean = { true },
+ thumbColor: Color = MaterialTheme.colorScheme.primary,
+ topContentPadding: Dp = Dp.Hairline,
+ bottomContentPadding: Dp = Dp.Hairline,
+ endContentPadding: Dp = Dp.Hairline,
+ content: @Composable () -> Unit,
+) {
+ val slotSizesSums = rememberColumnWidthSums(
+ columns = columns,
+ horizontalArrangement = arrangement,
+ contentPadding = contentPadding,
+ )
+
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
+ val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
+ val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val scrollerPlaceable = subcompose("scroller") {
+ val layoutInfo = state.layoutInfo
+ val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
+ if (!showScroller) return@subcompose
+ val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
+ var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
+
+ val dragInteractionSource = remember { MutableInteractionSource() }
+ val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
+ val scrolled = remember {
+ MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ }
+
+ val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
+ val heightPx = contentHeight.toFloat() -
+ thumbTopPadding -
+ thumbBottomPadding -
+ state.layoutInfo.afterContentPadding
+ val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
+ val trackHeightPx = heightPx - thumbHeightPx
+
+ val columnCount = remember { slotSizesSums(constraints).size }
+
+ // When thumb dragged
+ LaunchedEffect(thumbOffsetY) {
+ if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
+ val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
+ val scrollItem = layoutInfo.totalItemsCount * scrollRatio
+ // I can't think of anything else rn but this'll do
+ val scrollItemWhole = scrollItem.toInt()
+ val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount
+ val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole
+ val offsetPerItem = 1f / columnCount
+ val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1))
+
+ // TODO: Sometimes item height is not available when scrolling up
+ val scrollItemSize = (1..columnCount).maxOf { num ->
+ val actualIndex = if (num != columnNum) {
+ scrollItemWhole + num - columnCount
+ } else {
+ scrollItemWhole
+ }
+ layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0
+ }
+ val scrollItemOffset = scrollItemSize * offsetRatio
+
+ state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt())
+ scrolled.tryEmit(Unit)
+ }
+
+ // When list scrolled
+ LaunchedEffect(state.firstVisibleItemScrollOffset) {
+ if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
+ val scrollOffset = computeScrollOffset(state = state)
+ val scrollRange = computeScrollRange(state = state)
+ val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
+ thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
+ scrolled.tryEmit(Unit)
+ }
+
+ // Thumb alpha
+ val alpha = remember { Animatable(0f) }
+ val isThumbVisible = alpha.value > 0f
+ LaunchedEffect(scrolled, alpha) {
+ scrolled
+ .sample(100)
+ .collectLatest {
+ if (thumbAllowed()) {
+ alpha.snapTo(1f)
+ alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
+ } else {
+ alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
+ .then(
+ // Recompose opts
+ if (isThumbVisible && !state.isScrollInProgress) {
+ Modifier.draggable(
+ interactionSource = dragInteractionSource,
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { delta ->
+ val newOffsetY = thumbOffsetY + delta
+ thumbOffsetY = newOffsetY.coerceIn(
+ thumbTopPadding,
+ thumbTopPadding + trackHeightPx,
+ )
+ },
+ )
+ } else {
+ Modifier
+ },
+ )
+ .then(
+ // Exclude thumb from gesture area only when needed
+ if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) {
+ Modifier.systemGestureExclusion()
+ } else {
+ Modifier
+ },
+ )
+ .height(ThumbLength)
+ .padding(end = endContentPadding)
+ .width(ThumbThickness)
+ .alpha(alpha.value)
+ .background(color = thumbColor, shape = ThumbShape),
+ )
+ }.map { it.measure(scrollerConstraints) }
+ val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ layout(contentWidth, contentHeight) {
+ contentPlaceable.fastForEach {
+ it.place(0, 0)
+ }
+ scrollerPlaceable.fastForEach {
+ it.placeRelative(contentWidth - scrollerWidth, 0)
+ }
+ }
+ }
+}
+
+private fun computeScrollOffset(state: LazyGridState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems.first()
+ val endChild = visibleItems.last()
+ val minPosition = min(startChild.index, endChild.index)
+ val maxPosition = max(startChild.index, endChild.index)
+ val itemsBefore = minPosition.coerceAtLeast(0)
+ val startDecoratedTop = startChild.offset.y
+ val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop)
+ val itemRange = abs(minPosition - maxPosition) + 1
+ val avgSizePerRow = laidOutArea.toFloat() / itemRange
+ return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
+}
+
+private fun computeScrollRange(state: LazyGridState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems.first()
+ val endChild = visibleItems.last()
+ val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
+ val laidOutRange = abs(startChild.index - endChild.index) + 1
+ return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
+}
+
+private fun computeScrollOffset(state: LazyListState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems
+ .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
+ val endChild = visibleItems.last()
+ val minPosition = min(startChild.index, endChild.index)
+ val maxPosition = max(startChild.index, endChild.index)
+ val itemsBefore = minPosition.coerceAtLeast(0)
+ val startDecoratedTop = startChild.top
+ val laidOutArea = abs(endChild.bottom - startDecoratedTop)
+ val itemRange = abs(minPosition - maxPosition) + 1
+ val avgSizePerRow = laidOutArea.toFloat() / itemRange
+ return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
+}
+
+private fun computeScrollRange(state: LazyListState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems
+ .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
+ val endChild = visibleItems.last()
+ val laidOutArea = endChild.bottom - startChild.top
+ val laidOutRange = abs(startChild.index - endChild.index) + 1
+ return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
+}
+
+object Scroller {
+ const val STICKY_HEADER_KEY_PREFIX = "sticky:"
+}
+
+private val ThumbLength = 48.dp
+private val ThumbThickness = 6.dp
+private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
+private val FadeOutAnimationSpec = tween(
+ durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
+ delayMillis = 2000,
+)
+private val ImmediateFadeOutAnimationSpec = tween(
+ durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
+)
+
+private val LazyListItemInfo.top: Int
+ get() = offset
+
+private val LazyListItemInfo.bottom: Int
+ get() = offset + size
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/effects/ListAnimation.kt b/app/src/main/java/org/xtimms/shirizu/core/components/effects/ListAnimation.kt
index fae1ab9..fc37aae 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/effects/ListAnimation.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/effects/ListAnimation.kt
@@ -159,9 +159,7 @@ data class AnimatedItem(
other as AnimatedItem<*>
- if (item != other.item) return false
-
- return true
+ return item == other.item
}
}
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/icons/ArrowDecisionOutline.kt b/app/src/main/java/org/xtimms/shirizu/core/components/icons/ArrowDecisionOutline.kt
index 75c4188..9704cb1 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/icons/ArrowDecisionOutline.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/icons/ArrowDecisionOutline.kt
@@ -5,7 +5,7 @@ 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
+val Icons.Outlined.ArrowDecisionOutline: ImageVector
get() {
if (_arrow_decision_outline != null) {
return _arrow_decision_outline!!
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Creation.kt b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Creation.kt
index b352826..925ab31 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Creation.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Creation.kt
@@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
-public val Icons.Outlined.Creation: ImageVector
+val Icons.Outlined.Creation: ImageVector
get() {
if (_creation != null) {
return _creation!!
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Dice.kt b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Dice.kt
index f7419f8..ab7db55 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Dice.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Dice.kt
@@ -5,7 +5,7 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
-public val Icons.Outlined.Dice: ImageVector
+val Icons.Outlined.Dice: ImageVector
get() {
if (_dice != null) {
return _dice!!
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Kotatsu.kt b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Kotatsu.kt
index c57df6c..8984fb6 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Kotatsu.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Kotatsu.kt
@@ -11,7 +11,7 @@ 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
+val Icons.Filled.Kotatsu: ImageVector
get() {
if (_kotatsu != null) {
return _kotatsu!!
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/icons/Shirizu.kt b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Shirizu.kt
new file mode 100644
index 0000000..dc9eb22
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/icons/Shirizu.kt
@@ -0,0 +1,67 @@
+package org.xtimms.shirizu.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
+
+val Icons.Filled.Shirizu: ImageVector
+ get() {
+ if (_shirizu != null) {
+ return _shirizu!!
+ }
+ _shirizu = Builder(name = "Shirizu", defaultWidth = 30.0.dp, defaultHeight = 30.0.dp,
+ viewportWidth = 30.0f, viewportHeight = 30.0f).apply {
+ path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
+ strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
+ pathFillType = NonZero) {
+ moveTo(9.1f, 17.6f)
+ curveToRelative(-0.8f, -0.4f, -1.7f, -0.9f, -2.8f, -1.3f)
+ curveToRelative(-1.1f, -0.4f, -2.2f, -0.8f, -3.3f, -1.2f)
+ curveToRelative(-1.1f, -0.3f, -2.1f, -0.6f, -2.9f, -0.8f)
+ lineToRelative(1.6f, -5.3f)
+ curveToRelative(1.0f, 0.2f, 2.1f, 0.5f, 3.2f, 0.8f)
+ curveToRelative(1.1f, 0.4f, 2.3f, 0.8f, 3.4f, 1.2f)
+ curveToRelative(1.1f, 0.4f, 2.1f, 0.9f, 3.1f, 1.3f)
+ lineTo(9.1f, 17.6f)
+ close()
+ moveTo(30.0f, 15.7f)
+ curveToRelative(-1.0f, 1.5f, -2.2f, 3.0f, -3.6f, 4.4f)
+ curveToRelative(-1.4f, 1.4f, -2.9f, 2.7f, -4.5f, 3.9f)
+ curveToRelative(-1.6f, 1.2f, -3.2f, 2.3f, -4.9f, 3.2f)
+ curveToRelative(-1.7f, 0.9f, -3.3f, 1.6f, -4.9f, 2.1f)
+ curveTo(10.6f, 29.7f, 9.1f, 30.0f, 7.8f, 30.0f)
+ curveToRelative(-1.8f, 0.0f, -3.4f, -0.6f, -4.8f, -1.7f)
+ curveToRelative(-1.4f, -1.1f, -2.3f, -3.0f, -2.9f, -5.6f)
+ lineTo(5.0f, 20.5f)
+ curveToRelative(0.3f, 1.4f, 0.8f, 2.4f, 1.4f, 3.0f)
+ curveToRelative(0.6f, 0.6f, 1.4f, 0.9f, 2.3f, 0.9f)
+ curveToRelative(0.6f, 0.0f, 1.4f, -0.2f, 2.5f, -0.6f)
+ curveToRelative(1.1f, -0.4f, 2.2f, -0.9f, 3.5f, -1.7f)
+ curveToRelative(1.3f, -0.7f, 2.6f, -1.6f, 4.0f, -2.7f)
+ curveToRelative(1.4f, -1.1f, 2.7f, -2.3f, 4.0f, -3.7f)
+ curveToRelative(1.3f, -1.4f, 2.4f, -2.9f, 3.4f, -4.6f)
+ lineTo(30.0f, 15.7f)
+ close()
+ moveTo(12.9f, 10.3f)
+ curveToRelative(-1.0f, -0.9f, -2.3f, -1.9f, -4.0f, -2.8f)
+ curveTo(7.3f, 6.6f, 5.6f, 5.8f, 3.8f, 5.0f)
+ lineToRelative(1.9f, -5.0f)
+ curveTo(7.0f, 0.4f, 8.2f, 1.0f, 9.5f, 1.6f)
+ curveToRelative(1.2f, 0.6f, 2.4f, 1.3f, 3.5f, 2.0f)
+ curveToRelative(1.1f, 0.7f, 2.0f, 1.3f, 2.7f, 2.0f)
+ lineTo(12.9f, 10.3f)
+ close()
+ }
+ }
+ .build()
+ return _shirizu!!
+ }
+
+private var _shirizu: ImageVector? = null
diff --git a/app/src/main/java/org/xtimms/shirizu/core/components/shape/WavyShape.kt b/app/src/main/java/org/xtimms/shirizu/core/components/shape/WavyShape.kt
index 81a39f2..754bc77 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/components/shape/WavyShape.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/components/shape/WavyShape.kt
@@ -29,11 +29,11 @@ class WavyShape(
moveTo(x = 0f, y = 0f)
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
- relativeQuadraticBezierTo(
+ relativeQuadraticTo(
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dy1 = halfPeriod / 2,
dx2 = 0f,
- dy2 = halfPeriod,
+ dy2 = halfPeriod
)
}
lineTo(0f, size.height)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/ShirizuDatabase.kt b/app/src/main/java/org/xtimms/shirizu/core/database/ShirizuDatabase.kt
index 3e5d8f4..6be6547 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/ShirizuDatabase.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/ShirizuDatabase.kt
@@ -36,9 +36,12 @@ import org.xtimms.shirizu.core.database.entity.TrackEntity
import org.xtimms.shirizu.core.database.entity.TrackLogEntity
import org.xtimms.shirizu.core.database.migrations.Migration1To2
import org.xtimms.shirizu.core.database.migrations.Migration2To3
+import org.xtimms.shirizu.core.database.migrations.Migration3To4
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblingDao
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
import org.xtimms.shirizu.utils.lang.processLifecycleScope
-const val DATABASE_VERSION = 3
+const val DATABASE_VERSION = 4
@Database(
entities = [
@@ -54,6 +57,7 @@ const val DATABASE_VERSION = 3
TrackEntity::class,
TrackLogEntity::class,
StatsEntity::class,
+ ScrobblingEntity::class,
],
version = DATABASE_VERSION
)
@@ -81,11 +85,14 @@ abstract class ShirizuDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao
+ abstract fun getScrobblingDao(): ScrobblingDao
+
}
fun getDatabaseMigrations(context: Context): Array = arrayOf(
Migration1To2(),
- Migration2To3()
+ Migration2To3(),
+ Migration3To4()
)
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/dao/FavouritesDao.kt b/app/src/main/java/org/xtimms/shirizu/core/database/dao/FavouritesDao.kt
index e3009f3..21f96c4 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/dao/FavouritesDao.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/dao/FavouritesDao.kt
@@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
+import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
import org.xtimms.shirizu.core.database.entity.FavouriteEntity
import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.model.ListSortOrder
@@ -89,9 +90,6 @@ 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
@@ -99,6 +97,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
+ @Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
+ abstract suspend fun findAllRaw(mangaId: Long): List
+
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
@@ -107,7 +108,10 @@ abstract class FavouritesDao {
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow>
- @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0")
+ @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0")
+ abstract fun observeCategories(mangaId: Long): Flow>
+
+ @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection): List
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
@@ -172,7 +176,5 @@ abstract class FavouritesDao {
ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
-
- else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaDao.kt b/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaDao.kt
index 78b689d..feae7a7 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaDao.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaDao.kt
@@ -28,6 +28,17 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List
+ @Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
+ abstract suspend fun findAuthors(query: String, limit: Int): List
+
+ @Transaction
+ @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
+ abstract suspend fun searchByTitle(query: String, limit: Int): List
+
+ @Transaction
+ @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
+ abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List
+
@Upsert
abstract suspend fun upsert(manga: MangaEntity)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaSourcesDao.kt b/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaSourcesDao.kt
index 135d3eb..09ce76c 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaSourcesDao.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/dao/MangaSourcesDao.kt
@@ -1,6 +1,8 @@
package org.xtimms.shirizu.core.database.dao
import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
@@ -33,6 +35,10 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ @Transaction
+ abstract suspend fun insertIfAbsent(entries: Collection)
+
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/dao/SuggestionDao.kt b/app/src/main/java/org/xtimms/shirizu/core/database/dao/SuggestionDao.kt
index fd120fb..25e76d9 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/dao/SuggestionDao.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/dao/SuggestionDao.kt
@@ -25,6 +25,10 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
abstract suspend fun getRandom(): SuggestionWithManga?
+ @Transaction
+ @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit")
+ abstract suspend fun getRandom(limit: Int): List
+
@Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/shirizu/core/database/entity/EntityMapping.kt
index c0776cb..757c0ee 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/database/entity/EntityMapping.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/entity/EntityMapping.kt
@@ -13,6 +13,8 @@ import org.xtimms.shirizu.core.model.MangaHistory
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.sections.shelf.FavouriteManga
+import org.xtimms.shirizu.sections.shelf.ShelfCategory
+import org.xtimms.shirizu.sections.shelf.ShelfManga
import org.xtimms.shirizu.utils.lang.longHashCode
import java.time.Instant
@@ -44,6 +46,25 @@ fun MangaEntity.toManga(tags: Set) = Manga(
tags = tags,
)
+fun MangaEntity.toShelfManga(tags: Set) = ShelfManga(
+ Manga(
+ id = this.id,
+ title = this.title,
+ altTitle = this.altTitle,
+ state = this.state?.let { MangaState(it) },
+ rating = this.rating,
+ isNsfw = this.isNsfw,
+ url = this.url,
+ publicUrl = this.publicUrl,
+ coverUrl = this.coverUrl,
+ largeCoverUrl = this.largeCoverUrl,
+ author = this.author,
+ source = MangaSource(this.source),
+ tags = tags,
+ ),
+ category = 1
+)
+
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
@@ -56,10 +77,20 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
isVisibleInLibrary = isVisibleInLibrary,
)
+fun FavouriteCategoryEntity.toShelfCategory(id: Long = categoryId.toLong()) = ShelfCategory(
+ id = id,
+ title = title,
+ mangaCount = 0
+)
+
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
+fun FavouriteManga.toShelfManga() = manga.toShelfManga(tags.toMangaTags())
+
fun Collection.toMangaList() = map { it.toManga() }
+fun Collection.toShelfMangaList() = map { it.toShelfManga() }
+
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
diff --git a/app/src/main/java/org/xtimms/shirizu/core/database/migrations/Migration3To4.kt b/app/src/main/java/org/xtimms/shirizu/core/database/migrations/Migration3To4.kt
new file mode 100644
index 0000000..c220b51
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/database/migrations/Migration3To4.kt
@@ -0,0 +1,25 @@
+package org.xtimms.shirizu.core.database.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration3To4 : Migration(3, 4) {
+
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `scrobblings` (
+ `scrobbler` INTEGER NOT NULL,
+ `id` INTEGER NOT NULL,
+ `manga_id` INTEGER NOT NULL,
+ `target_id` INTEGER NOT NULL,
+ `status` TEXT,
+ `chapter` INTEGER NOT NULL,
+ `comment` TEXT,
+ `rating` REAL NOT NULL,
+ PRIMARY KEY(`scrobbler`, `id`, `manga_id`)
+ )
+ """.trimIndent()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/model/MangaCover.kt b/app/src/main/java/org/xtimms/shirizu/core/model/MangaCover.kt
new file mode 100644
index 0000000..87eebad
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/model/MangaCover.kt
@@ -0,0 +1,18 @@
+package org.xtimms.shirizu.core.model
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+data class MangaCover(
+ val mangaId: Long,
+ val source: MangaSource,
+ val url: String?,
+)
+
+fun Manga.asMangaCover(): MangaCover {
+ return MangaCover(
+ mangaId = id,
+ source = source,
+ url = largeCoverUrl ?: coverUrl,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/model/ShelfCategory.kt b/app/src/main/java/org/xtimms/shirizu/core/model/ShelfCategory.kt
deleted file mode 100644
index 61bd02c..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/model/ShelfCategory.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.xtimms.shirizu.core.model
-
-import java.io.Serializable
-
-data class ShelfCategory(
- val id: Long,
- val name: String,
- val order: Long,
- val flags: Long,
-) : Serializable {
-
- val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
-
- companion object {
- const val UNCATEGORIZED_ID = 0L
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/motion/MaterialSharedAxis.kt b/app/src/main/java/org/xtimms/shirizu/core/motion/MaterialSharedAxis.kt
index 47bcbb1..9ccecbf 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/motion/MaterialSharedAxis.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/motion/MaterialSharedAxis.kt
@@ -25,7 +25,7 @@ private val Int.ForIncoming: Int
* [materialSharedAxisX] allows to switch a layout with shared X-axis transition.
*
*/
-public fun materialSharedAxisX(
+fun materialSharedAxisX(
initialOffsetX: (fullWidth: Int) -> Int,
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
@@ -40,7 +40,7 @@ public fun materialSharedAxisX(
/**
* [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
*/
-public fun materialSharedAxisXIn(
+fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInHorizontally(
@@ -61,7 +61,7 @@ public fun materialSharedAxisXIn(
* [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
*
*/
-public fun materialSharedAxisXOut(
+fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutHorizontally(
diff --git a/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingScreen.kt b/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..271ab74
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingScreen.kt
@@ -0,0 +1,81 @@
+package org.xtimms.shirizu.core.onboarding
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.RocketLaunch
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.InfoScreen
+import org.xtimms.shirizu.utils.lang.materialSharedAxisX
+
+@Composable
+fun OnboardingScreen(
+ onComplete: () -> Unit,
+) {
+
+ var currentStep by rememberSaveable { mutableIntStateOf(0) }
+ val steps = remember {
+ listOf(
+ SourcesStep(),
+ )
+ }
+ val isLastStep = currentStep == steps.lastIndex
+
+ BackHandler(enabled = currentStep != 0, onBack = { currentStep-- })
+
+ InfoScreen(
+ icon = Icons.Outlined.RocketLaunch,
+ headingText1 = stringResource(R.string.onboarding_heading),
+ subtitleText = stringResource(R.string.onboarding_description),
+ acceptText = stringResource(
+ if (isLastStep) {
+ R.string.onboarding_action_finish
+ } else {
+ R.string.onboarding_action_next
+ },
+ ),
+ canAccept = steps[currentStep].isComplete,
+ onAcceptClick = {
+ if (isLastStep) {
+ onComplete()
+ } else {
+ currentStep++
+ }
+ },
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .clip(MaterialTheme.shapes.small)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ AnimatedContent(
+ targetState = currentStep,
+ transitionSpec = {
+ materialSharedAxisX(
+ forward = targetState > initialState
+ )
+ },
+ label = "stepContent",
+ ) {
+ steps[it].Content()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingStep.kt b/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingStep.kt
new file mode 100644
index 0000000..0178e70
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/onboarding/OnboardingStep.kt
@@ -0,0 +1,11 @@
+package org.xtimms.shirizu.core.onboarding
+
+import androidx.compose.runtime.Composable
+
+internal interface OnboardingStep {
+
+ val isComplete: Boolean
+
+ @Composable
+ fun Content()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/onboarding/SourcesStep.kt b/app/src/main/java/org/xtimms/shirizu/core/onboarding/SourcesStep.kt
new file mode 100644
index 0000000..ca7c6fb
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/onboarding/SourcesStep.kt
@@ -0,0 +1,14 @@
+package org.xtimms.shirizu.core.onboarding
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+
+internal class SourcesStep : OnboardingStep {
+
+ override val isComplete: Boolean = true
+
+ @Composable
+ override fun Content() {
+ Text(text = "Hello")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/os/NetworkState.kt b/app/src/main/java/org/xtimms/shirizu/core/os/NetworkState.kt
index 64c9e9b..2320be5 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/os/NetworkState.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/os/NetworkState.kt
@@ -18,7 +18,9 @@ class NetworkState(
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder()
- .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+ .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
}
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaDataRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaDataRepository.kt
index cfa029a..b0b80d8 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaDataRepository.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaDataRepository.kt
@@ -13,7 +13,6 @@ import javax.inject.Provider
@Reusable
class MangaDataRepository @Inject constructor(
private val db: ShirizuDatabase,
- private val resolverProvider: Provider,
) {
suspend fun findMangaById(mangaId: Long): Manga? {
@@ -24,13 +23,6 @@ class MangaDataRepository @Inject constructor(
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
}
- suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
- intent.manga != null -> intent.manga
- intent.mangaId != 0L -> findMangaById(intent.mangaId)
- intent.uri != null -> resolverProvider.get().resolve(intent.uri)
- else -> null
- }
-
suspend fun storeManga(manga: Manga) {
db.withTransaction {
val tags = manga.tags.toEntities()
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLinkResolver.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLinkResolver.kt
deleted file mode 100644
index 5ef06b4..0000000
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLinkResolver.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package org.xtimms.shirizu.core.parser
-
-import android.net.Uri
-import coil.request.CachePolicy
-import dagger.Reusable
-import org.koitharu.kotatsu.parsers.exception.NotFoundException
-import org.koitharu.kotatsu.parsers.model.ContentType
-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.levenshteinDistance
-import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
-import org.koitharu.kotatsu.parsers.util.toRelativeUrl
-import org.xtimms.shirizu.core.model.MangaSource
-import org.xtimms.shirizu.data.repository.MangaSourcesRepository
-import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
-import javax.inject.Inject
-
-@Reusable
-class MangaLinkResolver @Inject constructor(
- private val repositoryFactory: MangaRepository.Factory,
- private val sourcesRepository: MangaSourcesRepository,
- private val dataRepository: MangaDataRepository,
-) {
-
- suspend fun resolve(uri: Uri): Manga {
- return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
- resolveAppLink(uri)
- } else {
- resolveExternalLink(uri)
- } ?: throw NotFoundException("Cannot resolve link", uri.toString())
- }
-
- private suspend fun resolveAppLink(uri: Uri): Manga? {
- require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
- val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
- val source = MangaSource(sourceName)
- require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
- val repo = repositoryFactory.create(source)
- return repo.findExact(
- url = uri.getQueryParameter("url"),
- title = uri.getQueryParameter("name"),
- )
- }
-
- private suspend fun resolveExternalLink(uri: Uri): Manga? {
- dataRepository.findMangaByPublicUrl(uri.toString())?.let {
- return it
- }
- val host = uri.host ?: return null
- val repo = sourcesRepository.allMangaSources.asSequence()
- .map { source ->
- repositoryFactory.create(source) as RemoteMangaRepository
- }.find { repo ->
- host in repo.domains
- } ?: return null
- return repo.findExact(uri.toString().toRelativeUrl(host), null)
- }
-
- private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
- if (!title.isNullOrEmpty()) {
- val list = getList(0, MangaListFilter.Search(title))
- if (url != null) {
- list.find { it.url == url }?.let {
- return it
- }
- }
- list.minByOrNull { it.title.levenshteinDistance(title) }
- ?.takeIf { it.title.almostEquals(title, 0.2f) }
- ?.let { return it }
- }
- val seed = getDetailsNoCache(
- getSeedManga(source, url ?: return null, title),
- )
- return runCatchingCancellable {
- val seedTitle = seed.title.ifEmpty {
- seed.altTitle
- }.ifNullOrEmpty {
- seed.author
- } ?: return@runCatchingCancellable null
- val seedList = getList(0, MangaListFilter.Search(seedTitle))
- seedList.first { x -> x.url == url }
- }.getOrThrow()
- }
-
- private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
- return if (this is RemoteMangaRepository) {
- getDetails(manga, CachePolicy.READ_ONLY)
- } else {
- getDetails(manga)
- }
- }
-
- private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
- id = run {
- var h = 1125899906842597L
- source.name.forEach { c ->
- h = 31 * h + c.code
- }
- url.forEach { c ->
- h = 31 * h + c.code
- }
- h
- },
- title = title.orEmpty(),
- altTitle = null,
- url = url,
- publicUrl = "",
- rating = 0.0f,
- isNsfw = source.contentType == ContentType.HENTAI,
- coverUrl = "",
- tags = emptySet(),
- state = null,
- author = null,
- largeCoverUrl = null,
- description = null,
- chapters = null,
- source = source,
- )
-}
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLoaderContextImpl.kt
index 301f583..21022ec 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLoaderContextImpl.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaLoaderContextImpl.kt
@@ -4,22 +4,29 @@ import android.annotation.SuppressLint
import android.content.Context
import android.util.Base64
import android.webkit.WebView
+import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.network.UserAgents
+import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.xtimms.shirizu.core.network.MangaHttpClient
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.prefs.SourceSettings
+import org.xtimms.shirizu.utils.system.configureForParser
+import org.xtimms.shirizu.utils.system.sanitizeHeaderValue
import org.xtimms.shirizu.utils.system.toList
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -32,8 +39,10 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference? = null
+ private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
+
@SuppressLint("SetJavaScriptEnabled")
- override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
+ override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
@@ -49,6 +58,8 @@ class MangaLoaderContextImpl @Inject constructor(
return SourceSettings(androidContext, source)
}
+ override fun getDefaultUserAgent(): String = webViewUserAgent
+
override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_WRAP)
}
@@ -60,4 +71,30 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List {
return LocaleListCompat.getAdjustedDefault().toList()
}
+
+ @MainThread
+ private fun obtainWebView(): WebView {
+ return webViewCached?.get() ?: WebView(androidContext).also {
+ it.configureForParser(null)
+ webViewCached = WeakReference(it)
+ }
+ }
+
+ private fun obtainWebViewUserAgent(): String {
+ val mainDispatcher = Dispatchers.Main.immediate
+ return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
+ obtainWebViewUserAgentImpl()
+ } else {
+ runBlocking(mainDispatcher) {
+ obtainWebViewUserAgentImpl()
+ }
+ }
+ }
+
+ @MainThread
+ private fun obtainWebViewUserAgentImpl() = runCatching {
+ obtainWebView().settings.userAgentString.sanitizeHeaderValue()
+ }.onFailure { e ->
+ e.printStackTrace()
+ }.getOrDefault(UserAgents.FIREFOX_MOBILE)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaRepository.kt
index 42ac783..cfa92d4 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/parser/MangaRepository.kt
@@ -1,6 +1,7 @@
package org.xtimms.shirizu.core.parser
import androidx.annotation.AnyThread
+import androidx.paging.PagingSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/favicon/FaviconFetcher.kt
index fbef918..e9d8193 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/favicon/FaviconFetcher.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/parser/favicon/FaviconFetcher.kt
@@ -34,7 +34,7 @@ import org.xtimms.shirizu.utils.withExtraCloseable
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
-private const val FALLBACK_SIZE = 9999 // largest icon
+private const val FALLBACK_SIZE = 99999 // largest icon
@OptIn(ExperimentalCoilApi::class)
class FaviconFetcher(
diff --git a/app/src/main/java/org/xtimms/shirizu/core/parser/local/LocalMangaRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/parser/local/LocalMangaRepository.kt
index 78a298e..25feaa4 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/parser/local/LocalMangaRepository.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/parser/local/LocalMangaRepository.kt
@@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.parser.local
import android.net.Uri
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -27,9 +28,10 @@ import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
import org.xtimms.shirizu.core.parser.local.output.LocalMangaUtil
+import org.xtimms.shirizu.data.LocalMangaMappingCache
import org.xtimms.shirizu.data.LocalStorageManager
import org.xtimms.shirizu.utils.AlphanumComparator
-import org.xtimms.shirizu.utils.CompositeMutex2
+import org.xtimms.shirizu.utils.MultiMutex
import org.xtimms.shirizu.utils.system.children
import org.xtimms.shirizu.utils.system.deleteAwait
import org.xtimms.shirizu.utils.system.filterWith
@@ -48,7 +50,8 @@ class LocalMangaRepository @Inject constructor(
) : MangaRepository {
override val source = MangaSource.LOCAL
- private val locks = CompositeMutex2()
+ private val locks = MultiMutex()
+ private val localMappingCache = LocalMangaMappingCache()
override val isMultipleTagsSupported: Boolean = true
override val isTagsExclusionSupported: Boolean = true
@@ -133,6 +136,10 @@ class LocalMangaRepository @Inject constructor(
}
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
+ // very fast path
+ localMappingCache.get(remoteManga.id)?.let {
+ return@runCatchingCancellable it
+ }
// fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga()
@@ -154,6 +161,8 @@ class LocalMangaRepository @Inject constructor(
}
}
}.firstOrNull()?.getManga()
+ }.onSuccess { x: LocalManga? ->
+ localMappingCache[remoteManga.id] = x
}.onFailure {
it.printStackTrace()
}.getOrNull()
@@ -200,6 +209,7 @@ class LocalMangaRepository @Inject constructor(
locks.unlock(id)
}
+ @OptIn(ExperimentalCoroutinesApi::class)
private suspend fun getRawList(): ArrayList {
val files = getAllFiles().toList() // TODO remove toList()
return coroutineScope {
diff --git a/app/src/main/java/org/xtimms/shirizu/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/shirizu/core/prefs/AppSettings.kt
index a571d5b..27905b8 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/prefs/AppSettings.kt
@@ -67,6 +67,13 @@ const val PROXY_PORT = "proxy_port"
const val PROXY_USER = "proxy_user"
const val PROXY_PASSWORD = "proxy_password"
+const val MANGA = "manga_sources"
+const val HENTAI = "hentai_sources"
+const val COMICS = "comics_sources"
+const val OTHER = "other_sources"
+
+const val NSFW_HISTORY = "nsfw_history"
+
const val NOT_SPECIFIED = 0
val paletteStyles = listOf(
@@ -173,6 +180,13 @@ object AppSettings {
fun isModernViewEnabled() = MODERN_VIEW.getBoolean(true)
+ fun isMangaContentTypeEnabled() = MANGA.getBoolean(true)
+ fun isHentaiContentTypeEnabled() = HENTAI.getBoolean(true)
+ fun isComicsContentTypeEnabled() = COMICS.getBoolean(true)
+ fun isOtherContentTypeEnabled() = OTHER.getBoolean(true)
+
+ fun showNsfwInHistory() = NSFW_HISTORY.getBoolean(true)
+
fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float {
return when (columns) {
1 -> 1f
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/BaseOAuthLoginActivity.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/BaseOAuthLoginActivity.kt
new file mode 100644
index 0000000..72c4d9a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/BaseOAuthLoginActivity.kt
@@ -0,0 +1,38 @@
+package org.xtimms.shirizu.core.scrobbling
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import org.xtimms.shirizu.MainActivity
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import javax.inject.Inject
+
+abstract class BaseOAuthLoginActivity : ComponentActivity() {
+
+ @Inject
+ internal lateinit var shikimoriRepository: ShikimoriRepository
+
+ abstract fun handleResult(data: Uri?)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ LoadingScreen()
+ }
+
+ handleResult(intent.data)
+ }
+
+ internal fun returnToSettings() {
+ finish()
+
+ val intent = Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ startActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/ScrobblingLoginActivity.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/ScrobblingLoginActivity.kt
new file mode 100644
index 0000000..8fa5cb7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/ScrobblingLoginActivity.kt
@@ -0,0 +1,28 @@
+package org.xtimms.shirizu.core.scrobbling
+
+import android.net.Uri
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ScrobblingLoginActivity : BaseOAuthLoginActivity() {
+
+ override fun handleResult(data: Uri?) {
+ when (data?.host) {
+ "shikimori-auth" -> handleShikimori(data)
+ }
+ }
+
+ private fun handleShikimori(data: Uri) {
+ val code = data.getQueryParameter("code")
+ if (code != null) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ shikimoriRepository.authorize(code)
+ returnToSettings()
+ }
+ } else {
+ shikimoriRepository.logout()
+ returnToSettings()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerRepository.kt
new file mode 100644
index 0000000..1a03447
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerRepository.kt
@@ -0,0 +1,32 @@
+package org.xtimms.shirizu.core.scrobbling.data
+
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
+
+interface ScrobblerRepository {
+
+ val oauthUrl: String
+
+ val isAuthorized: Boolean
+
+ val cachedUser: ScrobblerUser?
+
+ suspend fun authorize(code: String?)
+
+ suspend fun loadUser(): ScrobblerUser
+
+ fun logout()
+
+ suspend fun unregister(mangaId: Long)
+
+ suspend fun findManga(query: String, offset: Int): List
+
+ suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
+
+ suspend fun createRate(mangaId: Long, scrobblerMangaId: Long)
+
+ suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int)
+
+ suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerStorage.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerStorage.kt
new file mode 100644
index 0000000..de9b7a4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblerStorage.kt
@@ -0,0 +1,59 @@
+package org.xtimms.shirizu.core.scrobbling.data
+
+import android.content.Context
+import androidx.core.content.edit
+import org.jsoup.internal.StringUtil
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
+
+private const val KEY_ACCESS_TOKEN = "access_token"
+private const val KEY_REFRESH_TOKEN = "refresh_token"
+private const val KEY_USER = "user"
+
+class ScrobblerStorage(context: Context, service: ScrobblerService) {
+
+ private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE)
+
+ var accessToken: String?
+ get() = prefs.getString(KEY_ACCESS_TOKEN, null)
+ set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
+
+ var refreshToken: String?
+ get() = prefs.getString(KEY_REFRESH_TOKEN, null)
+ set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
+
+ var user: ScrobblerUser?
+ get() = prefs.getString(KEY_USER, null)?.let {
+ val lines = it.lines()
+ if (lines.size != 4) {
+ return@let null
+ }
+ ScrobblerUser(
+ id = lines[0].toLong(),
+ nickname = lines[1],
+ avatar = lines[2],
+ service = ScrobblerService.valueOf(lines[3]),
+ )
+ }
+ set(value) = prefs.edit {
+ if (value == null) {
+ remove(KEY_USER)
+ return@edit
+ }
+ val str = StringUtil.StringJoiner("\n")
+ .add(value.id)
+ .add(value.nickname)
+ .add(value.avatar)
+ .add(value.service.name)
+ .complete()
+ putString(KEY_USER, str)
+ }
+
+ operator fun get(key: String): String? = prefs.getString(key, null)
+
+ operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) }
+
+ fun clear() = prefs.edit {
+ clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingDao.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingDao.kt
new file mode 100644
index 0000000..8a6932f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingDao.kt
@@ -0,0 +1,25 @@
+package org.xtimms.shirizu.core.scrobbling.data
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+abstract class ScrobblingDao {
+
+ @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
+
+ @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract fun observe(scrobbler: Int, mangaId: Long): Flow
+
+ @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler")
+ abstract fun observe(scrobbler: Int): Flow>
+
+ @Upsert
+ abstract suspend fun upsert(entity: ScrobblingEntity)
+
+ @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
+ abstract suspend fun delete(scrobbler: Int, mangaId: Long)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingEntity.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingEntity.kt
new file mode 100644
index 0000000..84c76b5
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/data/ScrobblingEntity.kt
@@ -0,0 +1,35 @@
+package org.xtimms.shirizu.core.scrobbling.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+
+@Entity(
+ tableName = "scrobblings",
+ primaryKeys = ["scrobbler", "id", "manga_id"],
+)
+class ScrobblingEntity(
+ @ColumnInfo(name = "scrobbler") val scrobbler: Int,
+ @ColumnInfo(name = "id") val id: Int,
+ @ColumnInfo(name = "manga_id") val mangaId: Long,
+ @ColumnInfo(name = "target_id") val targetId: Long,
+ @ColumnInfo(name = "status") val status: String?,
+ @ColumnInfo(name = "chapter") val chapter: Int,
+ @ColumnInfo(name = "comment") val comment: String?,
+ @ColumnInfo(name = "rating") val rating: Float,
+) {
+
+ fun copy(
+ status: String?,
+ comment: String?,
+ rating: Float,
+ ) = ScrobblingEntity(
+ scrobbler = scrobbler,
+ id = id,
+ mangaId = mangaId,
+ targetId = targetId,
+ status = status,
+ chapter = chapter,
+ comment = comment,
+ rating = rating,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/Scrobbler.kt
new file mode 100644
index 0000000..8e1f89e
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/Scrobbler.kt
@@ -0,0 +1,162 @@
+package org.xtimms.shirizu.core.scrobbling.domain
+
+import androidx.annotation.FloatRange
+import androidx.collection.LongSparseArray
+import androidx.collection.getOrElse
+import androidx.core.text.parseAsHtml
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.model.findById
+import org.xtimms.shirizu.core.parser.MangaRepository
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingInfo
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
+import org.xtimms.shirizu.utils.lang.findKeyByValue
+import org.xtimms.shirizu.utils.lang.sanitize
+import java.util.EnumMap
+
+abstract class Scrobbler(
+ protected val db: ShirizuDatabase,
+ val scrobblerService: ScrobblerService,
+ private val repository: ScrobblerRepository,
+ private val mangaRepositoryFactory: MangaRepository.Factory,
+) {
+
+ private val infoCache = LongSparseArray()
+ protected val statuses = EnumMap(ScrobblingStatus::class.java)
+
+ val user: Flow = flow {
+ repository.cachedUser?.let {
+ emit(it)
+ }
+ runCatchingCancellable {
+ repository.loadUser()
+ }.onSuccess {
+ emit(it)
+ }.onFailure {
+ it.printStackTrace()
+ }
+ }
+
+ val isAvailable: Boolean
+ get() = repository.isAuthorized
+
+ suspend fun authorize(authCode: String): ScrobblerUser {
+ repository.authorize(authCode)
+ return repository.loadUser()
+ }
+
+ fun logout() {
+ repository.logout()
+ }
+
+ suspend fun findManga(query: String, offset: Int): List {
+ return repository.findManga(query, offset)
+ }
+
+ suspend fun linkManga(mangaId: Long, targetId: Long) {
+ repository.createRate(mangaId, targetId)
+ }
+
+ suspend fun scrobble(manga: Manga, chapterId: Long) {
+ var chapters = manga.chapters
+ if (chapters.isNullOrEmpty()) {
+ chapters = mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters
+ }
+ requireNotNull(chapters)
+ val chapter = checkNotNull(chapters.findById(chapterId)) {
+ "Chapter $chapterId not found in this manga"
+ }
+ val number = if (chapter.number > 0f) {
+ chapter.number.toInt()
+ } else {
+ chapters = chapters.filter { x -> x.branch == chapter.branch }
+ chapters.indexOf(chapter) + 1
+ }
+ val entity = db.getScrobblingDao().find(scrobblerService.id, manga.id) ?: return
+ repository.updateRate(entity.id, entity.mangaId, number)
+ }
+
+ suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
+ val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return null
+ return entity.toScrobblingInfo()
+ }
+
+ abstract suspend fun updateScrobblingInfo(
+ mangaId: Long,
+ @FloatRange(from = 0.0, to = 1.0) rating: Float,
+ status: ScrobblingStatus?,
+ comment: String?,
+ )
+
+ fun observeScrobblingInfo(mangaId: Long): Flow {
+ return db.getScrobblingDao().observe(scrobblerService.id, mangaId)
+ .map { it?.toScrobblingInfo() }
+ }
+
+ fun observeAllScrobblingInfo(): Flow> {
+ return db.getScrobblingDao().observe(scrobblerService.id)
+ .map { entities ->
+ coroutineScope {
+ entities.map {
+ async {
+ it.toScrobblingInfo()
+ }
+ }.awaitAll()
+ }.filterNotNull()
+ }
+ }
+
+ suspend fun unregisterScrobbling(mangaId: Long) {
+ repository.unregister(mangaId)
+ }
+
+ protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
+ return repository.getMangaInfo(id)
+ }
+
+ private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? {
+ val mangaInfo = infoCache.getOrElse(targetId) {
+ runCatchingCancellable {
+ getMangaInfo(targetId)
+ }.onFailure {
+ it.printStackTrace()
+ }.onSuccess {
+ infoCache.put(targetId, it)
+ }.getOrNull() ?: return null
+ }
+ return ScrobblingInfo(
+ scrobbler = scrobblerService,
+ mangaId = mangaId,
+ targetId = targetId,
+ status = statuses.findKeyByValue(status),
+ chapter = chapter,
+ comment = comment,
+ rating = rating,
+ title = mangaInfo.name,
+ coverUrl = mangaInfo.cover,
+ description = mangaInfo.descriptionHtml.parseAsHtml().sanitize(),
+ externalUrl = mangaInfo.url,
+ )
+ }
+}
+
+suspend fun Scrobbler.tryScrobble(manga: Manga, chapterId: Long): Boolean {
+ return runCatchingCancellable {
+ scrobble(manga, chapterId)
+ }.onFailure {
+ it.printStackTrace()
+ }.isSuccess
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerManga.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerManga.kt
new file mode 100644
index 0000000..83867c0
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerManga.kt
@@ -0,0 +1,19 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+import org.xtimms.shirizu.core.model.ListModel
+
+data class ScrobblerManga(
+ val id: Long,
+ val name: String,
+ val altName: String?,
+ val cover: String,
+ val url: String,
+) : ListModel {
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is ScrobblerManga && other.id == id
+ }
+
+ override fun toString(): String {
+ return "ScrobblerManga #$id \"$name\" $url"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerMangaInfo.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerMangaInfo.kt
new file mode 100644
index 0000000..dc6a6ed
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerMangaInfo.kt
@@ -0,0 +1,9 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+class ScrobblerMangaInfo(
+ val id: Long,
+ val name: String,
+ val cover: String,
+ val url: String,
+ val descriptionHtml: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerService.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerService.kt
new file mode 100644
index 0000000..28ae98c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerService.kt
@@ -0,0 +1,14 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import org.xtimms.shirizu.R
+
+enum class ScrobblerService(
+ val id: Int,
+ @StringRes val titleResId: Int,
+ @DrawableRes val iconResId: Int,
+) {
+ SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori),
+ KITSU(2, R.string.kitsu, R.drawable.ic_kitsu)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerType.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerType.kt
new file mode 100644
index 0000000..fddd083
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerType.kt
@@ -0,0 +1,8 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+import javax.inject.Qualifier
+
+@Qualifier
+annotation class ScrobblerType(
+ val service: ScrobblerService
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerUser.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerUser.kt
new file mode 100644
index 0000000..a16430f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblerUser.kt
@@ -0,0 +1,8 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+data class ScrobblerUser(
+ val id: Long,
+ val nickname: String,
+ val avatar: String?,
+ val service: ScrobblerService,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingInfo.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingInfo.kt
new file mode 100644
index 0000000..566d8a3
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingInfo.kt
@@ -0,0 +1,22 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+import org.xtimms.shirizu.core.model.ListModel
+
+data class ScrobblingInfo(
+ val scrobbler: ScrobblerService,
+ val mangaId: Long,
+ val targetId: Long,
+ val status: ScrobblingStatus?,
+ val chapter: Int,
+ val comment: String?,
+ val rating: Float,
+ val title: String,
+ val coverUrl: String,
+ val description: CharSequence?,
+ val externalUrl: String,
+) : ListModel {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is ScrobblingInfo && other.scrobbler == scrobbler
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingStatus.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingStatus.kt
new file mode 100644
index 0000000..7aad761
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/domain/model/ScrobblingStatus.kt
@@ -0,0 +1,12 @@
+package org.xtimms.shirizu.core.scrobbling.domain.model
+
+import org.xtimms.shirizu.core.model.ListModel
+
+enum class ScrobblingStatus : ListModel {
+
+ PLANNED, READING, RE_READING, COMPLETED, ON_HOLD, DROPPED;
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is ScrobblingStatus && other.ordinal == ordinal
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuAuthenticator.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuAuthenticator.kt
new file mode 100644
index 0000000..645250d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuAuthenticator.kt
@@ -0,0 +1,54 @@
+package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import org.xtimms.shirizu.core.network.CommonHeaders
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
+import javax.inject.Inject
+import javax.inject.Provider
+
+class KitsuAuthenticator @Inject constructor(
+ @ScrobblerType(ScrobblerService.KITSU) private val storage: ScrobblerStorage,
+ private val repositoryProvider: Provider,
+) : Authenticator {
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ val accessToken = storage.accessToken ?: return null
+ if (!isRequestWithAccessToken(response)) {
+ return null
+ }
+ synchronized(this) {
+ val newAccessToken = storage.accessToken ?: return null
+ if (accessToken != newAccessToken) {
+ return newRequestWithAccessToken(response.request, newAccessToken)
+ }
+ val updatedAccessToken = refreshAccessToken() ?: return null
+ return newRequestWithAccessToken(response.request, updatedAccessToken)
+ }
+ }
+
+ private fun isRequestWithAccessToken(response: Response): Boolean {
+ val header = response.request.header(CommonHeaders.AUTHORIZATION)
+ return header?.startsWith("Bearer") == true
+ }
+
+ private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
+ return request.newBuilder()
+ .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
+ .build()
+ }
+
+ private fun refreshAccessToken(): String? = runCatching {
+ val repository = repositoryProvider.get()
+ runBlocking { repository.authorize(null) }
+ return storage.accessToken
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuInterceptor.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuInterceptor.kt
new file mode 100644
index 0000000..7e6f457
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuInterceptor.kt
@@ -0,0 +1,27 @@
+package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import org.xtimms.shirizu.core.network.CommonHeaders
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+
+class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val sourceRequest = chain.request()
+ val request = sourceRequest.newBuilder()
+ request.header(CommonHeaders.CONTENT_TYPE, VND_JSON)
+ request.header(CommonHeaders.ACCEPT, VND_JSON)
+ if (!sourceRequest.url.pathSegments.contains("oauth")) {
+ storage.accessToken?.let {
+ request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
+ }
+ }
+ return chain.proceed(request.build())
+ }
+
+ companion object {
+
+ const val VND_JSON = "application/vnd.api+json"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuRepository.kt
new file mode 100644
index 0000000..4a86e58
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/data/KitsuRepository.kt
@@ -0,0 +1,244 @@
+package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import okhttp3.FormBody
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okio.IOException
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.util.await
+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.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.parseJson
+import org.koitharu.kotatsu.parsers.util.urlEncoded
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuInterceptor.Companion.VND_JSON
+import org.xtimms.shirizu.utils.system.parseJsonOrNull
+
+private const val BASE_WEB_URL = "https://kitsu.io"
+
+class KitsuRepository(
+ @ApplicationContext context: Context,
+ private val okHttp: OkHttpClient,
+ private val storage: ScrobblerStorage,
+ private val db: ShirizuDatabase,
+) : ScrobblerRepository {
+
+ override val oauthUrl: String = "kotatsu+kitsu://auth"
+
+ override val isAuthorized: Boolean
+ get() = storage.accessToken != null
+
+ override val cachedUser: ScrobblerUser?
+ get() {
+ return storage.user
+ }
+
+ override suspend fun authorize(code: String?) {
+ val body = FormBody.Builder()
+ if (code != null) {
+ body.add("grant_type", "password")
+ body.add("username", code.substringBefore(';'))
+ body.add("password", code.substringAfter(';'))
+ } else {
+ body.add("grant_type", "refresh_token")
+ body.add("refresh_token", checkNotNull(storage.refreshToken))
+ }
+ val request = Request.Builder()
+ .post(body.build())
+ .url("$BASE_WEB_URL/api/oauth/token")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ storage.accessToken = response.getString("access_token")
+ storage.refreshToken = response.getString("refresh_token")
+ }
+
+ override suspend fun loadUser(): ScrobblerUser {
+ val request = Request.Builder()
+ .get()
+ .url("$BASE_WEB_URL/api/edge/users?filter[self]=true")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ .getJSONArray("data")
+ .getJSONObject(0)
+ return ScrobblerUser(
+ id = response.getAsLong("id"),
+ nickname = response.getJSONObject("attributes").getString("name"),
+ avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"),
+ service = ScrobblerService.KITSU,
+ ).also { storage.user = it }
+ }
+
+ override fun logout() {
+ storage.clear()
+ }
+
+ override suspend fun unregister(mangaId: Long) {
+ return db.getScrobblingDao().delete(ScrobblerService.KITSU.id, mangaId)
+ }
+
+ override suspend fun findManga(query: String, offset: Int): List {
+ val request = Request.Builder()
+ .get()
+ .url("$BASE_WEB_URL/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}")
+ val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess()
+ return response.getJSONArray("data").mapJSON { jo ->
+ val attrs = jo.getJSONObject("attributes")
+ val titles = attrs.getJSONObject("titles").valuesToStringList()
+ ScrobblerManga(
+ id = jo.getAsLong("id"),
+ name = titles.first(),
+ altName = titles.drop(1).joinToString(),
+ cover = attrs.getJSONObject("posterImage").getStringOrNull("small").orEmpty(),
+ url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
+ )
+ }
+ }
+
+ override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
+ val request = Request.Builder()
+ .get()
+ .url("$BASE_WEB_URL/api/edge/manga/$id")
+ val data = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
+ val attrs = data.getJSONObject("attributes")
+ return ScrobblerMangaInfo(
+ id = data.getAsLong("id"),
+ name = attrs.getString("canonicalTitle"),
+ cover = attrs.getJSONObject("posterImage").getString("medium"),
+ url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
+ descriptionHtml = attrs.getString("description").replace("\\n", "
"),
+ )
+ }
+
+ override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
+ findExistingRate(scrobblerMangaId)?.let {
+ saveRate(it, mangaId)
+ return
+ }
+ val user = cachedUser ?: loadUser()
+ val payload = JSONObject()
+ payload.putJO("data") {
+ put("type", "libraryEntries")
+ putJO("attributes") {
+ put("status", "planned") // will be updated by next call
+ put("progress", 0)
+ }
+ putJO("relationships") {
+ putJO("manga") {
+ putJO("data") {
+ put("type", "manga")
+ put("id", scrobblerMangaId)
+ }
+ }
+ putJO("user") {
+ putJO("data") {
+ put("type", "users")
+ put("id", user.id)
+ }
+ }
+ }
+ }
+ val request = Request.Builder()
+ .url("$BASE_WEB_URL/api/edge/library-entries?include=manga")
+ .post(payload.toKitsuRequestBody())
+ val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
+ saveRate(response, mangaId)
+ }
+
+ override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) {
+ val payload = JSONObject()
+ payload.putJO("data") {
+ put("type", "libraryEntries")
+ put("id", rateId)
+ putJO("attributes") {
+ put("progress", chapter)
+ }
+ }
+ val request = Request.Builder()
+ .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
+ .patch(payload.toKitsuRequestBody())
+ val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
+ saveRate(response, mangaId)
+ }
+
+ override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
+ val payload = JSONObject()
+ payload.putJO("data") {
+ put("type", "libraryEntries")
+ put("id", rateId)
+ putJO("attributes") {
+ put("status", status)
+ put("ratingTwenty", (rating * 20).toInt().coerceIn(2, 20))
+ put("notes", comment)
+ }
+ }
+ val request = Request.Builder()
+ .url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
+ .patch(payload.toKitsuRequestBody())
+ val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
+ saveRate(response, mangaId)
+ }
+
+ private fun JSONObject.valuesToStringList(): List {
+ val result = ArrayList(length())
+ for (key in keys()) {
+ result.add(getStringOrNull(key) ?: continue)
+ }
+ return result
+ }
+
+ private inline fun JSONObject.putJO(name: String, init: JSONObject.() -> Unit) {
+ put(name, JSONObject().apply(init))
+ }
+
+ private fun JSONObject.toKitsuRequestBody() = toString().toRequestBody(VND_JSON.toMediaType())
+
+ private suspend fun findExistingRate(scrobblerMangaId: Long): JSONObject? {
+ val userId = (cachedUser ?: loadUser()).id
+ val request = Request.Builder()
+ .get()
+ .url("$BASE_WEB_URL/api/edge/library-entries?filter[manga_id]=$scrobblerMangaId&filter[userId]=$userId&include=manga")
+ val data = okHttp.newCall(request.build()).await().parseJsonOrNull()?.optJSONArray("data") ?: return null
+ return data.optJSONObject(0)
+ }
+
+ private suspend fun saveRate(json: JSONObject, mangaId: Long) {
+ val attrs = json.getJSONObject("attributes")
+ val manga = json.getJSONObject("relationships").getJSONObject("manga").getJSONObject("data")
+ val entity = ScrobblingEntity(
+ scrobbler = ScrobblerService.KITSU.id,
+ id = json.getInt("id"),
+ mangaId = mangaId,
+ targetId = manga.getAsLong("id"),
+ status = attrs.getString("status"),
+ chapter = attrs.getIntOrDefault("progress", 0),
+ comment = attrs.getStringOrNull("notes"),
+ rating = (attrs.getFloatOrDefault("ratingTwenty", 0f) / 20f).coerceIn(0f, 1f),
+ )
+ db.getScrobblingDao().upsert(entity)
+ }
+
+ private fun JSONObject.ensureSuccess(): JSONObject {
+ val error = optJSONArray("errors")?.optJSONObject(0) ?: return this
+ val title = error.getString("title")
+ val detail = error.getStringOrNull("detail")
+ throw IOException("$title: $detail")
+ }
+
+ private fun JSONObject.getAsLong(name: String): Long = when (val rawValue = opt(name)) {
+ is Long -> rawValue
+ is Number -> rawValue.toLong()
+ is String -> rawValue.toLong()
+ else -> throw IllegalArgumentException("Value $rawValue at \"$name\" is not of type long")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/domain/KitsuScrobbler.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/domain/KitsuScrobbler.kt
new file mode 100644
index 0000000..a561ad5
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/kitsu/domain/KitsuScrobbler.kt
@@ -0,0 +1,42 @@
+package org.xtimms.shirizu.core.scrobbling.services.kitsu.domain
+
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.parser.MangaRepository
+import org.xtimms.shirizu.core.scrobbling.domain.Scrobbler
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
+import javax.inject.Inject
+
+class KitsuScrobbler @Inject constructor(
+ private val repository: KitsuRepository,
+ db: ShirizuDatabase,
+ mangaRepositoryFactory: MangaRepository.Factory,
+) : Scrobbler(db, ScrobblerService.KITSU, repository, mangaRepositoryFactory) {
+
+ init {
+ statuses[ScrobblingStatus.PLANNED] = "planned"
+ statuses[ScrobblingStatus.READING] = "current"
+ statuses[ScrobblingStatus.COMPLETED] = "completed"
+ statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
+ statuses[ScrobblingStatus.DROPPED] = "dropped"
+ }
+
+ override suspend fun updateScrobblingInfo(
+ mangaId: Long,
+ rating: Float,
+ status: ScrobblingStatus?,
+ comment: String?
+ ) {
+ val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
+ requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
+ repository.updateRate(
+ rateId = entity.id,
+ mangaId = entity.mangaId,
+ rating = rating,
+ status = statuses[status],
+ comment = comment,
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriAuthenticator.kt
new file mode 100644
index 0000000..6b8733f
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriAuthenticator.kt
@@ -0,0 +1,53 @@
+package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import org.xtimms.shirizu.core.network.CommonHeaders
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
+import javax.inject.Inject
+import javax.inject.Provider
+
+class ShikimoriAuthenticator @Inject constructor(
+ @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
+ private val repositoryProvider: Provider,
+) : Authenticator {
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ val accessToken = storage.accessToken ?: return null
+ if (!isRequestWithAccessToken(response)) {
+ return null
+ }
+ synchronized(this) {
+ val newAccessToken = storage.accessToken ?: return null
+ if (accessToken != newAccessToken) {
+ return newRequestWithAccessToken(response.request, newAccessToken)
+ }
+ val updatedAccessToken = refreshAccessToken() ?: return null
+ return newRequestWithAccessToken(response.request, updatedAccessToken)
+ }
+ }
+
+ private fun isRequestWithAccessToken(response: Response): Boolean {
+ val header = response.request.header(CommonHeaders.AUTHORIZATION)
+ return header?.startsWith("Bearer") == true
+ }
+
+ private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
+ return request.newBuilder()
+ .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
+ .build()
+ }
+
+ private fun refreshAccessToken(): String? = runCatching {
+ val repository = repositoryProvider.get()
+ runBlocking { repository.authorize(null) }
+ return storage.accessToken
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriInterceptor.kt
new file mode 100644
index 0000000..f3aefc8
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriInterceptor.kt
@@ -0,0 +1,28 @@
+package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import okio.IOException
+import org.xtimms.shirizu.core.network.CommonHeaders
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+
+private const val USER_AGENT_SHIKIMORI = "Kotatsu"
+
+class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val sourceRequest = chain.request()
+ val request = sourceRequest.newBuilder()
+ request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
+ if (!sourceRequest.url.pathSegments.contains("oauth")) {
+ storage.accessToken?.let {
+ request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
+ }
+ }
+ val response = chain.proceed(request.build())
+ if (!response.isSuccessful && !response.isRedirect) {
+ throw IOException("${response.code} ${response.message}")
+ }
+ return response
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriRepository.kt
new file mode 100644
index 0000000..e626550
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/data/ShikimoriRepository.kt
@@ -0,0 +1,218 @@
+package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
+
+import okhttp3.FormBody
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.util.await
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+import org.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.parseJson
+import org.koitharu.kotatsu.parsers.util.parseJsonArray
+import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
+import org.xtimms.shirizu.BuildConfig
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
+import org.xtimms.shirizu.utils.system.toRequestBody
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val DOMAIN = "shikimori.one"
+private const val REDIRECT_URI = "shrz://shikimori-auth"
+private const val BASE_URL = "https://$DOMAIN/"
+private const val MANGA_PAGE_SIZE = 10
+
+@Singleton
+class ShikimoriRepository @Inject constructor(
+ @ScrobblerType(ScrobblerService.SHIKIMORI) private val okHttp: OkHttpClient,
+ @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
+ private val db: ShirizuDatabase,
+) : ScrobblerRepository {
+
+ private val clientId = BuildConfig.SHIKIMORI_CLIENT_ID
+ private val clientSecret = BuildConfig.SHIKIMORI_CLIENT_SECRET
+
+ override val oauthUrl: String
+ get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" +
+ "redirect_uri=$REDIRECT_URI&response_type=code&scope="
+
+ override val isAuthorized: Boolean
+ get() = storage.accessToken != null
+
+ override suspend fun authorize(code: String?) {
+ val body = FormBody.Builder()
+ body.add("client_id", clientId)
+ body.add("client_secret", clientSecret)
+ if (code != null) {
+ body.add("grant_type", "authorization_code")
+ body.add("redirect_uri", REDIRECT_URI)
+ body.add("code", code)
+ } else {
+ body.add("grant_type", "refresh_token")
+ body.add("refresh_token", checkNotNull(storage.refreshToken))
+ }
+ val request = Request.Builder()
+ .post(body.build())
+ .url("${BASE_URL}oauth/token")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ storage.accessToken = response.getString("access_token")
+ storage.refreshToken = response.getString("refresh_token")
+ }
+
+ override suspend fun loadUser(): ScrobblerUser {
+ val request = Request.Builder()
+ .get()
+ .url("${BASE_URL}api/users/whoami")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ return ShikimoriUser(response).also { storage.user = it }
+ }
+
+ override val cachedUser: ScrobblerUser?
+ get() {
+ return storage.user
+ }
+
+ override suspend fun unregister(mangaId: Long) {
+ return db.getScrobblingDao().delete(ScrobblerService.SHIKIMORI.id, mangaId)
+ }
+
+ override fun logout() {
+ storage.clear()
+ }
+
+ override suspend fun findManga(query: String, offset: Int): List {
+ val page = offset / MANGA_PAGE_SIZE
+ val pageOffset = offset % MANGA_PAGE_SIZE
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("mangas")
+ .addEncodedQueryParameter("page", (page + 1).toString())
+ .addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
+ .addEncodedQueryParameter("censored", false.toString())
+ .addQueryParameter("search", query)
+ .build()
+ val request = Request.Builder().url(url).get().build()
+ val response = okHttp.newCall(request).await().parseJsonArray()
+ val list = response.mapJSON { ScrobblerManga(it) }
+ return if (pageOffset != 0) list.drop(pageOffset) else list
+ }
+
+ override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
+ val user = cachedUser ?: loadUser()
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("target_id", scrobblerMangaId)
+ put("target_type", "Manga")
+ put("user_id", user.id)
+ },
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .build()
+ val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) {
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("chapters", chapter)
+ },
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .addPathSegment(rateId.toString())
+ .build()
+ val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
+ val payload = JSONObject()
+ payload.put(
+ "user_rate",
+ JSONObject().apply {
+ put("score", rating.toString())
+ if (comment != null) {
+ put("text", comment)
+ }
+ if (status != null) {
+ put("status", status)
+ }
+ },
+ )
+ val url = BASE_URL.toHttpUrl().newBuilder()
+ .addPathSegment("api")
+ .addPathSegment("v2")
+ .addPathSegment("user_rates")
+ .addPathSegment(rateId.toString())
+ .build()
+ val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
+ val response = okHttp.newCall(request).await().parseJson()
+ saveRate(response, mangaId)
+ }
+
+ override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
+ val request = Request.Builder()
+ .get()
+ .url("${BASE_URL}api/mangas/$id")
+ val response = okHttp.newCall(request.build()).await().parseJson()
+ return ScrobblerMangaInfo(response)
+ }
+
+ private suspend fun saveRate(json: JSONObject, mangaId: Long) {
+ val entity = ScrobblingEntity(
+ scrobbler = ScrobblerService.SHIKIMORI.id,
+ id = json.getInt("id"),
+ mangaId = mangaId,
+ targetId = json.getLong("target_id"),
+ status = json.getString("status"),
+ chapter = json.getInt("chapters"),
+ comment = json.getString("text"),
+ rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
+ )
+ db.getScrobblingDao().upsert(entity)
+ }
+
+ private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
+ id = json.getLong("id"),
+ name = json.getString("name"),
+ altName = json.getStringOrNull("russian"),
+ cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
+ url = json.getString("url").toAbsoluteUrl(DOMAIN),
+ )
+
+ private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
+ id = json.getLong("id"),
+ name = json.getString("name"),
+ cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
+ url = json.getString("url").toAbsoluteUrl(DOMAIN),
+ descriptionHtml = json.getString("description_html"),
+ )
+
+ @Suppress("FunctionName")
+ private fun ShikimoriUser(json: JSONObject) = ScrobblerUser(
+ id = json.getLong("id"),
+ nickname = json.getString("nickname"),
+ avatar = json.getStringOrNull("avatar"),
+ service = ScrobblerService.SHIKIMORI,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/domain/ShikimoriScrobbler.kt
new file mode 100644
index 0000000..e9f3e8a
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/scrobbling/services/shikimori/domain/ShikimoriScrobbler.kt
@@ -0,0 +1,46 @@
+package org.xtimms.shirizu.core.scrobbling.services.shikimori.domain
+
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.parser.MangaRepository
+import org.xtimms.shirizu.core.scrobbling.domain.Scrobbler
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val RATING_MAX = 10f
+
+@Singleton
+class ShikimoriScrobbler @Inject constructor(
+ private val repository: ShikimoriRepository,
+ db: ShirizuDatabase,
+ mangaRepositoryFactory: MangaRepository.Factory,
+) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository, mangaRepositoryFactory) {
+
+ init {
+ statuses[ScrobblingStatus.PLANNED] = "planned"
+ statuses[ScrobblingStatus.READING] = "watching"
+ statuses[ScrobblingStatus.RE_READING] = "rewatching"
+ statuses[ScrobblingStatus.COMPLETED] = "completed"
+ statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
+ statuses[ScrobblingStatus.DROPPED] = "dropped"
+ }
+
+ override suspend fun updateScrobblingInfo(
+ mangaId: Long,
+ rating: Float,
+ status: ScrobblingStatus?,
+ comment: String?,
+ ) {
+ val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
+ requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
+ repository.updateRate(
+ rateId = entity.id,
+ mangaId = entity.mangaId,
+ rating = rating * RATING_MAX,
+ status = statuses[status],
+ comment = comment,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/core/tracker/Tracker.kt b/app/src/main/java/org/xtimms/shirizu/core/tracker/Tracker.kt
index e21a4b7..a9cb571 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/tracker/Tracker.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/tracker/Tracker.kt
@@ -1,7 +1,6 @@
package org.xtimms.shirizu.core.tracker
import androidx.annotation.VisibleForTesting
-import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -12,7 +11,7 @@ import org.xtimms.shirizu.core.tracker.model.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.data.repository.TrackingRepository
-import org.xtimms.shirizu.utils.CompositeMutex2
+import org.xtimms.shirizu.utils.MultiMutex
import org.xtimms.shirizu.work.tracker.TrackerNotificationChannels
import org.xtimms.shirizu.work.tracker.TrackingItem
import javax.inject.Inject
@@ -119,7 +118,7 @@ class Tracker @Inject constructor(
private companion object {
- private val mangaMutex = CompositeMutex2()
+ private val mangaMutex = MultiMutex()
@OptIn(ExperimentalContracts::class)
suspend inline fun withMangaLock(id: Long, action: () -> T): T {
diff --git a/app/src/main/java/org/xtimms/shirizu/core/ui/screens/InfoScreen.kt b/app/src/main/java/org/xtimms/shirizu/core/ui/screens/InfoScreen.kt
index 61d6997..8857393 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/ui/screens/InfoScreen.kt
+++ b/app/src/main/java/org/xtimms/shirizu/core/ui/screens/InfoScreen.kt
@@ -57,7 +57,7 @@ import org.xtimms.shirizu.utils.material.combineColors
fun InfoScreen(
icon: ImageVector,
headingText1: String,
- headingText2: String,
+ headingText2: String? = null,
subtitleText: String,
acceptText: String,
onAcceptClick: () -> Unit,
@@ -182,10 +182,12 @@ fun InfoScreen(
else -> headingText2
}
AnimatedContent(targetState = heading, label = "heading animation") {
- Text(
- text = it,
- style = MaterialTheme.typography.headlineLarge,
- )
+ if (it != null) {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.headlineLarge,
+ )
+ }
}
Text(
text = subtitleText,
diff --git a/app/src/main/java/org/xtimms/shirizu/core/ui/screens/TabbedScreen.kt b/app/src/main/java/org/xtimms/shirizu/core/ui/screens/TabbedScreen.kt
new file mode 100644
index 0000000..71d3433
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/core/ui/screens/TabbedScreen.kt
@@ -0,0 +1,99 @@
+package org.xtimms.shirizu.core.ui.screens
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Tab
+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.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.zIndex
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.TabText
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TabbedScreen(
+ @StringRes titleRes: Int,
+ tabs: ImmutableList,
+ startIndex: Int? = null,
+ searchQuery: String? = null,
+ onChangeSearchQuery: (String?) -> Unit = {},
+) {
+ val scope = rememberCoroutineScope()
+ val state = rememberPagerState { tabs.size }
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scroll = rememberLazyListState()
+ val navigator = LocalNavigator.currentOrThrow
+
+ LaunchedEffect(startIndex) {
+ if (startIndex != null) {
+ state.scrollToPage(startIndex)
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ ) { contentPadding ->
+ Column(
+ modifier = Modifier.padding(
+ top = contentPadding.calculateTopPadding(),
+ start = contentPadding.calculateStartPadding(LocalLayoutDirection.current),
+ end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
+ ),
+ ) {
+ PrimaryTabRow(
+ selectedTabIndex = state.currentPage,
+ modifier = Modifier.zIndex(1f),
+ ) {
+ tabs.forEachIndexed { index, tab ->
+ Tab(
+ selected = state.currentPage == index,
+ onClick = { scope.launch { state.animateScrollToPage(index) } },
+ text = { TabText(text = stringResource(tab.titleRes), badgeCount = tab.badgeNumber) },
+ unselectedContentColor = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ HorizontalPager(
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ verticalAlignment = Alignment.Top,
+ ) { page ->
+ tabs[page].content(
+ PaddingValues(bottom = contentPadding.calculateBottomPadding()),
+ snackbarHostState,
+ )
+ }
+ }
+ }
+}
+
+data class TabContent(
+ @StringRes val titleRes: Int,
+ val badgeNumber: Int? = null,
+ val searchEnabled: Boolean = false,
+ val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/crash/CrashActivity.kt b/app/src/main/java/org/xtimms/shirizu/crash/CrashActivity.kt
index df951d9..7d15c82 100644
--- a/app/src/main/java/org/xtimms/shirizu/crash/CrashActivity.kt
+++ b/app/src/main/java/org/xtimms/shirizu/crash/CrashActivity.kt
@@ -22,7 +22,7 @@ class CrashActivity : ComponentActivity() {
val exception = GlobalExceptionHandler.getThrowableFromIntent(intent)
setContent {
- SettingsProvider(LocalWindowWidthState.current) {
+ SettingsProvider {
ShirizuTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
diff --git a/app/src/main/java/org/xtimms/shirizu/data/LocalMangaMappingCache.kt b/app/src/main/java/org/xtimms/shirizu/data/LocalMangaMappingCache.kt
new file mode 100644
index 0000000..25d540d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/data/LocalMangaMappingCache.kt
@@ -0,0 +1,31 @@
+package org.xtimms.shirizu.data
+
+import androidx.collection.MutableLongObjectMap
+import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
+import org.xtimms.shirizu.core.model.LocalManga
+import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
+import java.io.File
+
+class LocalMangaMappingCache {
+
+ private val map = MutableLongObjectMap()
+
+ suspend fun get(mangaId: Long): LocalManga? {
+ val file = synchronized(this) {
+ map[mangaId]
+ } ?: return null
+ return runCatchingCancellable {
+ LocalMangaInput.of(file).getManga()
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrNull()
+ }
+
+ operator fun set(mangaId: Long, localManga: LocalManga?) = synchronized(this) {
+ if (localManga == null) {
+ map.remove(mangaId)
+ } else {
+ map[mangaId] = localManga.file
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/data/repository/ExploreRepository.kt b/app/src/main/java/org/xtimms/shirizu/data/repository/ExploreRepository.kt
deleted file mode 100644
index 0753559..0000000
--- a/app/src/main/java/org/xtimms/shirizu/data/repository/ExploreRepository.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package org.xtimms.shirizu.data.repository
-
-import org.koitharu.kotatsu.parsers.model.ContentType
-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.shirizu.core.model.TagsBlacklist
-import org.xtimms.shirizu.core.parser.MangaRepository
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.utils.lang.asArrayList
-import javax.inject.Inject
-
-class ExploreRepository @Inject constructor(
- private val sourcesRepository: MangaSourcesRepository,
- private val historyRepository: HistoryRepository,
- private val mangaRepositoryFactory: MangaRepository.Factory,
-) {
-
- suspend fun findRandomManga(tagsLimit: Int): Manga {
- val tagsBlacklist = TagsBlacklist(setOf(), 0.4f)
- val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
- if (it in tagsBlacklist) null else it.title
- }
- val sources = sourcesRepository.getEnabledSources()
- check(sources.isNotEmpty()) { "No sources available" }
- for (i in 0..4) {
- val list = getList(sources.random(), tags, tagsBlacklist)
- val manga = list.randomOrNull() ?: continue
- val details = runCatchingCancellable {
- mangaRepositoryFactory.create(manga.source).getDetails(manga)
- }.getOrNull() ?: continue
- if ((AppSettings.isSuggestionsExcludeNsfw() && details.isNsfw) || details in tagsBlacklist) {
- continue
- }
- return details
- }
- throw NoSuchElementException()
- }
-
- suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
- val tagsBlacklist = TagsBlacklist(setOf(), 0.4f)
- val skipNsfw = AppSettings.isSuggestionsExcludeNsfw() && source.contentType != ContentType.HENTAI
- val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
- if (it in tagsBlacklist) null else it.title
- }
- for (i in 0..4) {
- val list = getList(source, tags, tagsBlacklist)
- val manga = list.randomOrNull() ?: continue
- val details = runCatchingCancellable {
- mangaRepositoryFactory.create(manga.source).getDetails(manga)
- }.getOrNull() ?: continue
- if ((skipNsfw && details.isNsfw) || details in tagsBlacklist) {
- continue
- }
- return details
- }
- throw NoSuchElementException()
- }
-
- private suspend fun getList(
- source: MangaSource,
- tags: List,
- blacklist: TagsBlacklist,
- ): 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()
- if (AppSettings.isSuggestionsExcludeNsfw()) {
- list.removeAll { it.isNsfw }
- }
- if (blacklist.isNotEmpty()) {
- list.removeAll { manga -> manga in blacklist }
- }
- list.shuffle()
- list
- }.onFailure {
- it.printStackTrace()
- }.getOrDefault(emptyList())
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/data/repository/FavouritesRepository.kt b/app/src/main/java/org/xtimms/shirizu/data/repository/FavouritesRepository.kt
index 1d5ea04..e90f190 100644
--- a/app/src/main/java/org/xtimms/shirizu/data/repository/FavouritesRepository.kt
+++ b/app/src/main/java/org/xtimms/shirizu/data/repository/FavouritesRepository.kt
@@ -17,15 +17,22 @@ import org.xtimms.shirizu.core.database.entity.toEntity
import org.xtimms.shirizu.core.database.entity.toFavouriteCategory
import org.xtimms.shirizu.core.database.entity.toManga
import org.xtimms.shirizu.core.database.entity.toMangaList
+import org.xtimms.shirizu.core.database.entity.toShelfCategory
+import org.xtimms.shirizu.core.database.entity.toShelfManga
+import org.xtimms.shirizu.core.database.entity.toShelfMangaList
import org.xtimms.shirizu.core.model.FavouriteCategory
import org.xtimms.shirizu.core.model.ListSortOrder
+import org.xtimms.shirizu.sections.shelf.ShelfCategory
+import org.xtimms.shirizu.sections.shelf.ShelfManga
import org.xtimms.shirizu.utils.ReversibleHandle
import org.xtimms.shirizu.utils.lang.mapItems
+import org.xtimms.shirizu.work.tracker.TrackerNotificationChannels
import javax.inject.Inject
@Reusable
class FavouritesRepository @Inject constructor(
private val db: ShirizuDatabase,
+ private val channels: TrackerNotificationChannels,
) {
suspend fun getAllManga(): List {
@@ -33,13 +40,18 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList()
}
+ fun observeAllShelfManga(order: ListSortOrder): Flow> {
+ return db.getFavouritesDao().observeAll(order)
+ .mapItems { it.toShelfManga() }
+ }
+
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)
+ fun observeAll(order: ListSortOrder): Flow> {
+ return db.getFavouritesDao().observeAll(order)
.mapItems { it.toManga() }
}
@@ -48,7 +60,11 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList()
}
- @OptIn(ExperimentalCoroutinesApi::class)
+ fun observeAll(categoryId: Long, order: ListSortOrder): Flow> {
+ return db.getFavouritesDao().observeAll(categoryId, order)
+ .mapItems { it.toManga() }
+ }
+
fun observeAll(categoryId: Long): Flow> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
@@ -59,11 +75,6 @@ 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()
@@ -76,10 +87,21 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged()
}
+ fun observeCategory(id: Long): Flow {
+ return db.getFavouriteCategoriesDao().observe(id)
+ .map { it?.toFavouriteCategory() }
+ }
+
fun observeCategoriesIds(mangaId: Long): Flow> {
return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() }
}
+ fun observeCategories(mangaId: Long): Flow> {
+ return db.getFavouritesDao().observeCategories(mangaId).map {
+ it.mapTo(LinkedHashSet(it.size)) { x -> x.toFavouriteCategory() }
+ }
+ }
+
suspend fun getCategory(id: Long): FavouriteCategory {
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
}
@@ -105,7 +127,9 @@ class FavouritesRepository @Inject constructor(
isVisibleInLibrary = isVisibleOnShelf,
)
val id = db.getFavouriteCategoriesDao().insert(entity)
- return entity.toFavouriteCategory(id)
+ val category = entity.toFavouriteCategory(id)
+ channels.createChannel(category)
+ return category
}
suspend fun updateCategory(
@@ -133,6 +157,10 @@ class FavouritesRepository @Inject constructor(
db.getFavouriteCategoriesDao().delete(id)
}
}
+ // run after transaction success
+ for (id in ids) {
+ channels.deleteChannel(id)
+ }
}
suspend fun setCategoryOrder(id: Long, order: ListSortOrder) {
diff --git a/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSearchRepository.kt b/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSearchRepository.kt
new file mode 100644
index 0000000..a3be5f7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSearchRepository.kt
@@ -0,0 +1,167 @@
+package org.xtimms.shirizu.data.repository
+
+import android.app.SearchManager
+import android.content.Context
+import android.provider.SearchRecentSuggestions
+import dagger.Reusable
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.util.levenshteinDistance
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.database.entity.MangaWithTags
+import org.xtimms.shirizu.core.database.entity.toEntity
+import org.xtimms.shirizu.core.database.entity.toManga
+import org.xtimms.shirizu.core.database.entity.toMangaTag
+import org.xtimms.shirizu.core.database.entity.toMangaTagsList
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.sections.search.global.MangaSuggestionsProvider
+import javax.inject.Inject
+
+@Reusable
+class MangaSearchRepository @Inject constructor(
+ private val db: ShirizuDatabase,
+ private val sourcesRepository: MangaSourcesRepository,
+ @ApplicationContext private val context: Context,
+ private val recentSuggestions: SearchRecentSuggestions,
+) {
+
+ suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List {
+ return when {
+ query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) }
+ source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
+ else -> db.getMangaDao().searchByTitle("%$query%", limit)
+ }.let {
+ if (!AppSettings.isNSFWEnabled()) it.filterNot { x -> x.manga.isNsfw } else it
+ }.map {
+ it.toManga()
+ }.sortedBy { x ->
+ x.title.levenshteinDistance(query)
+ }
+ }
+
+ suspend fun getQuerySuggestion(
+ query: String,
+ limit: Int,
+ ): List = withContext(Dispatchers.IO) {
+ context.contentResolver.query(
+ MangaSuggestionsProvider.QUERY_URI,
+ arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
+ "${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
+ arrayOf("%$query%"),
+ "date DESC",
+ )?.use { cursor ->
+ val count = minOf(cursor.count, limit)
+ if (count == 0) {
+ return@withContext emptyList()
+ }
+ val result = ArrayList(count)
+ if (cursor.moveToFirst()) {
+ val index = cursor.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_QUERY)
+ do {
+ result += cursor.getString(index)
+ } while (currentCoroutineContext().isActive && cursor.moveToNext())
+ }
+ result
+ }.orEmpty()
+ }
+
+ suspend fun getQueryHintSuggestion(
+ query: String,
+ limit: Int,
+ ): List {
+ if (query.isEmpty()) {
+ return emptyList()
+ }
+ val titles = db.getSuggestionDao().getTitles("$query%")
+ if (titles.isEmpty()) {
+ return emptyList()
+ }
+ return titles.shuffled().take(limit)
+ }
+
+ suspend fun getAuthorsSuggestion(
+ query: String,
+ limit: Int,
+ ): List {
+ if (query.isEmpty()) {
+ return emptyList()
+ }
+ return db.getMangaDao().findAuthors("$query%", limit)
+ }
+
+ suspend fun getTagsSuggestion(query: String, limit: Int, source: MangaSource?): List {
+ return when {
+ query.isNotEmpty() && source != null -> db.getTagsDao()
+ .findTags(source.name, "%$query%", limit)
+
+ query.isNotEmpty() -> db.getTagsDao().findTags("%$query%", limit)
+ source != null -> db.getTagsDao().findPopularTags(source.name, limit)
+ else -> db.getTagsDao().findPopularTags(limit)
+ }.toMangaTagsList()
+ }
+
+ suspend fun getTagsSuggestion(tags: Set): List {
+ val ids = tags.mapToSet { it.toEntity().id }
+ return if (ids.size == 1) {
+ db.getTagsDao().findRelatedTags(ids.first())
+ } else {
+ db.getTagsDao().findRelatedTags(ids)
+ }.mapNotNull { x ->
+ if (x.id in ids) null else x.toMangaTag()
+ }
+ }
+
+ suspend fun getRareTags(source: MangaSource, limit: Int): List {
+ return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList()
+ }
+
+ fun getSourcesSuggestion(query: String, limit: Int): List {
+ if (query.length < 3) {
+ return emptyList()
+ }
+ val skipNsfw = !AppSettings.isNSFWEnabled()
+ val sources = sourcesRepository.allMangaSources
+ .filter { x ->
+ (x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
+ }
+ return if (limit == 0) {
+ sources
+ } else {
+ sources.take(limit)
+ }
+ }
+
+ fun saveSearchQuery(query: String) {
+ recentSuggestions.saveRecentQuery(query, null)
+ }
+
+ suspend fun clearSearchHistory(): Unit = withContext(Dispatchers.IO) {
+ recentSuggestions.clearHistory()
+ }
+
+ suspend fun deleteSearchQuery(query: String) = withContext(Dispatchers.IO) {
+ context.contentResolver.delete(
+ MangaSuggestionsProvider.URI,
+ "display1 = ?",
+ arrayOf(query),
+ )
+ }
+
+ suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
+ context.contentResolver.query(
+ MangaSuggestionsProvider.QUERY_URI,
+ arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
+ null,
+ arrayOfNulls(1),
+ null,
+ )?.use { cursor -> cursor.count } ?: 0
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSourcesRepository.kt b/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSourcesRepository.kt
index fdd6e47..3054fd7 100644
--- a/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSourcesRepository.kt
+++ b/app/src/main/java/org/xtimms/shirizu/data/repository/MangaSourcesRepository.kt
@@ -1,6 +1,5 @@
package org.xtimms.shirizu.data.repository
-import androidx.compose.runtime.Composable
import dagger.Reusable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -14,7 +13,6 @@ 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.shirizu.BuildConfig
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.database.dao.MangaSourcesDao
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
@@ -41,9 +39,7 @@ class MangaSourcesRepository @Inject constructor(
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
- if (!BuildConfig.DEBUG) {
- remove(MangaSource.DUMMY)
- }
+ remove(MangaSource.DUMMY)
}
val allMangaSources: Set
@@ -58,6 +54,15 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
}
+ fun observeDisabledSources(): Flow> = combine(
+ observeIsNsfwDisabled(),
+ observeSortOrder(),
+ ) { skipNsfw, _ ->
+ dao.observeDisabled().map {
+ it.toSources(skipNsfw)
+ }
+ }.flatMapLatest { it }
+
fun observeEnabledSourcesCount(): Flow {
return combine(
observeIsNsfwDisabled(),
@@ -115,6 +120,39 @@ class MangaSourcesRepository @Inject constructor(
}
}
+ suspend fun assimilateNewSources(): Set {
+ val new = getNewSources()
+ if (new.isEmpty()) {
+ return emptySet()
+ }
+ var maxSortKey = dao.getMaxSortKey()
+ val entities = new.map { x ->
+ MangaSourceEntity(
+ source = x.name,
+ isEnabled = false,
+ sortKey = ++maxSortKey,
+ )
+ }
+ dao.insertIfAbsent(entities)
+ if (settings.isNsfwContentDisabled) {
+ new.removeAll { x -> x.isNsfw() }
+ }
+ return new
+ }
+
+ suspend fun isSetupRequired(): Boolean {
+ return dao.findAll().isEmpty()
+ }
+
+ private suspend fun getNewSources(): MutableSet {
+ val entities = dao.findAll()
+ val result = EnumSet.copyOf(remoteSources)
+ for (e in entities) {
+ result.remove(MangaSource(e.source))
+ }
+ return result
+ }
+
private fun List.toSources(
skipNsfwSources: Boolean,
): List {
diff --git a/app/src/main/java/org/xtimms/shirizu/core/network/NetworkModule.kt b/app/src/main/java/org/xtimms/shirizu/di/NetworkModule.kt
similarity index 92%
rename from app/src/main/java/org/xtimms/shirizu/core/network/NetworkModule.kt
rename to app/src/main/java/org/xtimms/shirizu/di/NetworkModule.kt
index 0a49308..1269b47 100644
--- a/app/src/main/java/org/xtimms/shirizu/core/network/NetworkModule.kt
+++ b/app/src/main/java/org/xtimms/shirizu/di/NetworkModule.kt
@@ -1,4 +1,4 @@
-package org.xtimms.shirizu.core.network
+package org.xtimms.shirizu.di
import android.content.Context
import android.util.AndroidRuntimeException
@@ -11,6 +11,9 @@ import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.CookieJar
import okhttp3.OkHttpClient
+import org.xtimms.shirizu.core.network.BaseHttpClient
+import org.xtimms.shirizu.core.network.MangaHttpClient
+import org.xtimms.shirizu.core.network.bypassSSLErrors
import org.xtimms.shirizu.core.network.cookies.AndroidCookieJar
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.network.cookies.PreferencesCookieJar
@@ -66,9 +69,9 @@ interface NetworkModule {
proxySelector(AppProxySelector())
proxyAuthenticator(ProxyAuthenticator())
dns(DoHManager(cache))
- if (AppSettings.isSSLBypassEnabled()) {
+ /*if (AppSettings.isSSLBypassEnabled()) {
bypassSSLErrors()
- }
+ }*/
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(CloudflareInterceptor())
diff --git a/app/src/main/java/org/xtimms/shirizu/di/ScrobblingModule.kt b/app/src/main/java/org/xtimms/shirizu/di/ScrobblingModule.kt
new file mode 100644
index 0000000..c062967
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/di/ScrobblingModule.kt
@@ -0,0 +1,77 @@
+package org.xtimms.shirizu.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.ElementsIntoSet
+import okhttp3.OkHttpClient
+import org.xtimms.shirizu.core.database.ShirizuDatabase
+import org.xtimms.shirizu.core.network.BaseHttpClient
+import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
+import org.xtimms.shirizu.core.scrobbling.domain.Scrobbler
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
+import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuAuthenticator
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuInterceptor
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
+import org.xtimms.shirizu.core.scrobbling.services.kitsu.domain.KitsuScrobbler
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriAuthenticator
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriInterceptor
+import org.xtimms.shirizu.core.scrobbling.services.shikimori.domain.ShikimoriScrobbler
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ScrobblingModule {
+
+ @Provides
+ @Singleton
+ @ScrobblerType(ScrobblerService.SHIKIMORI)
+ fun provideShikimoriHttpClient(
+ @BaseHttpClient baseHttpClient: OkHttpClient,
+ authenticator: ShikimoriAuthenticator,
+ @ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage,
+ ): OkHttpClient = baseHttpClient.newBuilder().apply {
+ authenticator(authenticator)
+ addInterceptor(ShikimoriInterceptor(storage))
+ }.build()
+
+ @Provides
+ @Singleton
+ fun provideKitsuRepository(
+ @ApplicationContext context: Context,
+ @ScrobblerType(ScrobblerService.KITSU) storage: ScrobblerStorage,
+ database: ShirizuDatabase,
+ authenticator: KitsuAuthenticator,
+ ): KitsuRepository {
+ val okHttp = OkHttpClient.Builder().apply {
+ authenticator(authenticator)
+ addInterceptor(KitsuInterceptor(storage))
+ }.build()
+ return KitsuRepository(context, okHttp, storage, database)
+ }
+
+ @Provides
+ @Singleton
+ @ScrobblerType(ScrobblerService.SHIKIMORI)
+ fun provideShikimoriStorage(
+ @ApplicationContext context: Context,
+ ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI)
+
+ @Provides
+ @Singleton
+ @ScrobblerType(ScrobblerService.KITSU)
+ fun provideKitsuStorage(
+ @ApplicationContext context: Context,
+ ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.KITSU)
+
+ @Provides
+ @ElementsIntoSet
+ fun provideScrobblers(
+ shikimoriScrobbler: ShikimoriScrobbler,
+ kitsuScrobbler: KitsuScrobbler
+ ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, kitsuScrobbler)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/ShirizuModule.kt b/app/src/main/java/org/xtimms/shirizu/di/ShirizuModule.kt
similarity index 97%
rename from app/src/main/java/org/xtimms/shirizu/ShirizuModule.kt
rename to app/src/main/java/org/xtimms/shirizu/di/ShirizuModule.kt
index ae7d76b..60f8e4e 100644
--- a/app/src/main/java/org/xtimms/shirizu/ShirizuModule.kt
+++ b/app/src/main/java/org/xtimms/shirizu/di/ShirizuModule.kt
@@ -1,4 +1,4 @@
-package org.xtimms.shirizu
+package org.xtimms.shirizu.di
import android.app.Application
import android.content.Context
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.cache.CacheDir
import org.xtimms.shirizu.core.cache.ContentCache
import org.xtimms.shirizu.core.cache.MemoryContentCache
@@ -114,7 +115,8 @@ interface ShirizuModule {
@Provides
@Singleton
@LocalStorageChanges
- fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow()
+ fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow =
+ MutableSharedFlow()
@Provides
@LocalStorageChanges
diff --git a/app/src/main/java/org/xtimms/shirizu/di/VoyagerModule.kt b/app/src/main/java/org/xtimms/shirizu/di/VoyagerModule.kt
new file mode 100644
index 0000000..8c10c3b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/di/VoyagerModule.kt
@@ -0,0 +1,101 @@
+package org.xtimms.shirizu.di
+
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.hilt.ScreenModelFactory
+import cafe.adriel.voyager.hilt.ScreenModelFactoryKey
+import cafe.adriel.voyager.hilt.ScreenModelKey
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoMap
+import org.xtimms.shirizu.sections.details.DetailsScreenModel
+import org.xtimms.shirizu.sections.explore.catalog.CatalogScreenModel
+import org.xtimms.shirizu.sections.explore.sources.SourcesScreenModel
+import org.xtimms.shirizu.sections.feed.FeedScreenModel
+import org.xtimms.shirizu.sections.library.history.HistoryScreenModel
+import org.xtimms.shirizu.sections.library.shelves.ShelvesScreenModel
+import org.xtimms.shirizu.sections.list.MangaListScreenModel
+import org.xtimms.shirizu.sections.search.SearchScreenModel
+import org.xtimms.shirizu.sections.settings.backup.RestoreBackupScreenModel
+import org.xtimms.shirizu.sections.settings.shelf.categories.CategoriesScreenModel
+import org.xtimms.shirizu.sections.settings.storage.StorageScreenModel
+import org.xtimms.shirizu.sections.shelf.ShelfScreenModel
+import org.xtimms.shirizu.sections.suggestions.SuggestionsScreenModel
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface VoyagerModule {
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(ShelfScreenModel::class)
+ fun bindShelfScreenModel(shelfScreenModel: ShelfScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(HistoryScreenModel::class)
+ fun bindHistoryScreenModel(historyScreenModel: HistoryScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(SearchScreenModel::class)
+ fun bindSearchScreenModel(searchScreenModel: SearchScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(SuggestionsScreenModel::class)
+ fun bindSuggestionsScreenModel(suggestionsScreenModel: SuggestionsScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(SourcesScreenModel::class)
+ fun bindSourcesScreenModel(sourcesScreenModel: SourcesScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(CatalogScreenModel::class)
+ fun bindCatalogScreenModel(catalogScreenModel: CatalogScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(FeedScreenModel::class)
+ fun bindFeedScreenModel(feedScreenModel: FeedScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(StorageScreenModel::class)
+ fun bindStorageScreenModel(storageScreenModel: StorageScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(CategoriesScreenModel::class)
+ fun bindCategoriesScreenModel(categoriesScreenModel: CategoriesScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelKey(ShelvesScreenModel::class)
+ fun bindShelvesScreenModel(shelvesScreenModel: ShelvesScreenModel): ScreenModel
+
+ @Binds
+ @IntoMap
+ @ScreenModelFactoryKey(RestoreBackupScreenModel.Factory::class)
+ fun bindRestoreBackupScreenModel(
+ restoreBackupScreenModelFactory: RestoreBackupScreenModel.Factory
+ ): ScreenModelFactory
+
+ @Binds
+ @IntoMap
+ @ScreenModelFactoryKey(MangaListScreenModel.Factory::class)
+ fun bindMangaListScreenModel(
+ mangaListScreenModelFactory: MangaListScreenModel.Factory
+ ): ScreenModelFactory
+
+ @Binds
+ @IntoMap
+ @ScreenModelFactoryKey(DetailsScreenModel.Factory::class)
+ fun bindDetailsScreenModelFactory(
+ detailsScreenModelFactory: DetailsScreenModel.Factory
+ ): ScreenModelFactory
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/ClassicDetailsInfoBox.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/ClassicDetailsInfoBox.kt
index 03a2e6d..0fd8164 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/ClassicDetailsInfoBox.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/ClassicDetailsInfoBox.kt
@@ -30,17 +30,18 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.ImageLoader
+import coil.compose.AsyncImage
+import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.MangaCover
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
@Composable
fun ClassicDetailsInfoBox(
- coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
@@ -50,7 +51,7 @@ fun ClassicDetailsInfoBox(
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
- readingTime: ReadingTime?,
+ readingTime: ReadingTime,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
@@ -67,8 +68,7 @@ fun ClassicDetailsInfoBox(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
@@ -80,7 +80,7 @@ fun ClassicDetailsInfoBox(
brush = Brush.verticalGradient(colors = backdropGradientColors),
)
}
- .blur(3.dp)
+ .blur(5.dp)
.alpha(0.33f),
)
@@ -88,7 +88,6 @@ fun ClassicDetailsInfoBox(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaInfoSmall(
- coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
@@ -107,7 +106,6 @@ fun ClassicDetailsInfoBox(
)
} else {
MangaInfoLarge(
- coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
@@ -131,7 +129,6 @@ fun ClassicDetailsInfoBox(
@Composable
fun MangaInfoLarge(
- coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
@@ -140,7 +137,7 @@ fun MangaInfoLarge(
author: String,
source: MangaSource,
state: MangaState?,
- historyInfo: HistoryInfo,
+ historyInfo: HistoryInfo?,
readingTime: ReadingTime?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
@@ -155,7 +152,6 @@ fun MangaInfoLarge(
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
- coil = coil,
modifier = Modifier
.fillMaxWidth(0.65f)
.clickable(
@@ -167,7 +163,6 @@ fun MangaInfoLarge(
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
- coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
@@ -177,7 +172,7 @@ fun MangaInfoLarge(
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked,
- historyInfo = historyInfo,
+ historyInfo = historyInfo!!,
readingTime = readingTime,
onDownloadClick = onDownloadClick
)
@@ -186,7 +181,6 @@ fun MangaInfoLarge(
@Composable
fun MangaInfoSmall(
- coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
@@ -196,7 +190,7 @@ fun MangaInfoSmall(
state: MangaState?,
source: MangaSource,
historyInfo: HistoryInfo,
- readingTime: ReadingTime?,
+ readingTime: ReadingTime,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onCoverClick: () -> Unit,
@@ -210,8 +204,7 @@ fun MangaInfoSmall(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
modifier = Modifier
.padding(horizontal = 16.dp)
.sizeIn(maxWidth = 54.dp)
@@ -224,9 +217,9 @@ fun MangaInfoSmall(
),
model = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
+ contentScale = ContentScale.Crop
)
DetailsContentInfo(
- coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsEvent.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsEvent.kt
deleted file mode 100644
index feec8b8..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsEvent.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.xtimms.shirizu.sections.details
-
-import org.xtimms.shirizu.core.base.event.UiEvent
-
-interface DetailsEvent : UiEvent
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsInfoHeader.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsInfoHeader.kt
index dd3c3ba..e44c7bb 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsInfoHeader.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsInfoHeader.kt
@@ -81,11 +81,12 @@ 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.shirizu.R
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.AnimatedButton
import org.xtimms.shirizu.core.components.ButtonType
import org.xtimms.shirizu.core.components.HtmlTextField
@@ -101,7 +102,6 @@ private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTIL
@Composable
fun MangaAndSourceTitlesLarge(
- coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
@@ -124,14 +124,12 @@ fun MangaAndSourceTitlesLarge(
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
- coil = coil,
modifier = Modifier.fillMaxWidth(0.65f),
data = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
- coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
@@ -150,7 +148,6 @@ fun MangaAndSourceTitlesLarge(
@Composable
fun MangaAndSourceTitlesSmall(
- coil: ImageLoader,
favicon: Uri,
title: String,
altTitle: String,
@@ -174,7 +171,6 @@ fun MangaAndSourceTitlesSmall(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
DetailsContentInfo(
- coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
@@ -198,7 +194,6 @@ fun MangaAndSourceTitlesSmall(
)
@Composable
fun DetailsContentInfo(
- coil: ImageLoader,
favicon: Uri,
title: String,
altTitle: String,
@@ -344,8 +339,7 @@ fun DetailsContentInfo(
AssistChip(
onClick = { onSourceClicked() },
leadingIcon = {
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(100)),
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreen.kt
new file mode 100644
index 0000000..1b72882
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreen.kt
@@ -0,0 +1,91 @@
+package org.xtimms.shirizu.sections.details
+
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import coil.ImageLoader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.parsers.MangaParser
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.utils.lang.AssistContentScreen
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.isTabletUi
+import javax.inject.Inject
+import javax.inject.Singleton
+
+class DetailsScreen(
+ private val manga: Manga,
+ val fromSource: Boolean = false,
+) : Screen(), AssistContentScreen {
+
+ private var assistUrl: String? = null
+
+ override fun onProvideAssistUrl() = assistUrl
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val haptic = LocalHapticFeedback.current
+ val scope = rememberCoroutineScope()
+
+ val screenModel =
+ getScreenModel { factory ->
+ factory.create(context, manga, SnackbarHostState())
+ }
+
+ val state by screenModel.state.collectAsState()
+
+ if (state is DetailsScreenModel.State.Loading) {
+ LoadingScreen()
+ return
+ }
+
+ val successState = state as DetailsScreenModel.State.Success
+ val isOnlineSource = remember { successState.source != MangaSource.DUMMY && successState.source != MangaSource.LOCAL }
+
+ MangaScreen(
+ state = successState,
+ snackbarHostState = screenModel.snackbarHostState,
+ isTabletUi = isTabletUi(),
+ onBackClicked = navigator::pop,
+ onWebViewClicked = {
+
+ },
+ onWebViewLongClicked = {
+
+ },
+ onTrackingClicked = {
+
+ },
+ onTagSearch = { },
+ onFilterButtonClicked = { },
+ onRefresh = { },
+ onContinueReading = { },
+ onCoverClicked = { },
+ )
+ }
+
+ private suspend fun getMangaUrl(manga_: Manga?, parser_: MangaParser?): String? {
+ val manga = manga_ ?: return null
+ val source = parser_ ?: return null
+
+ return try {
+ source.getDetails(manga).publicUrl
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewConstants.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenConstants.kt
similarity index 80%
rename from app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewConstants.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenConstants.kt
index 45248ea..1bb8a86 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewConstants.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenConstants.kt
@@ -1,6 +1,6 @@
package org.xtimms.shirizu.sections.details
-enum class DetailsViewItem {
+enum class DetailsScreenItem {
INFO_BOX,
ACTION_ROW,
DESCRIPTION_WITH_TAG,
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenModel.kt
similarity index 57%
rename from app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewModel.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenModel.kt
index 32673f2..ffbe67b 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsViewModel.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsScreenModel.kt
@@ -1,8 +1,13 @@
package org.xtimms.shirizu.sections.details
-import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
+import android.content.Context
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.hilt.ScreenModelFactory
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -19,17 +24,18 @@ 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.shirizu.R
-import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.base.viewmodel.BaseStateScreenModel
import org.xtimms.shirizu.core.model.findById
import org.xtimms.shirizu.core.model.getPreferredBranch
-import org.xtimms.shirizu.core.parser.MangaIntent
import org.xtimms.shirizu.data.repository.BookmarksRepository
import org.xtimms.shirizu.data.repository.FavouritesRepository
import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.sections.details.data.MangaDetails
+import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.domain.BranchComparator
import org.xtimms.shirizu.sections.details.domain.DetailsInteractor
import org.xtimms.shirizu.sections.details.domain.DetailsLoadUseCase
@@ -38,14 +44,11 @@ import org.xtimms.shirizu.sections.details.domain.RelatedMangaUseCase
import org.xtimms.shirizu.sections.details.model.ChapterItem
import org.xtimms.shirizu.sections.details.model.HistoryInfo
import org.xtimms.shirizu.sections.details.model.MangaBranch
-import org.xtimms.shirizu.utils.ReversibleAction
import org.xtimms.shirizu.utils.lang.onEachWhile
-import org.xtimms.shirizu.utils.lang.removeFirstAndLast
-import javax.inject.Inject
-@HiltViewModel
-class DetailsViewModel @Inject constructor(
- savedStateHandle: SavedStateHandle,
+class DetailsScreenModel @AssistedInject constructor(
+ @Assisted val context: Context,
+ @Assisted val manga: Manga,
private val interactor: DetailsInteractor,
private val historyRepository: HistoryRepository,
private val bookmarksRepository: BookmarksRepository,
@@ -53,69 +56,80 @@ class DetailsViewModel @Inject constructor(
private val detailsLoadUseCase: DetailsLoadUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
private val relatedMangaUseCase: RelatedMangaUseCase,
-) : KotatsuBaseViewModel() {
+ @Assisted val snackbarHostState: SnackbarHostState = SnackbarHostState(),
+) : BaseStateScreenModel(State.Loading) {
+
+ private val successState: State.Success?
+ get() = state.value as? State.Success
private val _events: Channel = Channel(Channel.UNLIMITED)
val events: Flow = _events.receiveAsFlow()
+ private inline fun updateSuccessState(func: (State.Success) -> State.Success) {
+ mutableState.update {
+ when (it) {
+ State.Loading -> it
+ is State.Success -> func(it)
+ }
+ }
+ }
+
private var loadingJob: Job
- private val mangaId = savedStateHandle.get(MANGA_ID_ARGUMENT.removeFirstAndLast())!!
- private val intent = MangaIntent(savedStateHandle)
- var details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
+ var details = MutableStateFlow(MangaDetails(manga, null, null, false))
- val manga = details.map { x -> x?.toManga() }
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+ private val mangaImpl = details.map { x -> x.toManga() }
+ .stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- val history = historyRepository.observeOne(mangaId)
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+ val history = historyRepository.observeOne(manga.id)
+ .stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- val favouriteCategories = interactor.observeIsFavourite(mangaId)
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
+ val favouriteCategories = interactor.observeIsFavourite(manga.id)
+ .stateIn(screenModelScope + 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)
+ }.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
val selectedBranch = MutableStateFlow(null)
val historyInfo: StateFlow = combine(
- manga,
+ mangaImpl,
selectedBranch,
history,
) { m, b, h ->
HistoryInfo(m, b, h)
}.stateIn(
- scope = viewModelScope + Dispatchers.Default,
+ scope = screenModelScope + Dispatchers.Default,
started = SharingStarted.Eagerly,
initialValue = HistoryInfo(null, null, null),
)
@OptIn(ExperimentalCoroutinesApi::class)
- val bookmarks = manga.flatMapLatest {
+ val bookmarks = mangaImpl.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
- }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
+ }.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
- val relatedManga: StateFlow> = manga.mapLatest {
+ val relatedManga: StateFlow> = mangaImpl.mapLatest {
if (it != null) {
relatedMangaUseCase.invoke(it).orEmpty()
} else {
emptyList()
}
- }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+ }.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow> = combine(
details,
selectedBranch,
history,
) { m, b, h ->
- val c = m?.chapters
- if (c.isNullOrEmpty()) {
+ val c = m.chapters
+ if (c.isEmpty()) {
return@combine emptyList()
}
val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
@@ -127,11 +141,11 @@ class DetailsViewModel @Inject constructor(
isCurrent = h != null && x.key == currentBranch,
)
}.sortedWith(BranchComparator())
- }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+ }.stateIn(screenModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow = details.map {
- it != null && it.isLoaded && it.allChapters.isEmpty()
- }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
+ it.isLoaded && it.allChapters.isEmpty()
+ }.stateIn(screenModelScope, SharingStarted.WhileSubscribed(), false)
val chapters = combine(
combine(
@@ -141,17 +155,17 @@ class DetailsViewModel @Inject constructor(
newChaptersCount,
bookmarks,
) { manga, history, branch, news, bookmarks ->
- manga?.mapChapters(
+ manga.mapChapters(
history,
news,
branch,
bookmarks,
- ).orEmpty()
+ )
},
chaptersQuery,
) { list, query ->
list.filterSearch(query)
- }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+ }.stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
val readingTime = combine(
details,
@@ -159,16 +173,17 @@ class DetailsViewModel @Inject constructor(
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
- }.stateIn(viewModelScope, SharingStarted.Lazily, null)
+ }.stateIn(screenModelScope, SharingStarted.Lazily, null)
val selectedBranchValue: String?
get() = selectedBranch.value
init {
- loadingJob = doLoad(mangaId)
+ loadingJob = doLoad(manga.id)
+ updateSuccessState { it.copy(isRefreshingData = false) }
}
- fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) {
+ private fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(mangaId)
.onEachWhile {
if (it.allChapters.isEmpty()) {
@@ -181,8 +196,20 @@ class DetailsViewModel @Inject constructor(
true
}.catch { error ->
_events.send(Event.InternalError)
+ snackbarHostState.showSnackbar(error.message ?: error.stackTraceToString())
}.collect {
details.value = it
+ mutableState.update {
+ State.Success(
+ manga = details.value.toManga(),
+ source = details.value.toManga().source,
+ readingTime = checkNotNull(readingTime.value),
+ historyInfo = historyInfo.value,
+ availableScanlators = setOf(),
+ excludedScanlators = setOf(),
+ isRefreshingData = false
+ )
+ }
}
}
@@ -197,11 +224,36 @@ class DetailsViewModel @Inject constructor(
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
- historyRepository.delete(setOf(mangaId))
+ historyRepository.delete(setOf(manga.id))
}
}
sealed interface Event {
data object InternalError : Event
}
+
+ sealed interface State {
+ @Immutable
+ data object Loading : State
+
+ @Immutable
+ data class Success(
+ val manga: Manga,
+ val source: MangaSource,
+ val historyInfo: HistoryInfo,
+ val readingTime: ReadingTime,
+ val availableScanlators: Set,
+ val excludedScanlators: Set,
+ val isRefreshingData: Boolean = false,
+ ) : State
+ }
+
+ @AssistedFactory
+ interface Factory : ScreenModelFactory {
+ fun create(
+ context: Context,
+ manga: Manga,
+ snackbarHostState: SnackbarHostState
+ ): DetailsScreenModel
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsView.kt
deleted file mode 100644
index cd49765..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/DetailsView.kt
+++ /dev/null
@@ -1,439 +0,0 @@
-package org.xtimms.shirizu.sections.details
-
-import android.Manifest
-import android.net.Uri
-import android.os.Build
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-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.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.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.material3.rememberModalBottomSheetState
-import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
-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.rememberCoroutineScope
-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.platform.LocalView
-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 com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.PermissionStatus
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.model.MangaState
-import org.xtimms.shirizu.LocalWindowWidthState
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.HapticFeedback.slightHapticFeedback
-import org.xtimms.shirizu.core.components.ClassicDetailsToolbar
-import org.xtimms.shirizu.core.components.MangaHorizontalItem
-import org.xtimms.shirizu.core.components.ModernDetailsToolbar
-import org.xtimms.shirizu.core.parser.favicon.faviconUri
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.AppSettings.getBoolean
-import org.xtimms.shirizu.core.prefs.AppSettings.updateBoolean
-import org.xtimms.shirizu.core.prefs.CELLULAR_DOWNLOAD
-import org.xtimms.shirizu.core.prefs.CONFIGURE
-import org.xtimms.shirizu.core.prefs.NOTIFICATION
-import org.xtimms.shirizu.core.ui.dialogs.MeteredNetworkDialog
-import org.xtimms.shirizu.core.ui.dialogs.NotificationPermissionDialog
-import org.xtimms.shirizu.utils.lang.toNavArgument
-import org.xtimms.shirizu.utils.system.toast
-
-const val MANGA_ID_ARGUMENT = "{mangaId}"
-const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
-
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
-@Composable
-fun DetailsView(
- coil: ImageLoader,
- mangaId: Long,
- viewModel: DetailsViewModel = hiltViewModel(),
- navigateBack: () -> Unit,
- navigateToFullImage: (String) -> Unit,
- navigateToDetails: (Long) -> Unit,
- navigateToSource: (MangaSource) -> Unit,
- navigateToReader: () -> Unit
-) {
-
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- val view = LocalView.current
- val useDialog = LocalWindowWidthState.current != WindowWidthSizeClass.Compact
-
- val chapterListState = rememberLazyListState()
- val snackbarHostState = remember { SnackbarHostState() }
- var openCategoriesBottomSheet by rememberSaveable { mutableStateOf(false) }
- var showDownloadDialog by rememberSaveable { mutableStateOf(false) }
- var showMeteredNetworkDialog by remember { mutableStateOf(false) }
- var showNotificationDialog by remember { mutableStateOf(false) }
- val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) { isGranted: Boolean ->
- showNotificationDialog = false
- if (!isGranted) {
- context.toast(R.string.permission_denied)
- }
- }
- } else null
- val isModernView = AppSettings.isModernViewEnabled()
-
- val checkNetworkOrDownload = {
- if (!AppSettings.isNetworkAvailableForDownload()) {
- showMeteredNetworkDialog = true
- } else {
- scope.launch { snackbarHostState.showSnackbar("Downloading...") }
- }
- }
-
- val storagePermission = rememberPermissionState(
- permission = Manifest.permission.WRITE_EXTERNAL_STORAGE
- ) { b: Boolean ->
- if (b) {
- checkNetworkOrDownload()
- } else {
- context.toast(R.string.permission_denied)
- }
- }
-
- val checkPermissionOrDownload = {
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q || storagePermission.status == PermissionStatus.Granted) {
- checkNetworkOrDownload()
- } else {
- storagePermission.launchPermissionRequest()
- }
- }
-
- val uriHandler = LocalUriHandler.current
- fun openUrl(url: String) {
- uriHandler.openUri(url)
- }
-
- 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()
- val details by viewModel.details.collectAsStateWithLifecycle(null)
- val historyInfo by viewModel.historyInfo.collectAsStateWithLifecycle()
- val sheetState =
- rememberModalBottomSheetState(skipPartiallyExpanded = true)
-
- val downloadCallback: () -> Unit = {
- view.slightHapticFeedback()
- if (NOTIFICATION.getBoolean() && notificationPermission?.status?.isGranted == false) {
- showNotificationDialog = true
- }
- if (CONFIGURE.getBoolean()) {
- showDownloadDialog = true
- scope.launch {
- delay(50)
- sheetState.show()
- }
- } else {
- checkPermissionOrDownload()
- }
- }
-
- if (showNotificationDialog) {
- NotificationPermissionDialog(onDismissRequest = {
- showNotificationDialog = false
- NOTIFICATION.updateBoolean(false)
- }, onPermissionGranted = {
- notificationPermission?.launchPermissionRequest()
- })
- }
-
- if (showMeteredNetworkDialog) {
- MeteredNetworkDialog(
- onDismissRequest = { showMeteredNetworkDialog = false },
- onAllowOnceConfirm = {
- scope.launch { snackbarHostState.showSnackbar("Downloading...") }
- showMeteredNetworkDialog = false
- },
- onAllowAlwaysConfirm = {
- scope.launch { snackbarHostState.showSnackbar("Downloading...") }
- CELLULAR_DOWNLOAD.updateBoolean(true)
- showMeteredNetworkDialog = false
- })
- }
-
- LaunchedEffect(mangaId) {
- if (details == 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 = {
- val isFirstItemVisible by remember {
- derivedStateOf { chapterListState.firstVisibleItemIndex == 0 }
- }
- val isFirstItemScrolled by remember {
- derivedStateOf { chapterListState.firstVisibleItemScrollOffset > 0 }
- }
- val animatedTitleAlpha by animateFloatAsState(
- if (!isFirstItemVisible) 1f else 0f,
- label = "Top Bar Title",
- )
- val animatedBgAlpha by animateFloatAsState(
- if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
- label = "Top Bar Background",
- )
- if (isModernView) {
- ModernDetailsToolbar(
- title = viewModel.details.value?.toManga()?.title.orEmpty(),
- titleAlphaProvider = { animatedTitleAlpha },
- backgroundAlphaProvider = { animatedBgAlpha },
- navigateBack = { navigateBack() },
- navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
- )
- } else {
- ClassicDetailsToolbar(
- title = viewModel.details.value?.toManga()?.title.orEmpty(),
- titleAlphaProvider = { animatedTitleAlpha },
- backgroundAlphaProvider = { animatedBgAlpha },
- navigateBack = { navigateBack() },
- navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
- )
- }
- },
- 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 = if (isModernView) contentPadding.calculateTopPadding() - 60.dp else 0.dp,
- start = contentPadding.calculateStartPadding(layoutDirection),
- end = contentPadding.calculateEndPadding(layoutDirection),
- bottom = contentPadding.calculateBottomPadding(),
- ),
- ) {
- val manga = details?.toManga()
- item(
- key = DetailsViewItem.INFO_BOX,
- contentType = DetailsViewItem.INFO_BOX
- ) {
- if (isModernView) {
- ModernDetailsInfoBox(
- coil = coil,
- imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
- favicon = manga?.source?.faviconUri() ?: Uri.EMPTY,
- title = manga?.title.orEmpty(),
- altTitle = manga?.altTitle.orEmpty(),
- author = manga?.author.orEmpty(),
- isNsfw = manga?.isNsfw ?: true,
- state = manga?.state ?: MangaState.FINISHED,
- source = manga?.source ?: MangaSource.DUMMY,
- isTabletUi = false,
- appBarPadding = topPadding,
- onCoverClick = {
- navigateToFullImage(
- arrayOf(
- manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
- ).toNavArgument()
- )
- },
- historyInfo = historyInfo,
- readingTime = readingTime,
- isInShelf = favouriteCategories,
- onAddToShelfClicked = {
- openCategoriesBottomSheet = !openCategoriesBottomSheet
- },
- onSourceClicked = {
- navigateToSource(
- manga?.source ?: MangaSource.DUMMY
- )
- },
- onDownloadClick = downloadCallback
- )
- } else {
- ClassicDetailsInfoBox(
- coil = coil,
- imageUrl = manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
- favicon = manga?.source?.faviconUri() ?: Uri.EMPTY,
- title = manga?.title.orEmpty(),
- altTitle = manga?.altTitle.orEmpty(),
- author = manga?.author.orEmpty(),
- isNsfw = manga?.isNsfw ?: true,
- state = manga?.state ?: MangaState.FINISHED,
- source = manga?.source ?: MangaSource.DUMMY,
- isTabletUi = false,
- appBarPadding = topPadding,
- onCoverClick = {
- navigateToFullImage(
- arrayOf(
- manga?.largeCoverUrl ?: manga?.coverUrl.orEmpty(),
- ).toNavArgument()
- )
- },
- historyInfo = historyInfo,
- readingTime = readingTime,
- isInShelf = favouriteCategories,
- onAddToShelfClicked = {
- openCategoriesBottomSheet = !openCategoriesBottomSheet
- },
- onSourceClicked = {
- navigateToSource(
- manga?.source ?: MangaSource.DUMMY
- )
- },
- onDownloadClick = downloadCallback
- )
- }
- }
-
- item(
- key = DetailsViewItem.DESCRIPTION_WITH_TAG,
- contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
- ) {
- ExpandableMangaDescription(
- defaultExpandState = true,
- description = viewModel.details.value?.toManga()?.description,
- tagsProvider = { viewModel.details.value?.toManga()?.tags },
- onTagSearch = { },
- onCopyTagToClipboard = { },
- )
- }
-
- item {
- AnimatedVisibility(
- visible = relatedManga.isNotEmpty() && AppSettings.isRelatedMangaEnabled(),
- enter = fadeIn(),
- exit = fadeOut()
- ) {
- 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 = { navigateToReader() }
- )
- }
- }
- }
-
- DownloadSettingDialog(
- useDialog = useDialog,
- showDialog = showDownloadDialog,
- sheetState = sheetState,
- onDownloadConfirm = { checkPermissionOrDownload() },
- onDismissRequest = {
- if (!useDialog) {
- scope.launch { sheetState.hide() }.invokeOnCompletion {
- showDownloadDialog = false
- }
- } else {
- showDownloadDialog = false
- }
- }
- )
-
- 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/shirizu/sections/details/FullImageView.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/FullImageView.kt
index e09c187..4b7012b 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/FullImageView.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/FullImageView.kt
@@ -30,7 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import kotlinx.coroutines.launch
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.components.BackIconButton
import org.xtimms.shirizu.core.components.ViewInBrowserButton
import org.xtimms.shirizu.ui.theme.ShirizuTheme
@@ -41,7 +41,6 @@ const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FullImageView(
- coil: ImageLoader,
pictures: Array,
navigateBack: () -> Unit,
) {
@@ -87,8 +86,7 @@ fun FullImageView(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
model = pictures[page],
contentDescription = "image$page",
modifier = Modifier.fillMaxSize(),
@@ -131,7 +129,6 @@ fun FullImageView(
fun FullPosterPreview() {
ShirizuTheme {
FullImageView(
- coil = ImageLoader(LocalContext.current),
pictures = arrayOf("", ""),
navigateBack = {}
)
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/MangaScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/MangaScreen.kt
new file mode 100644
index 0000000..95dea64
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/MangaScreen.kt
@@ -0,0 +1,159 @@
+package org.xtimms.shirizu.sections.details
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import coil.ImageLoader
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.ClassicDetailsToolbar
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.VerticalFastScroller
+import org.xtimms.shirizu.core.model.MangaHistory
+import org.xtimms.shirizu.core.parser.favicon.faviconUri
+import org.xtimms.shirizu.sections.details.data.ReadingTime
+import org.xtimms.shirizu.sections.details.model.HistoryInfo
+import java.time.Instant
+
+@Composable
+fun MangaScreen(
+ state: DetailsScreenModel.State.Success,
+ snackbarHostState: SnackbarHostState,
+ isTabletUi: Boolean,
+ onBackClicked: () -> Unit,
+ onWebViewClicked: (() -> Unit)?,
+ onWebViewLongClicked: (() -> Unit)?,
+ onTrackingClicked: () -> Unit,
+
+ // For tags menu
+ onTagSearch: (String) -> Unit,
+
+ onFilterButtonClicked: () -> Unit,
+ onRefresh: () -> Unit,
+ onContinueReading: () -> Unit,
+
+ // For cover dialog
+ onCoverClicked: () -> Unit,
+) {
+ if (!isTabletUi) {
+ MangaScreenSmallImpl(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onBackClicked = onBackClicked,
+ onTagSearch = onTagSearch,
+ onRefresh = onRefresh,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MangaScreenSmallImpl(
+ state: DetailsScreenModel.State.Success,
+ snackbarHostState: SnackbarHostState,
+ onBackClicked: () -> Unit,
+ onTagSearch: (String) -> Unit,
+ onRefresh: () -> Unit,
+) {
+ val chapterListState = rememberLazyListState()
+
+ BackHandler(onBack = { onBackClicked() })
+
+ Scaffold(
+ topBar = {
+ val isFirstItemVisible by remember {
+ derivedStateOf { chapterListState.firstVisibleItemIndex == 0 }
+ }
+ val isFirstItemScrolled by remember {
+ derivedStateOf { chapterListState.firstVisibleItemScrollOffset > 0 }
+ }
+ val animatedTitleAlpha by animateFloatAsState(
+ if (!isFirstItemVisible) 1f else 0f,
+ label = "Top Bar Title",
+ )
+ val animatedBgAlpha by animateFloatAsState(
+ if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f,
+ label = "Top Bar Background",
+ )
+ ClassicDetailsToolbar(
+ title = state.manga?.title ?: "",
+ titleAlphaProvider = { animatedTitleAlpha },
+ backgroundAlphaProvider = { animatedBgAlpha },
+ navigateBack = { onBackClicked() },
+ )
+ },
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ ) { contentPadding ->
+ val topPadding = contentPadding.calculateTopPadding()
+
+ val layoutDirection = LocalLayoutDirection.current
+ VerticalFastScroller(
+ listState = chapterListState,
+ topContentPadding = topPadding,
+ endContentPadding = contentPadding.calculateEndPadding(layoutDirection),
+ ) {
+ LazyColumn(
+ modifier = Modifier.fillMaxHeight(),
+ state = chapterListState,
+ contentPadding = PaddingValues(
+ start = contentPadding.calculateStartPadding(layoutDirection),
+ end = contentPadding.calculateEndPadding(layoutDirection),
+ bottom = contentPadding.calculateBottomPadding(),
+ ),
+ ) {
+ item(
+ key = DetailsScreenItem.INFO_BOX,
+ contentType = DetailsScreenItem.INFO_BOX,
+ ) {
+ ClassicDetailsInfoBox(
+ imageUrl = state.manga.largeCoverUrl ?: state.manga.coverUrl,
+ favicon = state.manga.source.faviconUri(),
+ title = state.manga.title,
+ altTitle = state.manga.altTitle ?: stringResource(id = R.string.unknown),
+ author = state.manga.author ?: stringResource(id = R.string.unknown),
+ isNsfw = state.manga.isNsfw,
+ state = state.manga.state,
+ source = state.manga.source,
+ historyInfo = state.historyInfo,
+ readingTime = state.readingTime,
+ isTabletUi = false,
+ appBarPadding = topPadding,
+ onCoverClick = { },
+ isInShelf = true,
+ onAddToShelfClicked = { },
+ onSourceClicked = { },
+ onDownloadClick = { }
+ )
+ }
+
+ item(
+ key = DetailsScreenItem.DESCRIPTION_WITH_TAG,
+ contentType = DetailsScreenItem.DESCRIPTION_WITH_TAG,
+ ) {
+ ExpandableMangaDescription(
+ defaultExpandState = false,
+ description = state.manga?.description,
+ tagsProvider = { state.manga?.tags },
+ onTagSearch = onTagSearch,
+ onCopyTagToClipboard = { },
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/details/ModernDetailsInfoBox.kt b/app/src/main/java/org/xtimms/shirizu/sections/details/ModernDetailsInfoBox.kt
index bec4719..cf8a14b 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/details/ModernDetailsInfoBox.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/details/ModernDetailsInfoBox.kt
@@ -25,13 +25,12 @@ import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.sections.details.data.ReadingTime
import org.xtimms.shirizu.sections.details.model.HistoryInfo
@Composable
fun ModernDetailsInfoBox(
- coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
@@ -57,8 +56,7 @@ fun ModernDetailsInfoBox(
.fillMaxWidth(),
contentAlignment = Alignment.BottomEnd,
) {
- AsyncImageImpl(
- coil = coil,
+ ShirizuAsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
@@ -91,7 +89,6 @@ fun ModernDetailsInfoBox(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (!isTabletUi) {
MangaAndSourceTitlesSmall(
- coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
@@ -107,7 +104,6 @@ fun ModernDetailsInfoBox(
)
} else {
MangaAndSourceTitlesLarge(
- coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreEvent.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreEvent.kt
deleted file mode 100644
index 6f19c1c..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreEvent.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.xtimms.shirizu.sections.explore
-
-import org.xtimms.shirizu.core.base.event.UiEvent
-
-interface ExploreEvent : UiEvent
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreTab.kt
new file mode 100644
index 0000000..ed4bd61
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreTab.kt
@@ -0,0 +1,48 @@
+package org.xtimms.shirizu.sections.explore
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Explore
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import kotlinx.collections.immutable.persistentListOf
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabbedScreen
+import org.xtimms.shirizu.sections.explore.catalog.catalogTab
+import org.xtimms.shirizu.sections.explore.sources.sourcesTab
+import org.xtimms.shirizu.utils.lang.NoLiftingAppBarScreen
+import org.xtimms.shirizu.utils.lang.Tab
+
+data class ExploreTab(
+ private val toCatalog: Boolean = false,
+) : Tab, NoLiftingAppBarScreen {
+
+ override val options: TabOptions
+ @Composable
+ get() {
+ val image = Icons.Outlined.Explore
+ return TabOptions(
+ index = 3u,
+ title = stringResource(R.string.nav_explore),
+ icon = rememberVectorPainter(image),
+ )
+ }
+
+ @Composable
+ override fun Content() {
+ val context = LocalContext.current
+
+ TabbedScreen(
+ titleRes = R.string.nav_explore,
+ tabs = persistentListOf(
+ sourcesTab(),
+ catalogTab()
+ ),
+ startIndex = 1.takeIf { toCatalog },
+ searchQuery = "",
+ onChangeSearchQuery = { },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreUiState.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreUiState.kt
deleted file mode 100644
index 9b4bf97..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreUiState.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.xtimms.shirizu.sections.explore
-
-import coil.ImageLoader
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shirizu.core.base.state.UiState
-
-data class ExploreUiState(
- val sources: List = emptyList(),
- val coil: ImageLoader? = null,
- 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/shirizu/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreView.kt
deleted file mode 100644
index 1457930..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreView.kt
+++ /dev/null
@@ -1,263 +0,0 @@
-package org.xtimms.shirizu.sections.explore
-
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.FlowRowOverflow
-import androidx.compose.foundation.layout.IntrinsicSize
-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.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-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.SdStorage
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-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.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
-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.LocalLayoutDirection
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-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 com.google.android.material.progressindicator.CircularProgressIndicator
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.AsyncImageImpl
-import org.xtimms.shirizu.core.components.ExploreButton
-import org.xtimms.shirizu.core.components.SourceItem
-import org.xtimms.shirizu.core.components.icons.Dice
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.ui.theme.ShirizuTheme
-import org.xtimms.shirizu.utils.lang.observeEvent
-
-const val EXPLORE_DESTINATION = "explore"
-
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-fun ExploreView(
- viewModel: ExploreViewModel = hiltViewModel(),
- coil: ImageLoader,
- navigateToDetails: (Long) -> Unit,
- navigateToSource: (SourceItemModel) -> Unit,
- navigateToSuggestions: () -> Unit,
- nestedScrollConnection: NestedScrollConnection? = null,
- listState: LazyListState,
- padding: PaddingValues = PaddingValues(),
-) {
-
- val layoutDirection = LocalLayoutDirection.current
-
- val sources = viewModel.content.collectAsStateWithLifecycle(emptyList())
- val recommendation by viewModel.getSuggestionFlow().collectAsStateWithLifecycle(null)
- val isSuggestionsEnabled by viewModel.isSuggestionsEnabled.collectAsStateWithLifecycle()
-
- Box(
- modifier = Modifier
- .clipToBounds()
- .fillMaxSize(),
- contentAlignment = Alignment.TopCenter
- ) {
- val listModifier = Modifier
- .fillMaxWidth()
- .align(Alignment.TopStart)
- .then(
- if (nestedScrollConnection != null)
- Modifier.nestedScroll(nestedScrollConnection)
- else Modifier
- )
- LazyColumn(
- modifier = listModifier,
- state = listState,
- contentPadding = PaddingValues(
- start = padding.calculateStartPadding(layoutDirection) + 8.dp,
- top = padding.calculateTopPadding() + 8.dp,
- end = padding.calculateEndPadding(layoutDirection) + 8.dp,
- bottom = padding.calculateBottomPadding() + 8.dp
- ),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- item {
- Row {
- ExploreButton(
- text = stringResource(R.string.local_storage),
- icon = Icons.Outlined.SdStorage,
- modifier = Modifier.weight(1f),
- onClick = { }
- )
-
- ExploreButton(
- text = stringResource(R.string.bookmarks),
- icon = Icons.Outlined.Bookmarks,
- modifier = Modifier.weight(1f),
- onClick = { }
- )
- }
- }
- item {
- Row {
- ExploreButton(
- text = stringResource(R.string.random),
- icon = if (viewModel.isRandomLoading.value) CircularProgressIndicator() else Icons.Outlined.Dice,
- modifier = Modifier.weight(1f),
- onClick = { viewModel.openRandom() },
- )
-
- ExploreButton(
- text = stringResource(R.string.downloads),
- icon = Icons.Outlined.Download,
- modifier = Modifier.weight(1f),
- onClick = { },
- )
- }
- }
- if (isSuggestionsEnabled) {
- item {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
- .clip(MaterialTheme.shapes.extraLarge)
- .clickable { recommendation?.id?.let { navigateToDetails(it) } }
- .animateContentSize(),
- 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 = stringResource(id = R.string.suggestions),
- style = MaterialTheme.typography.labelLarge
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- AsyncImageImpl(
- coil = coil,
- modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(72.dp))
- .aspectRatio(1f),
- contentScale = ContentScale.Crop,
- model = recommendation?.coverUrl,
- contentDescription = ""
- )
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- ) {
- Text(
- text = recommendation?.title ?: "",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurface,
- overflow = TextOverflow.Ellipsis,
- maxLines = 2
- )
- recommendation?.tags?.joinToString(", ") { it.title }?.let {
- Text(
- text = it,
- modifier = Modifier.padding(vertical = 2.dp),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- overflow = TextOverflow.Ellipsis,
- maxLines = 1
- )
- }
- }
- }
- Button(
- onClick = { navigateToSuggestions() },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(text = "More")
- }
- }
- }
- }
- }
- item {
- FlowRow(
- modifier = Modifier
- .fillMaxWidth(1f),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalArrangement = Arrangement.spacedBy(8.dp),
- overflow = FlowRowOverflow.Clip
- ) {
- for (item in sources.value) {
- Box(
- modifier = Modifier.width(IntrinsicSize.Min),
- contentAlignment = Alignment.TopCenter
- ) {
- SourceItem(
- coil = coil,
- faviconUrl = item.favicon,
- title = item.title
- ) {
- navigateToSource(item)
- }
- }
- }
- }
- }
- }
- }
-}
-
-@PreviewLightDark
-@Composable
-fun RecommendationPreview() {
- ShirizuTheme {
- 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/shirizu/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreViewModel.kt
deleted file mode 100644
index 4825069..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/explore/ExploreViewModel.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package org.xtimms.shirizu.sections.explore
-
-import androidx.lifecycle.viewModelScope
-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.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.plus
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
-import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
-import org.xtimms.shirizu.core.parser.favicon.faviconUri
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.data.repository.ExploreRepository
-import org.xtimms.shirizu.data.repository.MangaSourcesRepository
-import org.xtimms.shirizu.data.repository.SuggestionRepository
-import org.xtimms.shirizu.utils.lang.MutableEventFlow
-import org.xtimms.shirizu.utils.lang.call
-import org.xtimms.shirizu.utils.lang.mapItems
-import javax.inject.Inject
-
-@HiltViewModel
-class ExploreViewModel @Inject constructor(
- private val suggestionRepository: SuggestionRepository,
- private val exploreRepository: ExploreRepository,
- private val mangaSourcesRepository: MangaSourcesRepository,
-) : KotatsuBaseViewModel() {
-
- val onOpenManga = MutableEventFlow()
- val isRandomLoading = MutableStateFlow(false)
- val isSuggestionsEnabled = MutableStateFlow(AppSettings.isSuggestionsEnabled()).asStateFlow()
-
- private val sourcesStateFlow = mangaSourcesRepository.observeEnabledSources()
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
-
- 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(),
- getSuggestionFlow(),
- mangaSourcesRepository.observeNewSources(),
- ) { content, suggestions, newSources ->
- buildList(content, suggestions, newSources)
- }
-
- fun openRandom() {
- if (isRandomLoading.value) {
- return
- }
- launchJob(Dispatchers.Default) {
- isRandomLoading.value = true
- try {
- val manga = exploreRepository.findRandomManga(tagsLimit = 8)
- onOpenManga.call(manga)
- } finally {
- isRandomLoading.value = false
- }
- }
- }
-
- private fun buildList(
- sources: List,
- recommendation: Manga?,
- newSources: Set,
- ): List {
- val result = ArrayList(sources.size + 3)
- if (recommendation != null) {
-
- }
- return result
- }
-
- @OptIn(ExperimentalCoroutinesApi::class)
- fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled ->
- if (isEnabled) {
- runCatchingCancellable {
- suggestionRepository.getRandom()
- }.getOrNull()
- } else {
- null
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/SourceItemModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/SourceItemModel.kt
deleted file mode 100644
index 1a24abe..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/explore/SourceItemModel.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.xtimms.shirizu.sections.explore
-
-import android.net.Uri
-import org.xtimms.shirizu.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/shirizu/sections/explore/catalog/CatalogScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogScreen.kt
new file mode 100644
index 0000000..f51bdd1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogScreen.kt
@@ -0,0 +1,269 @@
+package org.xtimms.shirizu.sections.explore.catalog
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.ExploreOff
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.FastScrollLazyColumn
+import org.xtimms.shirizu.core.components.FilterSortPanel
+import org.xtimms.shirizu.core.components.SearchTextField
+import org.xtimms.shirizu.core.model.isNsfw
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.COMICS
+import org.xtimms.shirizu.core.prefs.HENTAI
+import org.xtimms.shirizu.core.prefs.MANGA
+import org.xtimms.shirizu.core.prefs.OTHER
+import org.xtimms.shirizu.core.prefs.RELATED
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.sections.explore.sources.BaseSourceItem
+import org.xtimms.shirizu.sections.explore.sources.SourceHeader
+import org.xtimms.shirizu.sections.explore.sources.SourceUiModel
+import org.xtimms.shirizu.utils.material.SecondaryItemAlpha
+import org.xtimms.shirizu.utils.system.plus
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun CatalogScreen(
+ state: CatalogScreenModel.State,
+ contentPadding: PaddingValues,
+ onFilterChanged: (String) -> Unit,
+ onClickItem: (MangaSource) -> Unit,
+ onClickMenu: (MangaSource) -> Unit,
+ onClickEnable: (MangaSource) -> Unit,
+ onLongClickItem: (MangaSource) -> Unit,
+ onToggleEnableMangaSources: (Boolean) -> Unit,
+ onToggleEnableHentaiSources: (Boolean) -> Unit,
+ onToggleEnableComicsSources: (Boolean) -> Unit,
+ onToggleEnableOtherSources: (Boolean) -> Unit
+) {
+
+ var filterExpanded by remember { mutableStateOf(false) }
+ var isMangaContentTypeEnabled by remember { mutableStateOf(AppSettings.isMangaContentTypeEnabled()) }
+ var isHentaiContentTypeEnabled by remember { mutableStateOf(AppSettings.isHentaiContentTypeEnabled()) }
+ var isComicsContentTypeEnabled by remember { mutableStateOf(AppSettings.isComicsContentTypeEnabled()) }
+ var isOtherContentTypeEnabled by remember { mutableStateOf(AppSettings.isOtherContentTypeEnabled()) }
+
+ when {
+ state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
+ else -> {
+ FastScrollLazyColumn(
+ contentPadding = contentPadding + PaddingValues(top = 8.dp),
+ ) {
+ item {
+ var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue(""))
+ }
+ FilterSortPanel(
+ filterIcon = {
+ IconButton(onClick = { filterExpanded = true }) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null, // FIXME
+ )
+ }
+ },
+ filterTextField = {
+ SearchTextField(
+ value = filter,
+ onValueChange = { value ->
+ filter = value
+ onFilterChanged(value.text)
+ },
+ hint = stringResource(
+ id = R.string.filter_n_sources,
+ state.items.size
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ onCleared = {
+ filter = TextFieldValue()
+ onFilterChanged("")
+ filterExpanded = false
+ },
+ )
+ },
+ filterExpanded = filterExpanded,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ ) {
+ FilterChip(
+ selected = isMangaContentTypeEnabled,
+ leadingIcon = {
+ AnimatedVisibility(visible = isMangaContentTypeEnabled) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ onClick = {
+ isMangaContentTypeEnabled = !isMangaContentTypeEnabled
+ AppSettings.updateValue(MANGA, isMangaContentTypeEnabled)
+ onToggleEnableMangaSources(isMangaContentTypeEnabled)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.manga))
+ },
+ )
+
+ FilterChip(
+ selected = isHentaiContentTypeEnabled,
+ leadingIcon = {
+ AnimatedVisibility(visible = isHentaiContentTypeEnabled) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ onClick = {
+ isHentaiContentTypeEnabled = !isHentaiContentTypeEnabled
+ AppSettings.updateValue(HENTAI, isHentaiContentTypeEnabled)
+ onToggleEnableHentaiSources(isHentaiContentTypeEnabled)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.hentai))
+ },
+ )
+
+ FilterChip(
+ selected = isComicsContentTypeEnabled,
+ leadingIcon = {
+ AnimatedVisibility(visible = isComicsContentTypeEnabled) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ onClick = {
+ isComicsContentTypeEnabled = !isComicsContentTypeEnabled
+ AppSettings.updateValue(COMICS, isComicsContentTypeEnabled)
+ onToggleEnableComicsSources(isComicsContentTypeEnabled)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.comics))
+ },
+ )
+
+ FilterChip(
+ selected = isOtherContentTypeEnabled,
+ leadingIcon = {
+ AnimatedVisibility(visible = isOtherContentTypeEnabled) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ onClick = {
+ isOtherContentTypeEnabled = !isOtherContentTypeEnabled
+ AppSettings.updateValue(OTHER, isOtherContentTypeEnabled)
+ onToggleEnableOtherSources(isOtherContentTypeEnabled)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.other_source))
+ },
+ )
+ }
+ }
+ items(
+ items = state.items,
+ contentType = {
+ when (it) {
+ is SourceUiModel.Header -> "catalog_header"
+ is SourceUiModel.Item -> "catalog_item"
+ }
+ },
+ key = {
+ when (it) {
+ is SourceUiModel.Header -> it.hashCode()
+ is SourceUiModel.Item -> "catalog_source-${it.source.ordinal}-${it.source.name}"
+ }
+ },
+ ) { model ->
+ when (model) {
+ is SourceUiModel.Header -> {
+ SourceHeader(
+ modifier = Modifier.animateItem(),
+ language = model.language
+ ?: stringResource(id = R.string.multi_lang),
+ )
+ }
+
+ is SourceUiModel.Item -> SourceItem(
+ modifier = Modifier.animateItem(),
+ source = model.source,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickMenu = onClickMenu,
+ onClickEnable = onClickEnable
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SourceItem(
+ source: MangaSource,
+ onClickItem: (MangaSource) -> Unit,
+ onLongClickItem: (MangaSource) -> Unit,
+ onClickMenu: (MangaSource) -> Unit,
+ onClickEnable: (MangaSource) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BaseSourceItem(
+ modifier = modifier,
+ source = source,
+ onClickItem = { onClickItem(source) },
+ onLongClickItem = { onLongClickItem(source) },
+ action = {
+ if (it.isNsfw()) {
+ Text(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ text = "18+",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ IconButton(onClick = { onClickEnable(it) }) {
+ Icon(
+ imageVector = Icons.Outlined.Add,
+ tint = MaterialTheme.colorScheme.onBackground.copy(
+ alpha = SecondaryItemAlpha,
+ ),
+ contentDescription = stringResource(R.string.add),
+ )
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogScreenModel.kt
new file mode 100644
index 0000000..4c080f5
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogScreenModel.kt
@@ -0,0 +1,142 @@
+package org.xtimms.shirizu.sections.explore.catalog
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.data.repository.MangaSourcesRepository
+import org.xtimms.shirizu.sections.explore.sources.SourceUiModel
+import org.xtimms.shirizu.utils.LocaleHelper
+import org.xtimms.shirizu.utils.lang.combine
+import javax.inject.Inject
+
+@OptIn(FlowPreview::class)
+class CatalogScreenModel @Inject constructor(
+ private val mangaSourcesRepository: MangaSourcesRepository,
+) : StateScreenModel(State()) {
+
+ private val _events = Channel(Int.MAX_VALUE)
+ val events = _events.receiveAsFlow()
+
+ init {
+ val queryFilter: (String) -> ((MangaSource) -> Boolean) = { query ->
+ filter@{ source ->
+ if (query.isEmpty()) return@filter true
+ query.split(",").any { _input ->
+ val input = _input.trim()
+ if (input.isEmpty()) return@any false
+ source.title.contains(input, ignoreCase = true)
+ }
+ }
+ }
+ screenModelScope.launch(Dispatchers.IO) {
+ mangaSourcesRepository.assimilateNewSources()
+ combine(
+ mangaSourcesRepository.observeDisabledSources(),
+ state.map { it.searchQuery }.distinctUntilChanged().debounce(150L),
+ state.map { it.mangaSourcesEnabled }.distinctUntilChanged(),
+ state.map { it.hentaiSourcesEnabled }.distinctUntilChanged(),
+ state.map { it.comicsSourcesEnabled }.distinctUntilChanged(),
+ state.map { it.otherSourcesEnabled }.distinctUntilChanged()
+ ) { sources, query, m, h, c, o ->
+ val searchQuery = query ?: ""
+ sources.sortedBy { it.title }
+ .filter {
+ when (it.contentType) {
+ ContentType.MANGA -> m
+ ContentType.HENTAI -> h
+ ContentType.COMICS -> c
+ ContentType.OTHER -> o
+ }
+ }
+ .filter(queryFilter(searchQuery))
+ .groupBy { it.locale }
+ .toSortedMap(LocaleHelper.comparator)
+ .flatMap {
+ listOf(
+ SourceUiModel.Header(it.key),
+ *it.value.map { source ->
+ SourceUiModel.Item(source)
+ }.toTypedArray()
+ )
+ }.toImmutableList()
+ }.collectLatest {
+ mutableState.update { state ->
+ state.copy(
+ isLoading = false,
+ items = it,
+ )
+ }
+ }
+ }
+ }
+
+ fun enableSource(source: MangaSource) {
+ screenModelScope.launch(Dispatchers.IO) {
+ mangaSourcesRepository.setSourceEnabled(source, true)
+ }
+ }
+
+ fun search(query: String?) {
+ mutableState.update {
+ it.copy(searchQuery = query)
+ }
+ }
+
+ fun filterMangaSources(enabled: Boolean) {
+ mutableState.update {
+ it.copy(mangaSourcesEnabled = enabled)
+ }
+ }
+
+ fun filterHentaiSources(enabled: Boolean) {
+ mutableState.update {
+ it.copy(hentaiSourcesEnabled = enabled)
+ }
+ }
+
+ fun filterComicsSources(enabled: Boolean) {
+ mutableState.update {
+ it.copy(comicsSourcesEnabled = enabled)
+ }
+ }
+
+ fun filterOtherSources(enabled: Boolean) {
+ mutableState.update {
+ it.copy(otherSourcesEnabled = enabled)
+ }
+ }
+
+ @Immutable
+ data class State(
+ val isLoading: Boolean = true,
+ val items: ImmutableList = persistentListOf(),
+ val searchQuery: String? = null,
+ val mangaSourcesEnabled: Boolean = AppSettings.isMangaContentTypeEnabled(),
+ val hentaiSourcesEnabled: Boolean = AppSettings.isHentaiContentTypeEnabled(),
+ val comicsSourcesEnabled: Boolean = AppSettings.isComicsContentTypeEnabled(),
+ val otherSourcesEnabled: Boolean = AppSettings.isOtherContentTypeEnabled()
+ ) {
+ val isEmpty = items.isEmpty()
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ }
+
+}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogTab.kt
new file mode 100644
index 0000000..289d4fb
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/catalog/CatalogTab.kt
@@ -0,0 +1,57 @@
+package org.xtimms.shirizu.sections.explore.catalog
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabContent
+import org.xtimms.shirizu.sections.explore.sources.SourcesScreen
+import org.xtimms.shirizu.sections.explore.sources.SourcesScreenModel
+import org.xtimms.shirizu.sections.list.MangaListScreen
+
+@Composable
+fun Screen.catalogTab(): TabContent {
+
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ return TabContent(
+ titleRes = R.string.catalog,
+ content = { contentPadding, snackbarHostState ->
+ CatalogScreen(
+ state = state,
+ contentPadding = contentPadding,
+ onClickItem = { source ->
+
+ },
+ onClickMenu = { },
+ onClickEnable = { screenModel.enableSource(it) },
+ onLongClickItem = { },
+ onFilterChanged = { screenModel.search(it) },
+ onToggleEnableMangaSources = { screenModel.filterMangaSources(it) },
+ onToggleEnableHentaiSources = { screenModel.filterHentaiSources(it) },
+ onToggleEnableComicsSources = { screenModel.filterComicsSources(it) },
+ onToggleEnableOtherSources = { screenModel.filterOtherSources(it) }
+ )
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { event ->
+ when (event) {
+ CatalogScreenModel.Event.InternalError -> {
+ launch { snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured)) }
+ }
+ }
+ }
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseExploreItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseExploreItem.kt
new file mode 100644
index 0000000..a5786b4
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseExploreItem.kt
@@ -0,0 +1,36 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun BaseExploreItem(
+ modifier: Modifier = Modifier,
+ onClickItem: () -> Unit = {},
+ onLongClickItem: () -> Unit = {},
+ icon: @Composable RowScope.() -> Unit = {},
+ action: @Composable RowScope.() -> Unit = {},
+ content: @Composable RowScope.() -> Unit = {},
+) {
+ Row(
+ modifier = modifier
+ .combinedClickable(
+ onClick = onClickItem,
+ onLongClick = onLongClickItem,
+ )
+ .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ icon()
+ content()
+ action()
+ }
+}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseSourceItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseSourceItem.kt
new file mode 100644
index 0000000..b968750
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/BaseSourceItem.kt
@@ -0,0 +1,85 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import android.content.Context
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.os.LocaleListCompat
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.utils.LocaleHelper
+import org.xtimms.shirizu.utils.composable.secondaryItemAlpha
+import java.util.Locale
+
+@Composable
+fun BaseSourceItem(
+ source: MangaSource,
+ modifier: Modifier = Modifier,
+ showTypeInContent: Boolean = true,
+ onClickItem: () -> Unit = {},
+ onLongClickItem: () -> Unit = {},
+ icon: @Composable RowScope.(MangaSource) -> Unit = defaultIcon,
+ action: @Composable RowScope.(MangaSource) -> Unit = {},
+ content: @Composable RowScope.(MangaSource, String?) -> Unit = defaultContent,
+) {
+ fun getPrettyContentTypeName(type: ContentType?, context: Context): String {
+ if (type == null) {
+ return ""
+ }
+ return when (type) {
+ ContentType.COMICS -> context.resources.getString(R.string.comics)
+ ContentType.HENTAI -> context.resources.getString(R.string.hentai)
+ ContentType.MANGA -> context.resources.getString(R.string.manga)
+ ContentType.OTHER -> context.resources.getString(R.string.other)
+ }
+ }
+
+ val sourceTypeString = getPrettyContentTypeName(source.contentType, LocalContext.current).takeIf {
+ showTypeInContent
+ }
+
+ BaseExploreItem(
+ modifier = modifier,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ icon = { icon.invoke(this, source) },
+ action = { action.invoke(this, source) },
+ content = { content.invoke(this, source, sourceTypeString) },
+ )
+}
+
+private val defaultIcon: @Composable RowScope.(MangaSource) -> Unit = { source ->
+ SourceIcon(source = source)
+}
+
+private val defaultContent: @Composable RowScope.(MangaSource, String?) -> Unit = { source, sourceLangString ->
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .weight(1f),
+ ) {
+ Text(
+ text = source.title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (sourceLangString != null) {
+ Text(
+ modifier = Modifier.secondaryItemAlpha(),
+ text = sourceLangString,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/ExploreIcons.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/ExploreIcons.kt
new file mode 100644
index 0000000..bf32328
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/ExploreIcons.kt
@@ -0,0 +1,39 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.ShirizuAsyncImage
+import org.xtimms.shirizu.core.parser.favicon.faviconUri
+
+private val defaultModifier = Modifier
+ .height(42.dp)
+ .aspectRatio(1f)
+
+@Composable
+fun SourceIcon(
+ source: MangaSource,
+ modifier: Modifier = Modifier,
+) {
+ val icon = source.faviconUri()
+
+ Card(
+ modifier = modifier.then(defaultModifier),
+ ) {
+ ShirizuAsyncImage(
+ model = icon,
+ contentDescription = "favicon",
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.medium)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreen.kt
new file mode 100644
index 0000000..fb7aa80
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreen.kt
@@ -0,0 +1,177 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.outlined.ExploreOff
+import androidx.compose.material.icons.outlined.ExtensionOff
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material.icons.outlined.PushPin
+import androidx.compose.material.icons.outlined.Remove
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.ScrollbarLazyColumn
+import org.xtimms.shirizu.core.model.isNsfw
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.utils.LocaleHelper
+import org.xtimms.shirizu.utils.material.SecondaryItemAlpha
+import org.xtimms.shirizu.utils.system.plus
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SourcesScreen(
+ state: SourcesScreenModel.State,
+ contentPadding: PaddingValues,
+ onClickItem: (MangaSource) -> Unit,
+ onClickMenu: (MangaSource) -> Unit,
+ onClickHide: (MangaSource) -> Unit,
+ onLongClickItem: (MangaSource) -> Unit,
+) {
+ when {
+ state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
+ state.isEmpty -> EmptyScreen(
+ icon = Icons.Outlined.ExtensionOff,
+ title = R.string.no_enabled_sources,
+ description = R.string.no_enabled_sources_hint,
+ modifier = Modifier.padding(contentPadding),
+ )
+ else -> {
+ ScrollbarLazyColumn(
+ contentPadding = contentPadding + PaddingValues(top = 8.dp),
+ ) {
+ items(
+ items = state.items,
+ contentType = {
+ when (it) {
+ is SourceUiModel.Header -> "sources_header"
+ is SourceUiModel.Item -> "sources_item"
+ }
+ },
+ key = {
+ when (it) {
+ is SourceUiModel.Header -> it.hashCode()
+ is SourceUiModel.Item -> "source-${it.source.ordinal}"
+ }
+ },
+ ) { model ->
+ when (model) {
+ is SourceUiModel.Header -> {
+ SourceHeader(
+ modifier = Modifier.animateItem(),
+ language = model.language ?: stringResource(id = R.string.multi_lang),
+ )
+ }
+ is SourceUiModel.Item -> SourceItem(
+ modifier = Modifier.animateItem(),
+ source = model.source,
+ onClickItem = onClickItem,
+ onLongClickItem = onLongClickItem,
+ onClickMenu = onClickMenu,
+ onClickHide = onClickHide
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SourceHeader(
+ language: String,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ Text(
+ text = LocaleHelper.getSourceDisplayName(language, context),
+ modifier = modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+}
+
+@Composable
+fun SourceItem(
+ source: MangaSource,
+ onClickItem: (MangaSource) -> Unit,
+ onLongClickItem: (MangaSource) -> Unit,
+ onClickMenu: (MangaSource) -> Unit,
+ onClickHide: (MangaSource) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BaseSourceItem(
+ modifier = modifier,
+ source = source,
+ onClickItem = { onClickItem(source) },
+ onLongClickItem = { onLongClickItem(source) },
+ action = {
+ if (it.isNsfw()) {
+ Text(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ text = "18+",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ IconButton(onClick = { onClickHide(it) }) {
+ Icon(
+ imageVector = Icons.Outlined.Remove,
+ tint = MaterialTheme.colorScheme.onBackground.copy(
+ alpha = SecondaryItemAlpha,
+ ),
+ contentDescription = stringResource(R.string.remove),
+ )
+ }
+ IconButton(onClick = { onClickMenu(it) }) {
+ Icon(
+ imageVector = Icons.Outlined.MoreVert,
+ tint = MaterialTheme.colorScheme.onBackground.copy(
+ alpha = SecondaryItemAlpha,
+ ),
+ contentDescription = stringResource(R.string.open_menu),
+ )
+ }
+ },
+ )
+}
+
+@Composable
+private fun SourcePinButton(
+ isPinned: Boolean,
+ onClick: () -> Unit,
+) {
+ val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
+ val tint = if (isPinned) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onBackground.copy(
+ alpha = SecondaryItemAlpha,
+ )
+ }
+ val description = if (isPinned) R.string.action_unpin else R.string.action_pin
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = icon,
+ tint = tint,
+ contentDescription = stringResource(description),
+ )
+ }
+}
+
+sealed interface SourceUiModel {
+ data class Item(val source: MangaSource) : SourceUiModel
+ data class Header(val language: String?) : SourceUiModel
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreenModel.kt
new file mode 100644
index 0000000..495cd7d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesScreenModel.kt
@@ -0,0 +1,83 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.data.repository.MangaSourcesRepository
+import org.xtimms.shirizu.utils.LocaleHelper
+import javax.inject.Inject
+
+class SourcesScreenModel @Inject constructor(
+ private val mangaSourcesRepository: MangaSourcesRepository,
+) : StateScreenModel(State()) {
+
+ private val _events = Channel(Int.MAX_VALUE)
+ val events = _events.receiveAsFlow()
+
+ init {
+ screenModelScope.launch(Dispatchers.IO) {
+ mangaSourcesRepository.observeEnabledSources()
+ .catch {
+ it.printStackTrace()
+ _events.send(Event.InternalError)
+ }
+ .collectLatest(::collectEnabledSources)
+ }
+ }
+
+ private fun collectEnabledSources(sources: List) {
+ mutableState.update { state ->
+ state.copy(
+ isLoading = false,
+ items = sources.sortedBy { it.title }.groupBy { it.locale }
+ .toSortedMap(LocaleHelper.comparator)
+ .flatMap {
+ listOf(
+ SourceUiModel.Header(it.key),
+ *it.value.map { source ->
+ SourceUiModel.Item(source)
+ }.toTypedArray(),
+ )
+ }
+ .toImmutableList(),
+ )
+ }
+ }
+
+ fun hideSource(source: MangaSource) {
+ screenModelScope.launch(Dispatchers.IO) {
+ mangaSourcesRepository.setSourceEnabled(source, false)
+ }
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ }
+
+ data class Dialog(val source: MangaSource)
+
+ @Immutable
+ data class State(
+ val dialog: Dialog? = null,
+ val isLoading: Boolean = true,
+ val items: ImmutableList = persistentListOf(),
+ ) {
+ val isEmpty = items.isEmpty()
+ }
+
+ companion object {
+ const val PINNED_KEY = "pinned"
+ const val LAST_USED_KEY = "last_used"
+ }
+}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesTab.kt
new file mode 100644
index 0000000..09c86f7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/explore/sources/SourcesTab.kt
@@ -0,0 +1,51 @@
+package org.xtimms.shirizu.sections.explore.sources
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabContent
+import org.xtimms.shirizu.sections.list.MangaListScreen
+
+@Composable
+fun Screen.sourcesTab(): TabContent {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ return TabContent(
+ titleRes = R.string.sources,
+ content = { contentPadding, snackbarHostState ->
+ SourcesScreen(
+ state = state,
+ contentPadding = contentPadding,
+ onClickItem = { source ->
+ navigator.push(MangaListScreen(source))
+ },
+ onClickMenu = { },
+ onClickHide = { screenModel.hideSource(it) },
+ onLongClickItem = { },
+ )
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { event ->
+ when (event) {
+ SourcesScreenModel.Event.InternalError -> {
+ launch { snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured)) }
+ }
+ }
+ }
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreen.kt
new file mode 100644
index 0000000..52bc0a7
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreen.kt
@@ -0,0 +1,243 @@
+package org.xtimms.shirizu.sections.feed
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ClearAll
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.Refresh
+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.MaterialTheme
+import androidx.compose.material3.Text
+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.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.ConfirmButton
+import org.xtimms.shirizu.core.components.DialogCheckBoxItem
+import org.xtimms.shirizu.core.components.DismissButton
+import org.xtimms.shirizu.core.components.ListGroupHeader
+import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
+import org.xtimms.shirizu.core.components.ShirizuDialog
+import org.xtimms.shirizu.core.components.effects.RowEntity
+import org.xtimms.shirizu.core.components.effects.RowEntityType
+import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed
+import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState
+import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.sections.feed.model.toFeedItem
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.calculateTimeAgo
+import org.xtimms.shirizu.utils.lang.isSameDay
+import java.time.Instant
+
+@OptIn(ExperimentalFoundationApi::class)
+object FeedScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+
+ val navigator = LocalNavigator.currentOrThrow
+ var showClearDialog by remember { mutableStateOf(false) }
+
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ val animatedList = run {
+ val list = emptyList().toMutableList()
+ var createdAt: Instant? = null
+ state.list?.forEach { item ->
+
+ if (createdAt === null || !isSameDay(
+ item.createdAt.toEpochMilli(),
+ createdAt!!.toEpochMilli()
+ )
+ ) {
+ createdAt = item.createdAt
+
+ list.add(
+ RowEntity(
+ type = RowEntityType.Header,
+ key = "header-${createdAt}",
+ itemModel = null,
+ day = createdAt!!,
+ )
+ )
+ }
+ list.add(
+ RowEntity(
+ type = RowEntityType.Item,
+ key = "item-${item.manga.id}-${item.createdAt}",
+ day = createdAt!!,
+ itemModel = item
+ )
+ )
+ }
+ updateAnimatedItemsState(newList = list.toList().map { it })
+ }
+
+ ScaffoldWithClassicTopAppBar(
+ title = stringResource(R.string.feed),
+ navigateBack = navigator::pop,
+ actions = {
+ IconButton(onClick = { screenModel.updateFeed() }) {
+ Icon(imageVector = Icons.Outlined.Refresh, contentDescription = null)
+ }
+ IconButton(onClick = { }) {
+ Icon(imageVector = Icons.Outlined.Tune, contentDescription = null)
+ }
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(onClick = { showClearDialog = true }
+ ) {
+ 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 ->
+ state.list.let {
+ if (it == null) {
+ LoadingScreen(Modifier.padding(padding))
+ } else if (it.isEmpty()) {
+ EmptyScreen(
+ icon = Icons.Outlined.History,
+ title = R.string.empty_history_title,
+ description = R.string.empty_history_description
+ )
+ } else {
+ Box(
+ Modifier.fillMaxSize()
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = padding
+ ) {
+ animatedItemsIndexed(
+ state = animatedList.value,
+ key = { rowItem -> rowItem.key },
+ ) { _, item ->
+ when (item.type) {
+ RowEntityType.Header -> ListGroupHeader(
+ calculateTimeAgo(item.day).format(
+ LocalContext.current.resources
+ )
+ )
+
+ RowEntityType.Item -> FeedViewItem(
+ modifier = Modifier.animateItem(),
+ selected = false,
+ feed = (item.itemModel as TrackingLogItem).toFeedItem(),
+ onClick = { /*TODO*/ },
+ onLongClick = { /*TODO*/ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showClearDialog) {
+ ClearFeedDialog(
+ onDismissRequest = { showClearDialog = false },
+ isClearInfoAboutNewChaptersSelected = false,
+ onConfirm = { isClearInfoAboutNewChaptersSelected ->
+ if (isClearInfoAboutNewChaptersSelected) {
+ screenModel.clearFeed(true)
+ } else {
+ screenModel.clearFeed(false)
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun ClearFeedDialog(
+ onDismissRequest: () -> Unit = {},
+ isClearInfoAboutNewChaptersSelected: Boolean,
+ onConfirm: (isPagesCacheSelected: Boolean) -> Unit = { _ -> }
+) {
+
+ var infoAboutNewChapters by remember {
+ mutableStateOf(isClearInfoAboutNewChaptersSelected)
+ }
+
+ ShirizuDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ ConfirmButton {
+ onConfirm(infoAboutNewChapters)
+ onDismissRequest()
+ }
+ },
+ dismissButton = {
+ DismissButton {
+ onDismissRequest()
+ }
+ },
+ title = {
+ Text(
+ text = stringResource(
+ id = R.string.clear_updates_feed
+ )
+ )
+ },
+ icon = { Icon(imageVector = Icons.Outlined.ClearAll, contentDescription = null) },
+ text = {
+ Column {
+ Text(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 12.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ text = stringResource(id = R.string.clear_updates_feed_desc)
+ )
+ DialogCheckBoxItem(
+ text = stringResource(id = R.string.clear_info_about_new_chapters),
+ checked = infoAboutNewChapters
+ ) {
+ infoAboutNewChapters = !infoAboutNewChapters
+ }
+ }
+ })
+}
+
+@Preview
+@Composable
+private fun ClearFeedDialogPreview() {
+ ClearFeedDialog(
+ onDismissRequest = {},
+ isClearInfoAboutNewChaptersSelected = false
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreenModel.kt
new file mode 100644
index 0000000..45b6246
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedScreenModel.kt
@@ -0,0 +1,82 @@
+package org.xtimms.shirizu.sections.feed
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
+import org.xtimms.shirizu.data.repository.TrackingRepository
+import org.xtimms.shirizu.work.tracker.TrackWorker
+import javax.inject.Inject
+
+private const val PAGE_SIZE = 20
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class FeedScreenModel @Inject constructor(
+ private val trackingRepository: TrackingRepository,
+ private val trackScheduler: TrackWorker.Scheduler,
+) : StateScreenModel(State()) {
+
+ private val limit = MutableStateFlow(PAGE_SIZE)
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+
+ init {
+ screenModelScope.launch {
+ trackingRepository.gc()
+ state.flatMapLatest {
+ trackingRepository.observeTrackingLog(limit)
+ .distinctUntilChanged()
+ .catch {
+ _events.send(Event.InternalError)
+ }
+ .map { it }
+ .flowOn(Dispatchers.IO)
+ }
+ .collect { newList -> mutableState.update { it.copy(list = newList) } }
+ }
+ }
+
+ fun clearFeed(clearCounters: Boolean) {
+ screenModelScope.launch(Dispatchers.Default) {
+ trackingRepository.clearLogs()
+ if (clearCounters) {
+ trackingRepository.clearCounters()
+ }
+ _events.send(Event.FeedCleared)
+ }
+ }
+
+ fun updateFeed() {
+ trackScheduler.startNow()
+ }
+
+ @Immutable
+ data class State(
+ val searchQuery: String? = null,
+ val list: List? = null,
+ val dialog: Dialog? = null,
+ )
+
+ sealed interface Dialog {
+ data object DeleteAll : Dialog
+ data class Delete(val history: FeedScreenModel) : Dialog
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ data object FeedCleared : Event
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedView.kt b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedView.kt
deleted file mode 100644
index 00e1a2f..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedView.kt
+++ /dev/null
@@ -1,242 +0,0 @@
-package org.xtimms.shirizu.sections.feed
-
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.VisibilityThreshold
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.ClearAll
-import androidx.compose.material.icons.outlined.Refresh
-import androidx.compose.material.icons.outlined.RssFeed
-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.MaterialTheme
-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.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.ImageLoader
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.ConfirmButton
-import org.xtimms.shirizu.core.components.DialogCheckBoxItem
-import org.xtimms.shirizu.core.components.DismissButton
-import org.xtimms.shirizu.core.components.ListGroupHeader
-import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
-import org.xtimms.shirizu.core.components.ShirizuDialog
-import org.xtimms.shirizu.core.components.effects.RowEntity
-import org.xtimms.shirizu.core.components.effects.RowEntityType
-import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed
-import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState
-import org.xtimms.shirizu.core.ui.screens.EmptyScreen
-import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
-import org.xtimms.shirizu.sections.feed.model.toFeedItem
-import org.xtimms.shirizu.utils.lang.calculateTimeAgo
-import org.xtimms.shirizu.utils.lang.isSameDay
-import java.time.Instant
-
-const val FEED_DESTINATION = "feed"
-
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-fun FeedView(
- coil: ImageLoader,
- viewModel: FeedViewModel = hiltViewModel(),
- navigateBack: () -> Unit,
- navigateToShelf: () -> Unit,
-) {
-
- var showClearDialog by remember { mutableStateOf(false) }
-
- val feed by viewModel.content.collectAsStateWithLifecycle(emptyList())
-
- val animatedList = run {
- val list = emptyList().toMutableList()
- var createdAt: Instant? = null
- feed.forEach { item ->
-
- if (createdAt === null || !isSameDay(
- item.createdAt.toEpochMilli(),
- createdAt!!.toEpochMilli()
- )
- ) {
- createdAt = item.createdAt
-
- list.add(
- RowEntity(
- type = RowEntityType.Header,
- key = "header-${createdAt}",
- itemModel = null,
- day = createdAt!!,
- )
- )
- }
- list.add(
- RowEntity(
- type = RowEntityType.Item,
- key = "item-${item.manga.id}-${item.createdAt}",
- day = createdAt!!,
- itemModel = item
- )
- )
- }
- updateAnimatedItemsState(newList = list.toList().map { it })
- }
-
- ScaffoldWithClassicTopAppBar(
- title = stringResource(R.string.feed),
- navigateBack = navigateBack,
- actions = {
- IconButton(onClick = { viewModel.updateFeed() }) {
- Icon(imageVector = Icons.Outlined.Refresh, contentDescription = null)
- }
- IconButton(onClick = { navigateToShelf() }) {
- Icon(imageVector = Icons.Outlined.Tune, contentDescription = null)
- }
- },
- floatingActionButton = {
- ExtendedFloatingActionButton(onClick = { showClearDialog = true }
- ) {
- 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 ->
- Box(
- Modifier.fillMaxSize()
- ) {
- Column(Modifier.fillMaxSize()) {
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = padding
- ) {
- animatedItemsIndexed(
- state = animatedList.value,
- key = { rowItem -> rowItem.key },
- ) { _, item ->
- when (item.type) {
- RowEntityType.Header -> ListGroupHeader(
- calculateTimeAgo(item.day).format(
- LocalContext.current.resources
- )
- )
-
- RowEntityType.Item -> FeedViewItem(
- modifier = Modifier.animateItemPlacement(),
- coil = coil,
- selected = false,
- feed = (item.itemModel as TrackingLogItem).toFeedItem(),
- onClick = { /*TODO*/ },
- onLongClick = { /*TODO*/ }
- )
- }
- }
- }
- }
- if (feed.isEmpty()) {
- EmptyScreen(
- icon = Icons.Outlined.RssFeed,
- title = R.string.empty_here,
- description = R.string.no_recent_updates
- )
- }
- }
- }
-
- if (showClearDialog) {
- ClearFeedDialog(
- onDismissRequest = { showClearDialog = false },
- isClearInfoAboutNewChaptersSelected = false,
- onConfirm = { isClearInfoAboutNewChaptersSelected ->
- if (isClearInfoAboutNewChaptersSelected) {
- viewModel.clearFeed(true)
- } else {
- viewModel.clearFeed(false)
- }
- }
- )
- }
-}
-
-@Composable
-fun ClearFeedDialog(
- onDismissRequest: () -> Unit = {},
- isClearInfoAboutNewChaptersSelected: Boolean,
- onConfirm: (isPagesCacheSelected: Boolean) -> Unit = { _ -> }
-) {
-
- var infoAboutNewChapters by remember {
- mutableStateOf(isClearInfoAboutNewChaptersSelected)
- }
-
- ShirizuDialog(
- onDismissRequest = onDismissRequest,
- confirmButton = {
- ConfirmButton {
- onConfirm(infoAboutNewChapters)
- onDismissRequest()
- }
- },
- dismissButton = {
- DismissButton {
- onDismissRequest()
- }
- },
- title = {
- Text(
- text = stringResource(
- id = R.string.clear_updates_feed
- )
- )
- },
- icon = { Icon(imageVector = Icons.Outlined.ClearAll, contentDescription = null) },
- text = {
- Column {
- Text(
- modifier = Modifier
- .padding(horizontal = 24.dp)
- .padding(bottom = 12.dp),
- style = MaterialTheme.typography.bodyLarge,
- text = stringResource(id = R.string.clear_updates_feed_desc)
- )
- DialogCheckBoxItem(
- text = stringResource(id = R.string.clear_info_about_new_chapters),
- checked = infoAboutNewChapters
- ) {
- infoAboutNewChapters = !infoAboutNewChapters
- }
- }
- })
-}
-
-@Preview
-@Composable
-private fun ClearFeedDialogPreview() {
- ClearFeedDialog(
- onDismissRequest = {},
- isClearInfoAboutNewChaptersSelected = false
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedViewItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedViewItem.kt
index 1f0baae..1cc8c39 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedViewItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/feed/FeedViewItem.kt
@@ -37,7 +37,6 @@ const val ReadItemAlpha = .38f
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FeedViewItem(
- coil: ImageLoader,
selected: Boolean,
feed: FeedItem,
onClick: () -> Unit,
@@ -63,7 +62,6 @@ fun FeedViewItem(
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Square(
- coil = coil,
modifier = Modifier
.padding(vertical = 12.dp)
.fillMaxHeight(),
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryTab.kt
new file mode 100644
index 0000000..b175a18
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryTab.kt
@@ -0,0 +1,60 @@
+package org.xtimms.shirizu.sections.history
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import kotlinx.coroutines.channels.Channel
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.sections.details.DetailsScreen
+import org.xtimms.shirizu.sections.library.history.HistoryScreen
+import org.xtimms.shirizu.sections.library.history.HistoryScreenModel
+import org.xtimms.shirizu.utils.lang.Tab
+
+object HistoryTab : Tab {
+
+ private val snackbarHostState = SnackbarHostState()
+
+ private val resumeLastChapterReadEvent = Channel()
+
+ override val options: TabOptions
+ @Composable
+ get() {
+ val image = Icons.Outlined.History
+ return TabOptions(
+ index = 1u,
+ title = stringResource(R.string.history),
+ icon = rememberVectorPainter(image),
+ )
+ }
+
+ override suspend fun onReselect(navigator: Navigator) {
+ resumeLastChapterReadEvent.send(Unit)
+ }
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ /*HistoryScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ onSearchQueryChange = { },
+ onClick = { navigator.push(DetailsScreen(it)) },
+ onDialogChange = { },
+ )*/
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryView.kt b/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryView.kt
deleted file mode 100644
index 633518e..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryView.kt
+++ /dev/null
@@ -1,259 +0,0 @@
-package org.xtimms.shirizu.sections.history
-
-//noinspection UsingMaterialAndMaterial3Libraries
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.DismissDirection
-import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.DeleteForever
-import androidx.compose.material.icons.outlined.History
-import androidx.compose.material.icons.outlined.PlayArrow
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.derivedStateOf
-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.draw.clipToBounds
-import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.min
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.ImageLoader
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.ListGroupHeader
-import org.xtimms.shirizu.core.components.effects.RowEntity
-import org.xtimms.shirizu.core.components.effects.RowEntityType
-import org.xtimms.shirizu.core.components.effects.animatedItemsIndexed
-import org.xtimms.shirizu.core.components.effects.updateAnimatedItemsState
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.SWIPE_TUTORIAL
-import org.xtimms.shirizu.core.ui.screens.EmptyScreen
-import org.xtimms.shirizu.core.ui.screens.LoadingScreen
-import org.xtimms.shirizu.utils.lang.calculateTimeAgo
-import org.xtimms.shirizu.utils.lang.isSameDay
-import java.time.Instant
-import kotlin.math.abs
-import kotlin.math.absoluteValue
-
-const val HISTORY_DESTINATION = "history"
-
-@OptIn(ExperimentalMaterialApi::class)
-@Composable
-fun HistoryView(
- coil: ImageLoader,
- viewModel: HistoryViewModel = hiltViewModel(),
- nestedScrollConnection: NestedScrollConnection? = null,
- listState: LazyListState,
- padding: PaddingValues,
- navigateToDetails: (Long) -> Unit,
- navigateToReader: () -> Unit
-) {
-
- var isUserTrySwipe by remember { mutableStateOf(false) }
-
- val history by viewModel.content.collectAsStateWithLifecycle(null)
-
- DisposableEffect(Unit) {
- onDispose {
- if (history?.isNotEmpty() == true && isUserTrySwipe) {
- AppSettings.updateValue(SWIPE_TUTORIAL, isUserTrySwipe)
- }
- }
- }
-
- val animatedList = run {
- val list = emptyList().toMutableList()
- var readDate: Instant? = null
- history?.forEach { item ->
-
- if (readDate === null || !isSameDay(
- item.history.updatedAt.toEpochMilli(),
- readDate!!.toEpochMilli()
- )
- ) {
- readDate = item.history.updatedAt
-
- list.add(
- RowEntity(
- type = RowEntityType.Header,
- key = "header-${readDate}",
- itemModel = null,
- day = readDate!!,
- )
- )
- }
- list.add(
- RowEntity(
- type = RowEntityType.Item,
- key = "item-${item.manga.id}",
- day = readDate!!,
- itemModel = item
- )
- )
- }
- updateAnimatedItemsState(newList = list.toList().map { it })
- }
-
- Box(
- modifier = Modifier
- .clipToBounds()
- .fillMaxSize(),
- ) {
- val listModifier = Modifier
- .fillMaxWidth()
- .align(Alignment.TopStart)
- .then(
- if (nestedScrollConnection != null)
- Modifier.nestedScroll(nestedScrollConnection)
- else Modifier
- )
- history.let {
- if (it == null) {
- LoadingScreen(Modifier.padding(padding))
- } else if (it.isEmpty()) {
- EmptyScreen(
- icon = Icons.Outlined.History,
- title = R.string.empty_history_title,
- description = R.string.empty_history_description
- )
- } else {
- Column(Modifier.fillMaxSize()) {
- LazyColumn(
- modifier = listModifier,
- state = listState,
- contentPadding = PaddingValues(
- top = padding.calculateTopPadding(),
- bottom = padding.calculateBottomPadding() + 96.dp
- ),
- ) {
- animatedItemsIndexed(
- state = animatedList.value,
- key = { rowItem -> rowItem.key },
- ) { _, item ->
- when (item.type) {
- RowEntityType.Header -> ListGroupHeader(
- calculateTimeAgo(item.day).format(
- LocalContext.current.resources
- )
- )
-
- RowEntityType.Item -> SwipeActions(
- startActionsConfig = SwipeActionsConfig(
- threshold = 0.33f,
- background = MaterialTheme.colorScheme.errorContainer,
- backgroundActive = MaterialTheme.colorScheme.error,
- iconTint = MaterialTheme.colorScheme.onError,
- icon = Icons.Outlined.DeleteForever,
- stayDismissed = true,
- onDismiss = {
- viewModel.removeFromHistory(item.itemModel!! as HistoryItemModel)
- }
- ),
- endActionsConfig = SwipeActionsConfig(
- threshold = 0.33f,
- background = MaterialTheme.colorScheme.tertiaryContainer,
- backgroundActive = MaterialTheme.colorScheme.tertiary,
- iconTint = MaterialTheme.colorScheme.onTertiary,
- icon = Icons.Outlined.PlayArrow,
- stayDismissed = false,
- onDismiss = {
- navigateToReader()
- }
- ),
- onTried = { isUserTrySwipe = true },
- ) { state ->
- val size = with(LocalDensity.current) {
- java.lang.Float.max(
- java.lang.Float.min(
- 16.dp.toPx(),
- abs(state.offset.value)
- ), 0f
- ).toDp()
- }
-
- val animateCorners by remember {
- derivedStateOf {
- state.offset.value.absoluteValue > 30
- }
- }
- val startCorners by animateDpAsState(
- targetValue = when {
- state.dismissDirection == DismissDirection.StartToEnd &&
- animateCorners -> 8.dp
-
- else -> 0.dp
- }, label = "startCorners"
- )
- val endCorners by animateDpAsState(
- targetValue = when {
- state.dismissDirection == DismissDirection.EndToStart &&
- animateCorners -> 8.dp
-
- else -> 0.dp
- }, label = "endCorners"
- )
-
- Box(
- modifier = Modifier.height(IntrinsicSize.Min)
- ) {
- Surface(
- modifier = Modifier
- .fillMaxSize()
- .padding(
- vertical = min(
- size / 4f,
- 4.dp
- )
- )
- .clip(RoundedCornerShape(size)),
- color = MaterialTheme.colorScheme.surface,
- shape = RoundedCornerShape(
- topStart = startCorners,
- bottomStart = startCorners,
- topEnd = endCorners,
- bottomEnd = endCorners,
- ),
- ) {
- // nothing
- }
- Box(
- modifier = Modifier.padding(vertical = 4.dp)
- ) {
- HistoryItem(
- coil = coil,
- history = (item.itemModel!! as HistoryItemModel),
- onClick = { navigateToDetails((item.itemModel!! as HistoryItemModel).manga.id) },
- )
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryViewModel.kt
deleted file mode 100644
index 96be18b..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryViewModel.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.xtimms.shirizu.sections.history
-
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.plus
-import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
-import org.xtimms.shirizu.data.repository.HistoryRepository
-import org.xtimms.shirizu.utils.lang.mapItems
-import javax.inject.Inject
-
-@HiltViewModel
-class HistoryViewModel @Inject constructor(
- private val repository: HistoryRepository,
-) : KotatsuBaseViewModel() {
-
- private val historyStateFlow = repository.observeAllWithHistory()
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
-
- val content = historyStateFlow
- .filterNotNull()
- .mapItems { HistoryItemModel(it.manga, it.history) }
- .distinctUntilChanged()
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
-
- fun removeFromHistory(history: HistoryItemModel) {
- launchJob(Dispatchers.Default) {
- repository.delete(history.manga)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/SwipeActions.kt b/app/src/main/java/org/xtimms/shirizu/sections/history/SwipeActions.kt
index 6ea5ed1..0b48241 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/history/SwipeActions.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/history/SwipeActions.kt
@@ -23,13 +23,11 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.*
-import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@@ -39,8 +37,6 @@ import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.sqrt
import androidx.compose.ui.unit.min
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.ui.theme.SEED
import org.xtimms.shirizu.ui.theme.ShirizuTheme
data class SwipeActionsConfig(
@@ -68,6 +64,7 @@ val DefaultSwipeActionsConfig = SwipeActionsConfig(
ExperimentalComposeUiApi::class,
)
@Composable
+@Deprecated("Should be removed")
fun SwipeActions(
modifier: Modifier = Modifier,
startActionsConfig: SwipeActionsConfig = DefaultSwipeActionsConfig,
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/LibraryTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/LibraryTab.kt
new file mode 100644
index 0000000..9cd7744
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/LibraryTab.kt
@@ -0,0 +1,48 @@
+package org.xtimms.shirizu.sections.library
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import kotlinx.collections.immutable.persistentListOf
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabbedScreen
+import org.xtimms.shirizu.sections.library.history.historyTab
+import org.xtimms.shirizu.sections.library.shelves.shelvesTab
+import org.xtimms.shirizu.sections.library.updates.updatesTab
+import org.xtimms.shirizu.utils.lang.NoLiftingAppBarScreen
+import org.xtimms.shirizu.utils.lang.Tab
+
+class LibraryTab : Tab, NoLiftingAppBarScreen {
+
+ override val options: TabOptions
+ @Composable
+ get() {
+ val image = Icons.AutoMirrored.Outlined.LibraryBooks
+ return TabOptions(
+ index = 0u,
+ title = stringResource(R.string.nav_library),
+ icon = rememberVectorPainter(image),
+ )
+ }
+
+ @Composable
+ override fun Content() {
+ val context = LocalContext.current
+
+ TabbedScreen(
+ titleRes = R.string.nav_library,
+ tabs = persistentListOf(
+ historyTab(),
+ shelvesTab(),
+ updatesTab()
+ ),
+ startIndex = 0, // TODO maybe customizable
+ searchQuery = "",
+ onChangeSearchQuery = { },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItem.kt
similarity index 70%
rename from app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItem.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItem.kt
index 64bab2d..6a65513 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItem.kt
@@ -1,6 +1,8 @@
-package org.xtimms.shirizu.sections.history
+package org.xtimms.shirizu.sections.library.history
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@@ -15,19 +17,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import coil.ImageLoader
import org.xtimms.shirizu.core.components.MangaCover
+import org.xtimms.shirizu.utils.composable.selectedBackground
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryItem(
- coil: ImageLoader,
history: HistoryItemModel,
+ selected: Boolean,
onClick: () -> Unit,
+ onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
- .clickable(onClick = onClick)
+ .selectedBackground(selected)
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ )
.height(IntrinsicSize.Max)
.padding(
horizontal = 16.dp,
@@ -36,7 +44,6 @@ fun HistoryItem(
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Book(
- coil = coil,
modifier = Modifier.height(96.dp),
data = history.manga.coverUrl,
)
@@ -62,13 +69,15 @@ fun HistoryItem(
overflow = TextOverflow.Ellipsis
)
}
- Text(
- text = history.manga.tags.joinToString(separator = ", ") { it.title },
- modifier = Modifier.padding(top = 4.dp),
- style = MaterialTheme.typography.bodySmall,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
+ if (history.manga.tags.isNotEmpty()) {
+ Text(
+ text = history.manga.tags.joinToString(separator = ", ") { it.title },
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItemModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItemModel.kt
similarity index 82%
rename from app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItemModel.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItemModel.kt
index 1404b94..8c68040 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/history/HistoryItemModel.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryItemModel.kt
@@ -1,4 +1,4 @@
-package org.xtimms.shirizu.sections.history
+package org.xtimms.shirizu.sections.library.history
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.core.model.ListModel
@@ -7,6 +7,7 @@ import org.xtimms.shirizu.core.model.MangaHistory
data class HistoryItemModel(
val manga: Manga,
val history: MangaHistory,
+ val selected: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreen.kt
new file mode 100644
index 0000000..4c92531
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreen.kt
@@ -0,0 +1,229 @@
+package org.xtimms.shirizu.sections.library.history
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.FastScrollLazyColumn
+import org.xtimms.shirizu.core.components.FilterSortPanel
+import org.xtimms.shirizu.core.components.ListGroupHeader
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.SearchTextField
+import org.xtimms.shirizu.core.components.SortChip
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.NSFW_HISTORY
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.utils.lang.calculateTimeAgo
+import org.xtimms.shirizu.utils.system.plus
+import java.time.Instant
+
+@Composable
+fun HistoryScreen(
+ state: HistoryScreenModel.State,
+ snackbarHostState: SnackbarHostState,
+ contentPadding: PaddingValues,
+ onToggleEnableNsfw: (Boolean) -> Unit,
+ onFilterChanged: (String) -> Unit,
+ onSortSelected: (SortOption) -> Unit,
+ onClick: (HistoryItemModel) -> Unit,
+ onHistorySelected: (HistoryItemModel, Boolean, Boolean, Boolean) -> Unit,
+) {
+ when {
+ state.isLoading -> LoadingScreen(
+ Modifier.padding(contentPadding)
+ )
+ /*state.isEmpty -> EmptyScreen(
+ icon = Icons.Outlined.History,
+ title = R.string.empty_history_title,
+ description = R.string.empty_history_description,
+ modifier = Modifier.padding(contentPadding),
+ )*/
+
+ else -> {
+ HistoryScreenContent(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ contentPadding = contentPadding + PaddingValues(top = 8.dp),
+ onToggleEnableNsfw = onToggleEnableNsfw,
+ onFilterChanged = onFilterChanged,
+ onSortSelected = onSortSelected,
+ onClick = onClick,
+ onHistorySelected = onHistorySelected
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HistoryScreenContent(
+ state: HistoryScreenModel.State,
+ snackbarHostState: SnackbarHostState,
+ contentPadding: PaddingValues,
+ onToggleEnableNsfw: (Boolean) -> Unit,
+ onFilterChanged: (String) -> Unit,
+ onSortSelected: (SortOption) -> Unit,
+ onClick: (HistoryItemModel) -> Unit,
+ onHistorySelected: (HistoryItemModel, Boolean, Boolean, Boolean) -> Unit,
+) {
+
+ var filterExpanded by remember { mutableStateOf(false) }
+ var isNsfwInHistoryEnabled by remember { mutableStateOf(AppSettings.showNsfwInHistory()) }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ ) {
+ FastScrollLazyColumn(
+ contentPadding = contentPadding,
+ ) {
+ item {
+ var filter by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue(""))
+ }
+ FilterSortPanel(
+ filterIcon = {
+ IconButton(onClick = { filterExpanded = true }) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null, // FIXME
+ )
+ }
+ },
+ filterTextField = {
+ SearchTextField(
+ value = filter,
+ onValueChange = { value ->
+ filter = value
+ onFilterChanged(value.text)
+ },
+ hint = stringResource(id = R.string.search_by_reading_history),
+ modifier = Modifier.fillMaxWidth(),
+ onCleared = {
+ filter = TextFieldValue()
+ onFilterChanged("")
+ filterExpanded = false
+ },
+ )
+ },
+ filterExpanded = filterExpanded,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ ) {
+ FilterChip(
+ selected = isNsfwInHistoryEnabled,
+ leadingIcon = {
+ AnimatedVisibility(visible = isNsfwInHistoryEnabled) {
+ Icon(
+ imageVector = Icons.Default.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ onClick = {
+ isNsfwInHistoryEnabled = !isNsfwInHistoryEnabled
+ AppSettings.updateValue(NSFW_HISTORY, isNsfwInHistoryEnabled)
+ onToggleEnableNsfw(isNsfwInHistoryEnabled)
+ },
+ label = {
+ Text(text = stringResource(id = R.string.show_nsfw))
+ },
+ )
+
+ SortChip(
+ sortOptions = state.availableSorts,
+ currentSortOption = state.sort,
+ onSortSelected = onSortSelected,
+ )
+ }
+ }
+
+ historyUiItems(
+ uiModels = state.getUiModel(),
+ selectionMode = state.selectionMode,
+ onHistorySelected = onHistorySelected,
+ onClickHistory = onClick,
+ )
+ }
+ }
+}
+
+internal fun LazyListScope.historyUiItems(
+ uiModels: List,
+ selectionMode: Boolean,
+ onHistorySelected: (HistoryItemModel, Boolean, Boolean, Boolean) -> Unit,
+ onClickHistory: (HistoryItemModel) -> Unit,
+) {
+ items(
+ items = uiModels,
+ contentType = {
+ when (it) {
+ is HistoryUiModel.Header -> "header"
+ is HistoryUiModel.Item -> "item"
+ }
+ },
+ key = {
+ when (it) {
+ is HistoryUiModel.Header -> "updatesHeader-${it.hashCode()}"
+ is HistoryUiModel.Item -> "updates-${it.item.manga.id}"
+ }
+ },
+ ) { item ->
+ when (item) {
+ is HistoryUiModel.Header -> {
+ ListGroupHeader(
+ modifier = Modifier.animateItem(),
+ text = calculateTimeAgo(item.date).format(
+ LocalContext.current.resources
+ ),
+ )
+ }
+ is HistoryUiModel.Item -> {
+ val historyItem = item.item
+ HistoryItem(
+ modifier = Modifier.animateItem(),
+ history = historyItem,
+ selected = historyItem.selected,
+ onLongClick = {
+ onHistorySelected(historyItem, !historyItem.selected, true, true)
+ },
+ onClick = {
+ when {
+ selectionMode -> onHistorySelected(historyItem, !historyItem.selected, true, false)
+ else -> onClickHistory(historyItem)
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+sealed interface HistoryUiModel {
+ data class Header(val date: Instant) : HistoryUiModel
+ data class Item(val item: HistoryItemModel) : HistoryUiModel
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreenModel.kt
new file mode 100644
index 0000000..dc17502
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryScreenModel.kt
@@ -0,0 +1,267 @@
+package org.xtimms.shirizu.sections.library.history
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastMap
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.mutate
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.model.MangaHistory
+import org.xtimms.shirizu.core.model.MangaWithHistory
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.data.repository.HistoryRepository
+import org.xtimms.shirizu.utils.lang.addOrRemove
+import org.xtimms.shirizu.utils.lang.combine
+import org.xtimms.shirizu.utils.lang.insertSeparators
+import org.xtimms.shirizu.utils.lang.isSameDay
+import javax.inject.Inject
+
+@OptIn(FlowPreview::class)
+class HistoryScreenModel @Inject constructor(
+ private val historyRepository: HistoryRepository,
+) : StateScreenModel(State()) {
+
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+
+ private val selectedPositions: Array = arrayOf(-1, -1)
+ private val selectedMangaIds: HashSet = HashSet()
+
+ init {
+ val queryFilter: (String) -> ((MangaWithHistory) -> Boolean) = { query ->
+ filter@{ mwh ->
+ if (query.isEmpty()) return@filter true
+ query.split(",").any { _input ->
+ val input = _input.trim()
+ if (input.isEmpty()) return@any false
+ mwh.manga.title.contains(input, ignoreCase = true)
+ }
+ }
+ }
+ screenModelScope.launch {
+ combine(
+ historyRepository.observeAllWithHistory().distinctUntilChanged()
+ .catch { _events.send(Event.InternalError) },
+ state.map { it.searchQuery }.distinctUntilChanged().debounce(150L),
+ state.map { it.showNsfw }.distinctUntilChanged(),
+ state.map { it.sort }.distinctUntilChanged()
+ ) { history, query, nsfw, sort ->
+ val searchQuery = query ?: ""
+ history.asSequence().map { it }
+ .filter { it.manga.isNsfw == nsfw }
+ .sortedByDescending {
+ when (sort) {
+ SortOption.DATE_ADDED -> it.history.updatedAt
+ SortOption.ALPHABETICAL -> it.manga.title.lowercase()
+ }.toString()
+ }
+ .filter(queryFilter(searchQuery)).toList()
+ .toImmutableList()
+ }.collectLatest {
+ mutableState.update { state ->
+ state.copy(
+ isLoading = false,
+ list = it.toHistoryItemModels(),
+ )
+ }
+ }
+ }
+ }
+
+ private fun List.toHistoryItemModels(): PersistentList {
+ return this
+ .map { history ->
+ HistoryItemModel(
+ manga = history.manga,
+ history = history.history,
+ selected = history.manga.id in selectedMangaIds,
+ )
+ }
+ .toPersistentList()
+ }
+
+ fun search(query: String?) {
+ mutableState.update {
+ it.copy(searchQuery = query)
+ }
+ }
+
+ fun sort(sort: SortOption) {
+ mutableState.update {
+ it.copy(sort = sort)
+ }
+ }
+
+ fun filterNsfw(enabled: Boolean) {
+ mutableState.update {
+ it.copy(showNsfw = enabled)
+ }
+ }
+
+ fun removeFromHistory(ids: Set) {
+ if (ids.isEmpty()) {
+ return
+ }
+ screenModelScope.launch(Dispatchers.Default) {
+ historyRepository.delete(ids)
+ _events.send(Event.HistoryCleared)
+ }
+ }
+
+ fun removeFromHistory(manga: Manga) {
+ screenModelScope.launch(Dispatchers.Default) {
+ historyRepository.delete(manga)
+ _events.send(Event.HistoryCleared)
+ }
+ }
+
+ fun openDeleteMangaDialog() {
+ val mangaList = state.value.selection.map { it }
+ mutableState.update { it.copy(dialog = Dialog.Delete(mangaList)) }
+ }
+
+ fun toggleSelection(
+ item: HistoryItemModel,
+ selected: Boolean,
+ userSelected: Boolean = false,
+ fromLongPress: Boolean = false,
+ ) {
+ mutableState.update { state ->
+ val newItems = state.list.toMutableList().apply {
+ val selectedIndex = indexOfFirst { it.manga.id == item.manga.id }
+ if (selectedIndex < 0) return@apply
+
+ val selectedItem = get(selectedIndex)
+ if (selectedItem.selected == selected) return@apply
+
+ val firstSelection = none { it.selected }
+ set(selectedIndex, selectedItem.copy(selected = selected))
+ selectedMangaIds.addOrRemove(item.manga.id, selected)
+
+ if (selected && userSelected && fromLongPress) {
+ if (firstSelection) {
+ selectedPositions[0] = selectedIndex
+ selectedPositions[1] = selectedIndex
+ } else {
+ // Try to select the items in-between when possible
+ val range: IntRange
+ if (selectedIndex < selectedPositions[0]) {
+ range = selectedIndex + 1.. selectedPositions[1]) {
+ range = (selectedPositions[1] + 1).. selectedPositions[1]) {
+ selectedPositions[1] = selectedIndex
+ }
+ }
+ }
+ }
+ state.copy(list = newItems.toPersistentList())
+ }
+ }
+
+ fun clearSelection() {
+ mutableState.update { it.copy(selection = persistentListOf()) }
+ }
+
+ fun closeDialog() {
+ mutableState.update { it.copy(dialog = null) }
+ }
+
+ @Immutable
+ data class State(
+ val isLoading: Boolean = true,
+ val searchQuery: String? = null,
+ val selection: PersistentList = persistentListOf(),
+ val showNsfw: Boolean = AppSettings.showNsfwInHistory(),
+ val availableSorts: List = listOf(
+ SortOption.DATE_ADDED,
+ SortOption.ALPHABETICAL
+ ),
+ val sort: SortOption = SortOption.ALPHABETICAL,
+ val list: PersistentList = persistentListOf(),
+ val dialog: Dialog? = null,
+ ) {
+ val isEmpty = list.isEmpty()
+
+ val selected = list.filter { it.selected }
+ val selectionMode = selected.isNotEmpty()
+
+ fun getUiModel(): List {
+ return list
+ .map { HistoryUiModel.Item(it) }
+ .takeIf { sort == SortOption.DATE_ADDED }
+ ?.insertSeparators { before, after ->
+ val beforeDate = before?.item?.history?.createdAt
+ val afterDate = after?.item?.history?.createdAt
+ when {
+ beforeDate != afterDate && afterDate != null -> HistoryUiModel.Header(
+ afterDate
+ )
+ // Return null to avoid adding a separator between two items.
+ else -> null
+ }
+ }
+ ?: list.map { HistoryUiModel.Item(it) }
+ }
+ }
+
+ sealed interface Dialog {
+ data object DeleteAll : Dialog
+ data class Delete(val history: List) : Dialog
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ data object HistoryCleared : Event
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryTab.kt
new file mode 100644
index 0000000..a984bb6
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/HistoryTab.kt
@@ -0,0 +1,166 @@
+package org.xtimms.shirizu.sections.library.history
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ClearAll
+import androidx.compose.material.icons.outlined.PlayArrow
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.xtimms.shirizu.MainScreen
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.DialogCheckBoxItem
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.LibraryBottomActionMenu
+import org.xtimms.shirizu.core.components.ShirizuDialog
+import org.xtimms.shirizu.core.ui.screens.TabContent
+import org.xtimms.shirizu.sections.details.DetailsScreen
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun Screen.historyTab(): TabContent {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ return TabContent(
+ titleRes = R.string.history,
+ content = { contentPadding, snackbarHostState ->
+
+ Scaffold(
+ bottomBar = {
+ LibraryBottomActionMenu(
+ visible = state.selectionMode,
+ onDeleteClicked = screenModel::openDeleteMangaDialog,
+ )
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(onClick = { }
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.PlayArrow,
+ contentDescription = stringResource(R.string.continue_reading)
+ )
+ Text(
+ text = stringResource(R.string.continue_reading),
+ modifier = Modifier.padding(start = 16.dp, end = 8.dp)
+ )
+ }
+ }
+ ) {
+ HistoryScreen(
+ state = state,
+ snackbarHostState = snackbarHostState,
+ contentPadding = contentPadding,
+ onToggleEnableNsfw = { screenModel.filterNsfw(it) },
+ onFilterChanged = { screenModel.search(it) },
+ onSortSelected = { screenModel.sort(it) },
+ onClick = { navigator.push(DetailsScreen(it.manga)) },
+ onHistorySelected = screenModel::toggleSelection
+ )
+ }
+
+ val onDismissRequest = screenModel::closeDialog
+
+ when (val dialog = state.dialog) {
+ is HistoryScreenModel.Dialog.Delete -> run {
+ HistoryDeleteDialog(
+ onDismissRequest = onDismissRequest,
+ onDelete = {
+ screenModel.removeFromHistory(dialog.history.mapToSet { it.id })
+ },
+ )
+ }
+
+ is HistoryScreenModel.Dialog.DeleteAll -> {
+
+ }
+
+ null -> {}
+ }
+
+ BackHandler(enabled = state.selectionMode || state.searchQuery != null) {
+ when {
+ state.selectionMode -> screenModel.clearSelection()
+ state.searchQuery != null -> screenModel.search(null)
+ }
+ }
+
+ LaunchedEffect(state.selectionMode, state.dialog) {
+ MainScreen.showBottomNav(!state.selectionMode)
+ }
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { event ->
+ when (event) {
+ HistoryScreenModel.Event.InternalError -> {
+ launch { snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured)) }
+ }
+
+ HistoryScreenModel.Event.HistoryCleared -> {
+ launch { snackbarHostState.showSnackbar(context.resources.getString(R.string.history_cleared)) }
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun HistoryDeleteDialog(
+ onDismissRequest: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ ShirizuDialog(
+ title = {
+ Text(text = stringResource(R.string.action_delete))
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(text = stringResource(R.string.delete_from_history_summary))
+ }
+ },
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(onClick = {
+ onDelete()
+ onDismissRequest()
+ }) {
+ Text(text = stringResource(R.string.remove))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(text = stringResource(R.string.dismiss))
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/history/SortOption.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/history/SortOption.kt
new file mode 100644
index 0000000..17552f1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/history/SortOption.kt
@@ -0,0 +1,6 @@
+package org.xtimms.shirizu.sections.library.history
+
+enum class SortOption {
+ ALPHABETICAL,
+ DATE_ADDED,
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelfItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelfItem.kt
new file mode 100644
index 0000000..0d3f389
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelfItem.kt
@@ -0,0 +1,98 @@
+package org.xtimms.shirizu.sections.library.shelves
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.absoluteOffset
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowForward
+import androidx.compose.material3.Card
+import androidx.compose.material3.HorizontalDivider
+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.pluralStringResource
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ShirizuAsyncImage
+
+@Composable
+fun ShelfItem(
+ onClick: () -> Unit,
+ categoryTitle: String,
+ numberOfFavourites: Int,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .height(IntrinsicSize.Max)
+ .padding(
+ PaddingValues(
+ top = 16.dp,
+ start = 16.dp,
+ end = 16.dp
+ )
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Box {
+ Card(
+ modifier = Modifier
+ .height(64.dp)
+ .width(48.dp)
+ .absoluteOffset(
+ y = 8.dp
+ ),
+ shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
+ content = {}
+ )
+ Card(
+ modifier = Modifier
+ .aspectRatio(2 / 3f)
+ .width(48.dp),
+ shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
+ ) {
+ ShirizuAsyncImage(
+ model = "https://avatars.githubusercontent.com/u/61558546?v=4",
+ contentDescription = null
+ )
+ }
+ }
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = categoryTitle)
+ Text(text = pluralStringResource(
+ id = R.plurals.mangas,
+ numberOfFavourites,
+ numberOfFavourites
+ ), style = MaterialTheme.typography.bodySmall)
+ }
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
+ }
+}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreen.kt
new file mode 100644
index 0000000..6667404
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreen.kt
@@ -0,0 +1,65 @@
+package org.xtimms.shirizu.sections.library.shelves
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material.icons.outlined.LocalLibrary
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.FastScrollLazyColumn
+import org.xtimms.shirizu.core.model.FavouriteCategory
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+
+@Composable
+fun ShelvesScreen(
+ state: ShelvesScreenModel.State,
+ contentPadding: PaddingValues,
+) {
+ when {
+ state.isLoading -> LoadingScreen(
+ Modifier.padding(contentPadding)
+ )
+ state.isEmpty -> EmptyScreen(
+ icon = Icons.Outlined.LocalLibrary,
+ title = R.string.empty_history_title,
+ description = R.string.empty_history_description,
+ modifier = Modifier.padding(contentPadding),
+ )
+
+ else -> {
+ ShelvesScreenContent(
+ categories = state.list,
+ count = state.mangaCount,
+ contentPadding = contentPadding,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ShelvesScreenContent(
+ categories: List,
+ count: Int,
+ contentPadding: PaddingValues,
+) {
+ FastScrollLazyColumn(
+ contentPadding = contentPadding,
+ ) {
+ items(
+ items = categories,
+ key = { "category-${it.hashCode()}" },
+ ) { item ->
+ ShelfItem(
+ modifier = Modifier.animateItem(),
+ categoryTitle = item.title,
+ numberOfFavourites = count,
+ onClick = { },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreenModel.kt
new file mode 100644
index 0000000..a52c811
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesScreenModel.kt
@@ -0,0 +1,89 @@
+package org.xtimms.shirizu.sections.library.shelves
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.core.model.FavouriteCategory
+import org.xtimms.shirizu.data.repository.FavouritesRepository
+import org.xtimms.shirizu.sections.library.history.HistoryItemModel
+import javax.inject.Inject
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ShelvesScreenModel @Inject constructor(
+ private val favouritesRepository: FavouritesRepository,
+) : StateScreenModel(State()) {
+
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+
+ init {
+ screenModelScope.launch {
+ state.flatMapLatest {
+ favouritesRepository.observeCategories()
+ .distinctUntilChanged()
+ .catch {
+ _events.send(Event.InternalError)
+ }
+ .map { it }
+ .flowOn(Dispatchers.IO)
+ }
+ .collect { newList ->
+ mutableState.update {
+ it.copy(
+ isLoading = false,
+ list = newList.toImmutableList(),
+ )
+ }
+ }
+ }
+
+ screenModelScope.launch {
+ state.flatMapLatest {
+ favouritesRepository.observeMangaCount()
+ .distinctUntilChanged()
+ .map { it }
+ .flowOn(Dispatchers.IO)
+ }.collect { count ->
+ mutableState.update {
+ it.copy(
+ mangaCount = count
+ )
+ }
+ }
+ }
+ }
+
+ @Immutable
+ data class State(
+ val isLoading: Boolean = true,
+ val searchQuery: String? = null,
+ val list: ImmutableList = persistentListOf(),
+ val mangaCount: Int = 0,
+ val dialog: Dialog? = null,
+ ) {
+ val isEmpty = list.isEmpty()
+ }
+
+ sealed interface Dialog {
+ data class Delete(val history: HistoryItemModel) : Dialog
+ }
+
+ sealed interface Event {
+ data object InternalError : Event
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesTab.kt
new file mode 100644
index 0000000..44c0755
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/shelves/ShelvesTab.kt
@@ -0,0 +1,45 @@
+package org.xtimms.shirizu.sections.library.shelves
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabContent
+
+@Composable
+fun Screen.shelvesTab(): TabContent {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ return TabContent(
+ titleRes = R.string.shelves,
+ content = { contentPadding, snackbarHostState ->
+ ShelvesScreen(
+ state = state,
+ contentPadding = contentPadding
+ )
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { event ->
+ when (event) {
+ ShelvesScreenModel.Event.InternalError -> {
+ launch { snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured)) }
+ }
+ }
+ }
+ }
+ }
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/library/updates/UpdatesTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/library/updates/UpdatesTab.kt
new file mode 100644
index 0000000..be5cf95
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/library/updates/UpdatesTab.kt
@@ -0,0 +1,18 @@
+package org.xtimms.shirizu.sections.library.updates
+
+import androidx.compose.runtime.Composable
+import cafe.adriel.voyager.core.screen.Screen
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.TabContent
+
+@Composable
+fun Screen.updatesTab(): TabContent {
+
+ return TabContent(
+ titleRes = R.string.updates,
+ content = { contentPadding, snackbarHostState ->
+
+ }
+ )
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaList.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaList.kt
new file mode 100644
index 0000000..9c728f1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaList.kt
@@ -0,0 +1,74 @@
+package org.xtimms.shirizu.sections.list
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import kotlinx.coroutines.flow.StateFlow
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.shirizu.core.components.MangaGridItem
+import org.xtimms.shirizu.utils.system.plus
+
+@Composable
+fun MangaList(
+ mangaList: List,
+ columns: GridCells,
+ contentPadding: PaddingValues,
+ onMangaClick: (Manga) -> Unit,
+ onMangaLongClick: (Manga) -> Unit,
+) {
+ LazyVerticalGrid(
+ columns = columns,
+ contentPadding = contentPadding + PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(count = mangaList.size) { index ->
+ val manga = mangaList[index]
+ MangaItem(
+ manga = manga,
+ onClick = { onMangaClick(manga) },
+ onLongClick = { onMangaLongClick(manga) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun MangaItem(
+ manga: Manga,
+ onClick: () -> Unit = {},
+ onLongClick: () -> Unit = onClick,
+) {
+ MangaGridItem(
+ manga = manga,
+ title = manga.title,
+ coverAlpha = if (manga.isNsfw) 0.3f else 1f,
+ onLongClick = onLongClick,
+ onClick = onClick,
+ )
+}
+
+@Composable
+internal fun MangaListLoadingItem() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListContent.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListContent.kt
new file mode 100644
index 0000000..422af02
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListContent.kt
@@ -0,0 +1,67 @@
+package org.xtimms.shirizu.sections.list
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.StateFlow
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.core.ui.screens.EmptyScreenAction
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.utils.system.getDisplayMessage
+
+@Composable
+fun MangaListContent(
+ mangaList: List,
+ columns: GridCells,
+ snackbarHostState: SnackbarHostState,
+ contentPadding: PaddingValues,
+ onMangaClick: (Manga) -> Unit,
+ onMangaLongClick: (Manga) -> Unit,
+) {
+ val context = LocalContext.current
+
+ val getErrorMessage: (LoadState.Error) -> String = { state ->
+ state.error.getDisplayMessage(context.resources)
+ }
+
+ if (mangaList.isEmpty()) {
+ EmptyScreen(
+ icon = Icons.Outlined.ErrorOutline,
+ modifier = Modifier.padding(contentPadding),
+ message = "",
+ summary = "",
+ actions = persistentListOf(
+ EmptyScreenAction(
+ stringRes = R.string.action_retry,
+ icon = Icons.Outlined.Refresh,
+ onClick = { },
+ ),
+ )
+ )
+
+ return
+ }
+
+ MangaList(
+ mangaList = mangaList,
+ contentPadding = contentPadding,
+ onMangaClick = onMangaClick,
+ onMangaLongClick = onMangaLongClick,
+ columns = columns
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreen.kt
new file mode 100644
index 0000000..6067d79
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreen.kt
@@ -0,0 +1,83 @@
+package org.xtimms.shirizu.sections.list
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.sections.details.DetailsScreen
+import org.xtimms.shirizu.utils.lang.Screen
+
+@OptIn(ExperimentalMaterial3Api::class)
+data class MangaListScreen(private val source: MangaSource) : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+
+ val scope = rememberCoroutineScope()
+ val haptic = LocalHapticFeedback.current
+ val uriHandler = LocalUriHandler.current
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ val screenModel = getScreenModel { factory ->
+ factory.create(source.name)
+ }
+ val state by screenModel.state.collectAsState()
+
+ Scaffold(
+ topBar = {
+ Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
+ BrowseSourceToolbar(
+ searchQuery = state.toolbarQuery,
+ onSearchQueryChange = { },
+ source = screenModel.source,
+ navigateUp = navigator::pop,
+ onWebViewClick = { },
+ onSearch = { },
+ )
+
+ Row(
+ modifier = Modifier
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+
+ }
+ }
+ },
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ ) { paddingValues ->
+ MangaListContent(
+ mangaList = state.list,
+ columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation),
+ snackbarHostState = snackbarHostState,
+ contentPadding = paddingValues,
+ onMangaClick = { navigator.push((DetailsScreen(it, true))) },
+ onMangaLongClick = { manga -> },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreenModel.kt
new file mode 100644
index 0000000..4fa06e9
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListScreenModel.kt
@@ -0,0 +1,87 @@
+package org.xtimms.shirizu.sections.list
+
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.hilt.ScreenModelFactory
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.getAndUpdate
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.parser.MangaRepository
+
+class MangaListScreenModel @AssistedInject constructor(
+ @Assisted sourceName: String,
+ mangaRepositoryFactory: MangaRepository.Factory,
+) : StateScreenModel(State()) {
+
+ val source = MangaSource.valueOf(sourceName)
+ private val repository = mangaRepositoryFactory.create(source)
+ private val hasNextPage = MutableStateFlow(false)
+ private val mangaList = MutableStateFlow?>(null)
+
+ init {
+ screenModelScope.launch(Dispatchers.Default) {
+ state.distinctUntilChangedBy { it.loadMore }
+ .filter { it.loadMore }
+ .collectLatest { uiState ->
+ val list = repository.getList(
+ offset = mangaList.value?.size ?: 0,
+ filter = null,
+ )
+ val oldList = mangaList.getAndUpdate { oldList ->
+ if (oldList.isNullOrEmpty()) {
+ list
+ } else {
+ oldList + list
+ }
+ }.orEmpty()
+ hasNextPage.value = list.size > oldList.size || hasNextPage.value
+ mutableState.update {
+ it.copy(
+ list = list,
+ loadMore = hasNextPage.value,
+ isLoading = false
+ )
+ }
+ }
+ }
+ }
+
+ fun getColumnsPreference(orientation: Int): GridCells {
+ return GridCells.Fixed(3)
+ }
+
+ sealed interface Dialog {
+ data object Filter : Dialog
+ data class RemoveManga(val manga: Manga) : Dialog
+ data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
+ data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
+ }
+
+ @Immutable
+ data class State(
+ val list: List = listOf(),
+ val toolbarQuery: String? = null,
+ val dialog: Dialog? = null,
+ val loadMore: Boolean = true,
+ val isLoading: Boolean = false,
+ ) {
+
+ }
+
+ @AssistedFactory
+ interface Factory : ScreenModelFactory {
+ fun create(sourceName: String): MangaListScreenModel
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListToolbar.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListToolbar.kt
new file mode 100644
index 0000000..ab7c225
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListToolbar.kt
@@ -0,0 +1,56 @@
+package org.xtimms.shirizu.sections.list
+
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.res.stringResource
+import kotlinx.collections.immutable.persistentListOf
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.AppBar
+import org.xtimms.shirizu.core.components.AppBarActions
+import org.xtimms.shirizu.core.components.AppBarTitle
+import org.xtimms.shirizu.core.components.SearchToolbar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BrowseSourceToolbar(
+ searchQuery: String?,
+ onSearchQueryChange: (String?) -> Unit,
+ source: MangaSource?,
+ navigateUp: () -> Unit,
+ onWebViewClick: () -> Unit,
+ onSearch: (String) -> Unit,
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ // Avoid capturing unstable source in actions lambda
+ val title = source?.title
+
+ SearchToolbar(
+ navigateUp = navigateUp,
+ titleContent = { AppBarTitle(title) },
+ searchQuery = searchQuery,
+ onChangeSearchQuery = onSearchQueryChange,
+ onSearch = onSearch,
+ onClickCloseSearch = navigateUp,
+ actions = {
+ AppBarActions(
+ actions = persistentListOf().builder()
+ .apply {
+ add(
+ AppBar.OverflowAction(
+ title = stringResource(R.string.open_in_browser),
+ onClick = onWebViewClick,
+ ),
+ )
+ }
+ .build(),
+ )
+ },
+ scrollBehavior = scrollBehavior,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListView.kt
deleted file mode 100644
index 432c5fc..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListView.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-package org.xtimms.shirizu.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.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-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.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.only
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.systemBars
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.lazy.grid.rememberLazyGridState
-import androidx.compose.material3.CircularProgressIndicator
-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.platform.LocalContext
-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.Manga
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shirizu.core.components.MangaGridItem
-import org.xtimms.shirizu.core.components.ScaffoldWithSmallTopAppBarWithChips
-import org.xtimms.shirizu.utils.composable.onBottomReached
-import org.xtimms.shirizu.utils.system.toast
-
-const val PROVIDER_ARGUMENT = "{source}"
-const val LIST_DESTINATION = "provider/${PROVIDER_ARGUMENT}"
-
-@Composable
-fun MangaListView(
- coil: ImageLoader,
- source: MangaSource,
- navigateBack: () -> Unit,
- navigateToDetails: (Long) -> Unit,
-) {
- val viewModel: MangaListViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-
- MangaListViewContent(
- coil = coil,
- source = source,
- uiState = uiState,
- event = viewModel,
- navigateBack = navigateBack,
- navigateToDetails = navigateToDetails
- )
-}
-
-@Composable
-private fun MangaListViewContent(
- coil: ImageLoader,
- source: MangaSource,
- uiState: MangaListUiState,
- event: MangaListEvent?,
- navigateBack: () -> Unit,
- navigateToDetails: (Long) -> Unit,
-) {
- val context = LocalContext.current
-
- if (uiState.message != null) {
- LaunchedEffect(uiState.message) {
- context.toast(uiState.message)
- event?.onMessageDisplayed()
- }
- }
-
- ScaffoldWithSmallTopAppBarWithChips(
- title = source.title,
- 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)
- ) { padding ->
- val listState = rememberLazyGridState()
- listState.onBottomReached(buffer = 5) {
- event?.loadMore()
- }
- Box(
- modifier = Modifier
- .padding(padding),
- contentAlignment = Alignment.Center
- ) {
- AnimatedVisibility(
- visible = uiState.isLoading,
- exit = fadeOut(),
- ) {
- 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
- ) {
- val onClickManga = { manga: Manga ->
- navigateToDetails(manga.id)
- }
- MangaGridItem(
- coil = coil,
- manga = item,
- onClick = onClickManga,
- onLongClick = { },
- )
- }
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListViewModel.kt
deleted file mode 100644
index f73727d..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/list/MangaListViewModel.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-package org.xtimms.shirizu.sections.list
-
-import androidx.lifecycle.SavedStateHandle
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.distinctUntilChangedBy
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.getAndUpdate
-import kotlinx.coroutines.flow.update
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shirizu.core.base.viewmodel.BaseViewModel
-import org.xtimms.shirizu.core.parser.MangaRepository
-import org.xtimms.shirizu.utils.lang.call
-import org.xtimms.shirizu.utils.lang.removeFirstAndLast
-import org.xtimms.shirizu.utils.lang.require
-import javax.inject.Inject
-import kotlin.coroutines.cancellation.CancellationException
-
-@HiltViewModel
-class MangaListViewModel @Inject constructor(
- savedStateHandle: SavedStateHandle,
- mangaRepositoryFactory: MangaRepository.Factory,
-) : BaseViewModel(), MangaListEvent {
-
- private var loadingJob: Job? = null
-
- val source = MangaSource.valueOf(savedStateHandle.get(PROVIDER_ARGUMENT.removeFirstAndLast())!!)
- private val repository = mangaRepositoryFactory.create(source)
- private val mangaList = MutableStateFlow?>(null)
- private val listError = MutableStateFlow(null)
- private val hasNextPage = MutableStateFlow(false)
-
- override val mutableUiState = MutableStateFlow(MangaListUiState())
-
- init {
- setLoading(true)
- launchLoadingJob(Dispatchers.Default) {
- mutableUiState
- .distinctUntilChangedBy { it.loadMore }
- .filter { it.loadMore }
- .collectLatest { uiState ->
- val list = repository.getList(
- offset = mangaList.value?.size ?: 0,
- filter = null,
- )
- val oldList = mangaList.getAndUpdate { oldList ->
- if (oldList.isNullOrEmpty()) {
- list
- } else {
- oldList + list
- }
- }.orEmpty()
- hasNextPage.value = list.size > oldList.size || hasNextPage.value
- mutableUiState.update {
- it.copy(
- manga = list,
- nextPage = "2",
- loadMore = hasNextPage.value,
- isLoading = false
- )
- }
- }
- }
- }
-
- protected fun loadList(append: Boolean): Job {
- loadingJob?.let {
- if (it.isActive) return it
- }
- return launchLoadingJob(Dispatchers.Default) {
- try {
- listError.value = null
- val list = repository.getList(
- offset = if (append) mangaList.value?.size ?: 0 else 0,
- filter = null,
- )
- val oldList = mangaList.getAndUpdate { oldList ->
- if (!append || oldList.isNullOrEmpty()) {
- list
- } else {
- oldList + list
- }
- }.orEmpty()
- hasNextPage.value = if (append) {
- list.isNotEmpty()
- } else {
- list.size > oldList.size || hasNextPage.value
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Throwable) {
- listError.value = e
- if (!mangaList.value.isNullOrEmpty()) {
- errorEvent.call(e)
- }
- hasNextPage.value = false
- }
- }.also { loadingJob = it }
- }
-
- fun loadNextPage() {
- if (hasNextPage.value && listError.value == null) {
- loadList(append = true)
- }
- }
-
- override fun loadMore() {
- if (mutableUiState.value.canLoadMore) {
- mutableUiState.update { it.copy(loadMore = true) }
- }
- }
-
- override fun showMessage(message: String?) {
- TODO("Not yet implemented")
- }
-
- override fun onMessageDisplayed() {
- TODO("Not yet implemented")
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/onboarding/OnboardingScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..66bcf3b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/onboarding/OnboardingScreen.kt
@@ -0,0 +1,31 @@
+package org.xtimms.shirizu.sections.onboarding
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.core.onboarding.OnboardingScreen
+import org.xtimms.shirizu.utils.lang.Screen
+
+class OnboardingScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+
+ val finishOnboarding: () -> Unit = {
+ navigator.pop()
+ }
+
+ BackHandler(
+ enabled = true,
+ onBack = {
+ // Prevent exiting if onboarding hasn't been completed
+ },
+ )
+
+ OnboardingScreen(
+ onComplete = finishOnboarding
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/reader/ReaderViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/reader/ReaderViewModel.kt
index 31bcaf2..d6a8c80 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/reader/ReaderViewModel.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/reader/ReaderViewModel.kt
@@ -5,7 +5,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel
import org.xtimms.shirizu.core.parser.MangaDataRepository
import org.xtimms.shirizu.core.parser.MangaIntent
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/SearchScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchScreenModel.kt
new file mode 100644
index 0000000..624aa9c
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchScreenModel.kt
@@ -0,0 +1,67 @@
+package org.xtimms.shirizu.sections.search
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.xtimms.shirizu.data.repository.SuggestionRepository
+import org.xtimms.shirizu.work.suggestions.SuggestionsWorker
+import javax.inject.Inject
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SearchScreenModel @Inject constructor(
+ private val suggestionRepository: SuggestionRepository,
+ private val suggestionsScheduler: SuggestionsWorker.Scheduler,
+) : StateScreenModel(State()) {
+
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+
+ init {
+ screenModelScope.launch {
+ state.flatMapLatest {
+ suggestionRepository.observeAll()
+ .distinctUntilChanged()
+ .map { it }
+ .filterNotNull()
+ .flowOn(Dispatchers.IO)
+ }.collect { newList ->
+ mutableState.update {
+ it.copy(isLoading = false, list = newList.take(10))
+ }
+ }
+ }
+ }
+
+ fun updateSuggestions() {
+ screenModelScope.launch(Dispatchers.IO) {
+ suggestionsScheduler.startNow()
+ _events.send(Event.GettingSuggestions)
+ }
+ }
+
+ @Immutable
+ data class State(
+ var isLoading: Boolean = true,
+ val list: List = emptyList(),
+ )
+
+ sealed interface Event {
+ data object GettingSuggestions : Event
+ data object InternalError : Event
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTab.kt
new file mode 100644
index 0000000..e18dcd1
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTab.kt
@@ -0,0 +1,168 @@
+package org.xtimms.shirizu.sections.search
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+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.lazy.LazyColumn
+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.SdCard
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import kotlinx.coroutines.flow.collectLatest
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.MangaCarouselWithHeader
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.icons.Dice
+import org.xtimms.shirizu.sections.details.DetailsScreen
+import org.xtimms.shirizu.sections.search.global.GlobalSearchScreen
+import org.xtimms.shirizu.sections.suggestions.SuggestionsScreen
+import org.xtimms.shirizu.utils.composable.bodyWidth
+import org.xtimms.shirizu.utils.lang.Tab
+
+object SearchTab : Tab {
+
+ private val snackbarHostState = SnackbarHostState()
+
+ override val options: TabOptions
+ @Composable
+ get() {
+ val image = Icons.Outlined.Search
+ return TabOptions(
+ index = 4u,
+ title = stringResource(R.string.search),
+ icon = rememberVectorPainter(image),
+ )
+ }
+
+ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ val categories = listOf(
+ SearchTabItemModel(
+ id = 1,
+ icon = Icons.Outlined.SdCard,
+ title = stringResource(id = R.string.local_storage)
+ ),
+ SearchTabItemModel(
+ id = 2,
+ icon = Icons.Outlined.Bookmarks,
+ title = stringResource(id = R.string.bookmarks)
+ ),
+ SearchTabItemModel(
+ id = 3,
+ icon = Icons.Outlined.Dice,
+ title = stringResource(id = R.string.random)
+ ),
+ SearchTabItemModel(
+ id = 4,
+ icon = Icons.Outlined.Download,
+ title = stringResource(id = R.string.downloads)
+ ),
+ )
+
+ Scaffold(
+ snackbarHost = { snackbarHostState }
+ ) {
+ LazyColumn(
+ modifier = Modifier.bodyWidth(),
+ ) {
+ item(key = "search") {
+ Card(
+ onClick = { navigator.push(GlobalSearchScreen) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(72.dp)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(50),
+ colors = CardDefaults.cardColors()
+ .copy(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)),
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxHeight(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Search,
+ contentDescription = "search",
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = stringResource(R.string.search),
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ item(key = "carousel") {
+ MangaCarouselWithHeader(
+ items = state.list,
+ title = stringResource(id = R.string.suggestions),
+ onItemClick = { navigator.push(DetailsScreen(it)) },
+ onMoreClick = { navigator.push(SuggestionsScreen) },
+ refreshing = state.isLoading,
+ modifier = Modifier.animateItem(),
+ )
+ }
+ item(key = "categories") {
+ SearchTabItemWithHeader(
+ items = categories,
+ title = stringResource(id = R.string.categories),
+ refreshing = false,
+ onItemClick = { }
+ )
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { e ->
+ when (e) {
+ SearchScreenModel.Event.GettingSuggestions -> {
+ state.isLoading = true
+ snackbarHostState.showSnackbar(context.resources.getString(R.string.suggestions_updating))
+ }
+ SearchScreenModel.Event.InternalError ->
+ snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured))
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTabItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTabItem.kt
new file mode 100644
index 0000000..24e984d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchTabItem.kt
@@ -0,0 +1,115 @@
+package org.xtimms.shirizu.sections.search
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.Icon
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Terminal
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.LocalContentColor
+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.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xtimms.shirizu.core.components.Header
+import org.xtimms.shirizu.ui.theme.ShirizuTheme
+
+@Composable
+fun SearchTabItemWithHeader(
+ items: List,
+ title: String,
+ refreshing: Boolean,
+ onItemClick: (SearchTabItemModel) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ if (refreshing || items.isNotEmpty()) {
+ Header(
+ title = title,
+ loading = refreshing,
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ )
+ }
+ if (items.isNotEmpty()) {
+ SearchTabItem(
+ items = items,
+ onItemClick = onItemClick,
+ modifier = Modifier
+ .testTag("search_carousel")
+ .fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Composable
+fun SearchTabItem(
+ items: List,
+ onItemClick: (SearchTabItemModel) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column {
+ for (item in items) {
+ Row(
+ modifier
+ .height(54.dp)
+ .padding(horizontal = 16.dp)
+ .clickable {
+ onItemClick(item)
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = item.icon,
+ contentDescription = item.title,
+ tint = LocalContentColor.current
+ )
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ HorizontalDivider(modifier.padding(start = 64.dp, end = 16.dp))
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SearchTabItemPreview() {
+ ShirizuTheme {
+ SearchTabItemWithHeader(
+ items = listOf(
+ SearchTabItemModel(id = 1, icon = Icons.Outlined.Terminal, title = "Action 1"),
+ SearchTabItemModel(id = 2, icon = Icons.Outlined.Terminal, title = "Action 2")
+ ),
+ onItemClick = { },
+ refreshing = false,
+ title = "Category"
+ )
+ }
+}
+
+data class SearchTabItemModel(
+ val id: Int,
+ val icon: ImageVector,
+ val title: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/SearchView.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/SearchView.kt
deleted file mode 100644
index 9cb8192..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/search/SearchView.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-package org.xtimms.shirizu.sections.search
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-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
-import androidx.compose.material3.TextField
-import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-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.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.BackIconButton
-import org.xtimms.shirizu.core.ui.screens.EmptyScreen
-import org.xtimms.shirizu.ui.theme.ShirizuTheme
-
-const val SEARCH_DESTINATION = "search"
-
-@Composable
-fun SearchHostView(
- padding: PaddingValues,
- isCompactScreen: Boolean,
- navigateBack: () -> Unit,
-) {
- var query by remember { mutableStateOf("") }
- val performSearch = remember { mutableStateOf(false) }
- val focusRequester = remember { FocusRequester() }
- val keyboardController = LocalSoftwareKeyboardController.current
-
- LaunchedEffect(focusRequester) {
- focusRequester.requestFocus()
- }
-
- Column(
- modifier = Modifier
- .statusBarsPadding()
- .padding(top = padding.calculateTopPadding())
- .fillMaxWidth()
- ) {
- TextField(
- value = query,
- onValueChange = { query = it },
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester)
- .height(64.dp),
- placeholder = { Text(text = stringResource(R.string.search)) },
- leadingIcon = {
- if (isCompactScreen) BackIconButton(onClick = navigateBack)
- },
- keyboardActions = KeyboardActions(
- onSearch = {
- keyboardController?.hide()
- }
- ),
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
- singleLine = true,
- colors = TextFieldDefaults.colors(
- focusedContainerColor = Color.Transparent,
- unfocusedContainerColor = Color.Transparent,
- focusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant,
- unfocusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant
- )
- )
- SearchView(
- query = query,
- performSearch = performSearch,
- showAsGrid = !isCompactScreen,
- contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
- )
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun SearchView(
- query: String,
- performSearch: MutableState,
- showAsGrid: Boolean,
- contentPadding: PaddingValues = PaddingValues(),
-) {
- val context = LocalContext.current
-
- EmptyScreen(
- icon = Icons.Outlined.SearchOff,
- title = R.string.nothing_found,
- description = R.string.nothing_found_summary
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun SearchPreview() {
- ShirizuTheme {
- SearchHostView(
- isCompactScreen = true,
- padding = PaddingValues(),
- navigateBack = {},
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreen.kt
new file mode 100644
index 0000000..464954d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreen.kt
@@ -0,0 +1,104 @@
+package org.xtimms.shirizu.sections.search.global
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.BackIconButton
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.utils.lang.Screen
+
+object GlobalSearchScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ var query by remember { mutableStateOf("") }
+ val performSearch = remember { mutableStateOf(false) }
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val navigator = LocalNavigator.currentOrThrow
+
+ LaunchedEffect(focusRequester) {
+ focusRequester.requestFocus()
+ }
+
+ Column(
+ modifier = Modifier
+ .statusBarsPadding()
+ .fillMaxWidth()
+ ) {
+ TextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .height(64.dp),
+ placeholder = { Text(text = stringResource(R.string.search)) },
+ leadingIcon = {
+ BackIconButton(navigator::pop)
+ },
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ keyboardController?.hide()
+ }
+ ),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant,
+ unfocusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant
+ )
+ )
+ SearchView(
+ query = query,
+ performSearch = performSearch,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchView(
+ query: String,
+ performSearch: MutableState,
+) {
+ val context = LocalContext.current
+
+ EmptyScreen(
+ icon = Icons.Outlined.SearchOff,
+ title = R.string.nothing_found,
+ description = R.string.nothing_found_summary
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreenModel.kt
new file mode 100644
index 0000000..aa47be3
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchScreenModel.kt
@@ -0,0 +1,112 @@
+package org.xtimms.shirizu.sections.search.global
+
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.plus
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.data.repository.MangaSearchRepository
+import org.xtimms.shirizu.data.repository.MangaSourcesRepository
+import org.xtimms.shirizu.sections.search.global.model.SearchSuggestionItem
+import org.xtimms.shirizu.utils.lang.sizeOrZero
+import org.xtimms.shirizu.utils.lang.toEnumSet
+import javax.inject.Inject
+
+private const val DEBOUNCE_TIMEOUT = 500L
+private const val MAX_MANGA_ITEMS = 12
+private const val MAX_QUERY_ITEMS = 16
+private const val MAX_HINTS_ITEMS = 3
+private const val MAX_AUTHORS_ITEMS = 2
+private const val MAX_TAGS_ITEMS = 8
+private const val MAX_SOURCES_ITEMS = 6
+
+class GlobalSearchScreenModel @Inject constructor(
+ private val repository: MangaSearchRepository,
+ private val sourcesRepository: MangaSourcesRepository,
+) : StateScreenModel(State()) {
+
+ private val query = MutableStateFlow("")
+ private var suggestionJob: Job? = null
+ private var invalidateOnResume = false
+
+ val suggestion = MutableStateFlow>(emptyList())
+
+ init {
+ setupSuggestion()
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun setupSuggestion() {
+
+ }
+
+ private suspend fun buildSearchSuggestion(
+ searchQuery: String,
+ enabledSources: Set,
+ types: Set,
+ ): List = coroutineScope {
+ val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) {
+ async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) }
+ } else {
+ null
+ }
+ val hintsDeferred = if (SearchSuggestionType.QUERIES_SUGGEST in types) {
+ async { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) }
+ } else {
+ null
+ }
+ val authorsDeferred = if (SearchSuggestionType.AUTHORS in types) {
+ async { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) }
+ } else {
+ null
+ }
+ val tagsDeferred = if (SearchSuggestionType.GENRES in types) {
+ async { repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) }
+ } else {
+ null
+ }
+ val mangaDeferred = if (SearchSuggestionType.MANGA in types) {
+ async { repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) }
+ } else {
+ null
+ }
+ val sources = if (SearchSuggestionType.SOURCES in types) {
+ repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
+ } else {
+ null
+ }
+
+ val tags = tagsDeferred?.await()
+ val mangaList = mangaDeferred?.await()
+ val queries = queriesDeferred?.await()
+ val hints = hintsDeferred?.await()
+ val authors = authorsDeferred?.await()
+
+ buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
+ if (!mangaList.isNullOrEmpty()) {
+ add(SearchSuggestionItem.MangaList(mangaList))
+ }
+ sources?.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) }
+ queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
+ authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
+ hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
+ }
+ }
+
+ @Immutable
+ data class State(
+ var isLoading: Boolean = true,
+ val hints: List? = null,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchSuggestionType.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchSuggestionType.kt
new file mode 100644
index 0000000..81c4237
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/global/GlobalSearchSuggestionType.kt
@@ -0,0 +1,16 @@
+package org.xtimms.shirizu.sections.search.global
+
+import androidx.annotation.StringRes
+import org.xtimms.shirizu.R
+
+enum class SearchSuggestionType(
+ @StringRes val titleResId: Int,
+) {
+
+ GENRES(R.string.genres),
+ QUERIES_RECENT(R.string.recent_queries),
+ QUERIES_SUGGEST(R.string.suggested_queries),
+ MANGA(R.string.content_type_manga),
+ SOURCES(R.string.remote_sources),
+ AUTHORS(R.string.authors),
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/global/MangaSuggestionsProvider.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/global/MangaSuggestionsProvider.kt
new file mode 100644
index 0000000..a850ec2
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/global/MangaSuggestionsProvider.kt
@@ -0,0 +1,34 @@
+package org.xtimms.shirizu.sections.search.global
+
+import android.app.SearchManager
+import android.content.ContentResolver
+import android.content.Context
+import android.content.SearchRecentSuggestionsProvider
+import android.net.Uri
+import android.provider.SearchRecentSuggestions
+import org.xtimms.shirizu.BuildConfig
+
+class MangaSuggestionsProvider : SearchRecentSuggestionsProvider() {
+
+ init {
+ setupSuggestions(AUTHORITY, MODE)
+ }
+
+ companion object {
+
+ private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.MangaSuggestionsProvider"
+ private const val MODE = DATABASE_MODE_QUERIES
+
+ fun createSuggestions(context: Context): SearchRecentSuggestions {
+ return SearchRecentSuggestions(context, AUTHORITY, MODE)
+ }
+
+ val QUERY_URI: Uri = Uri.Builder()
+ .scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(AUTHORITY)
+ .appendPath(SearchManager.SUGGEST_URI_PATH_QUERY)
+ .build()
+
+ val URI: Uri = Uri.parse("content://$AUTHORITY/suggestions")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/search/global/model/SearchSuggestionItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/search/global/model/SearchSuggestionItem.kt
new file mode 100644
index 0000000..ddeb930
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/search/global/model/SearchSuggestionItem.kt
@@ -0,0 +1,58 @@
+package org.xtimms.shirizu.sections.search.global.model
+
+import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.xtimms.shirizu.core.model.ListModel
+
+sealed interface SearchSuggestionItem : ListModel {
+
+ data class MangaList(
+ val items: List,
+ ) : SearchSuggestionItem {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is MangaList
+ }
+ }
+
+ data class RecentQuery(
+ val query: String,
+ ) : SearchSuggestionItem {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is RecentQuery && query == other.query
+ }
+ }
+
+ data class Hint(
+ val query: String,
+ ) : SearchSuggestionItem {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Hint && query == other.query
+ }
+ }
+
+ data class Author(
+ val name: String,
+ ) : SearchSuggestionItem {
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Author && name == other.name
+ }
+ }
+
+ data class Source(
+ val source: MangaSource,
+ val isEnabled: Boolean,
+ ) : SearchSuggestionItem {
+
+ val isNsfw: Boolean
+ get() = source.contentType == ContentType.HENTAI
+
+ override fun areItemsTheSame(other: ListModel): Boolean {
+ return other is Source && other.source == source
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsScreen.kt
new file mode 100644
index 0000000..9ea9858
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsScreen.kt
@@ -0,0 +1,248 @@
+package org.xtimms.shirizu.sections.settings
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.PowerManager
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+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.fillMaxSize
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+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.Extension
+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.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.TopAppBarDefaults
+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.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.BackIconButton
+import org.xtimms.shirizu.core.components.PreferencesHintCard
+import org.xtimms.shirizu.core.components.SettingItem
+import org.xtimms.shirizu.core.components.SettingTitle
+import org.xtimms.shirizu.core.components.SmallTopAppBar
+import org.xtimms.shirizu.sections.settings.about.AboutScreen
+import org.xtimms.shirizu.sections.settings.advanced.AdvancedScreen
+import org.xtimms.shirizu.sections.settings.appearance.AppearanceScreen
+import org.xtimms.shirizu.sections.settings.backup.BackupRestoreScreen
+import org.xtimms.shirizu.sections.settings.network.NetworkScreen
+import org.xtimms.shirizu.sections.settings.services.ServicesScreen
+import org.xtimms.shirizu.sections.settings.shelf.ShelfSettingsScreen
+import org.xtimms.shirizu.sections.settings.storage.StorageScreen
+import org.xtimms.shirizu.sections.settings.storage.StorageScreenModel
+import org.xtimms.shirizu.utils.FileSize
+import org.xtimms.shirizu.utils.lang.Screen
+
+@SuppressLint("BatteryLife")
+@OptIn(ExperimentalMaterial3Api::class)
+object SettingsScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+
+ val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ var showBatteryHint by remember {
+ mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
+ }
+
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
+ val intent =
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${context.packageName}")
+ }
+
+ val isActivityAvailable: Boolean =
+ if (Build.VERSION.SDK_INT < 33) context.packageManager.queryIntentActivities(
+ intent,
+ PackageManager.MATCH_ALL
+ ).isNotEmpty()
+ else context.packageManager.queryIntentActivities(
+ intent,
+ PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_SYSTEM_ONLY.toLong())
+ ).isNotEmpty()
+
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName)
+ }
+
+ val screenModel = getScreenModel()
+ val state by screenModel.state.collectAsState()
+
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ SmallTopAppBar(
+ titleText = stringResource(id = R.string.settings),
+ navigationIcon = {
+ BackIconButton(navigator::pop)
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ SettingTitle(text = stringResource(id = R.string.settings))
+ }
+ item {
+ AnimatedVisibility(
+ visible = showBatteryHint && isActivityAvailable,
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ PreferencesHintCard(
+ title = stringResource(R.string.disable_battery_optimization),
+ icon = Icons.Outlined.BatterySaver,
+ description = stringResource(R.string.disable_battery_optimization_summary),
+ ) {
+ launcher.launch(intent)
+ showBatteryHint =
+ !pm.isIgnoringBatteryOptimizations(context.packageName)
+ }
+ }
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.appearance),
+ description = stringResource(id = R.string.appearance_page),
+ icon = Icons.Outlined.Palette,
+ onClick = { navigator.push(AppearanceScreen) }
+ )
+ }
+ /*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),*//*
+ description = "TODO",
+ icon = Icons.Outlined.CollectionsBookmark,
+ onClick = { }
+ )
+ }*/
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.nav_shelf),
+ description = stringResource(id = R.string.shelf_page),
+ icon = Icons.Outlined.LocalLibrary,
+ onClick = { navigator.push(ShelfSettingsScreen) }
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.services),
+ description = stringResource(id = R.string.services_page),
+ icon = Icons.Outlined.Extension,
+ onClick = { navigator.push(ServicesScreen) }
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.backup_and_restore),
+ description = "TODO",
+ icon = Icons.Outlined.SettingsBackupRestore,
+ onClick = { navigator.push(BackupRestoreScreen) }
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.network),
+ description = stringResource(id = R.string.network_page),
+ icon = Icons.Outlined.Wifi,
+ onClick = { navigator.push(NetworkScreen) }
+ )
+ }
+ item {
+ val allCaches = state.httpCacheSize +
+ state.pagesCache +
+ state.thumbnailsCache
+ val desc = buildString {
+ append((allCaches / state.availableSpace) * 100)
+ append(context.getString(R.string.space_used))
+ append(" - ")
+ append(
+ FileSize.BYTES.freeFormat(
+ context,
+ (state.availableSpace -
+ state.httpCacheSize -
+ state.pagesCache -
+ state.thumbnailsCache).toFloat()
+ )
+ )
+ }
+ SettingItem(
+ title = stringResource(id = R.string.storage),
+ description = desc,
+ icon = Icons.Outlined.Storage,
+ onClick = { navigator.push(StorageScreen) }
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.advanced),
+ description = stringResource(id = R.string.advanced_page),
+ icon = Icons.Outlined.Code,
+ onClick = { navigator.push(AdvancedScreen) }
+ )
+ }
+ item {
+ SettingItem(
+ title = stringResource(id = R.string.about),
+ description = stringResource(id = R.string.about_page),
+ icon = Icons.Outlined.Info,
+ onClick = { navigator.push(AboutScreen) }
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsView.kt
deleted file mode 100644
index c5db7c5..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/SettingsView.kt
+++ /dev/null
@@ -1,248 +0,0 @@
-package org.xtimms.shirizu.sections.settings
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.PowerManager
-import android.provider.Settings
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkVertically
-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.fillMaxSize
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.only
-import androidx.compose.foundation.layout.padding
-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.Extension
-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.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.TopAppBarDefaults
-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.input.nestedscroll.nestedScroll
-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.shirizu.R
-import org.xtimms.shirizu.core.components.BackIconButton
-import org.xtimms.shirizu.core.components.PreferencesHintCard
-import org.xtimms.shirizu.core.components.SettingItem
-import org.xtimms.shirizu.core.components.SettingTitle
-import org.xtimms.shirizu.core.components.SmallTopAppBar
-import org.xtimms.shirizu.utils.FileSize
-
-const val SETTINGS_DESTINATION = "settings"
-
-@OptIn(ExperimentalMaterial3Api::class)
-@SuppressLint("BatteryLife")
-@Composable
-fun SettingsView(
- viewModel: SettingsViewModel = hiltViewModel(),
- navigateBack: () -> Unit,
- navigateToAppearance: () -> Unit,
- navigateToAbout: () -> Unit,
- navigateToAdvanced: () -> Unit,
- navigateToBackupRestoreSettings: () -> Unit,
- navigateToMangaSources: () -> Unit,
- navigateToNetwork: () -> Unit,
- navigateToServicesSettings: () -> Unit,
- navigateToShelfSettings: () -> Unit,
- navigateToStorage: () -> Unit
-) {
-
- val context = LocalContext.current
-
- 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 {
- mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
- }
-
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
-
- val intent =
- Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
- data = Uri.parse("package:${context.packageName}")
- }
-
- val isActivityAvailable: Boolean =
- if (Build.VERSION.SDK_INT < 33) context.packageManager.queryIntentActivities(
- intent,
- PackageManager.MATCH_ALL
- ).isNotEmpty()
- else context.packageManager.queryIntentActivities(
- intent,
- PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_SYSTEM_ONLY.toLong())
- ).isNotEmpty()
-
- val launcher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName)
- }
-
- Scaffold(
- modifier = Modifier
- .fillMaxSize()
- .nestedScroll(scrollBehavior.nestedScrollConnection),
- topBar = {
- SmallTopAppBar(
- titleText = stringResource(id = R.string.settings),
- navigationIcon = {
- BackIconButton {
- navigateBack()
- }
- },
- scrollBehavior = scrollBehavior
- )
- },
- contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- SettingTitle(text = stringResource(id = R.string.settings))
- }
- item {
- AnimatedVisibility(
- visible = showBatteryHint && isActivityAvailable,
- exit = shrinkVertically() + fadeOut()
- ) {
- PreferencesHintCard(
- title = stringResource(R.string.disable_battery_optimization),
- icon = Icons.Outlined.BatterySaver,
- description = stringResource(R.string.disable_battery_optimization_summary),
- ) {
- launcher.launch(intent)
- showBatteryHint =
- !pm.isIgnoringBatteryOptimizations(context.packageName)
- }
- }
- }
- item {
- SettingItem(
- title = stringResource(id = R.string.appearance),
- description = stringResource(id = R.string.appearance_page),
- icon = Icons.Outlined.Palette,
- 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),
- description = stringResource(id = R.string.shelf_page),
- icon = Icons.Outlined.LocalLibrary,
- onClick = navigateToShelfSettings
- )
- }
- item {
- SettingItem(
- title = stringResource(id = R.string.services),
- description = stringResource(id = R.string.services_page),
- icon = Icons.Outlined.Extension,
- onClick = navigateToServicesSettings
- )
- }
- item {
- 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 / state.availableSpace) * 100)
- append(context.getString(R.string.space_used))
- append(" - ")
- append(
- FileSize.BYTES.freeFormat(
- context,
- (state.availableSpace -
- state.httpCacheSize -
- state.pagesCache -
- state.thumbnailsCache).toFloat()
- )
- )
- }
- SettingItem(
- title = stringResource(id = R.string.storage),
- description = desc,
- icon = Icons.Outlined.Storage,
- onClick = navigateToStorage
- )
- }
- item {
- SettingItem(
- title = stringResource(id = R.string.advanced),
- description = stringResource(id = R.string.advanced_page),
- icon = Icons.Outlined.Code,
- onClick = navigateToAdvanced
- )
- }
- item {
- SettingItem(
- title = stringResource(id = R.string.about),
- description = stringResource(id = R.string.about_page),
- icon = Icons.Outlined.Info,
- onClick = navigateToAbout
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutScreen.kt
new file mode 100644
index 0000000..f0b129b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutScreen.kt
@@ -0,0 +1,119 @@
+package org.xtimms.shirizu.sections.settings.about
+
+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.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
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.App
+import org.xtimms.shirizu.App.Companion.packageInfo
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.AUTO_UPDATE
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.system.toast
+
+private const val repoUrl = "https://git.kotatsu.app/Xtimms/Shirizu"
+const val weblate = "https://hosted.weblate.org/engage/shirizu/"
+
+object AboutScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val clipboardManager = LocalClipboardManager.current
+ var isAutoUpdateEnabled by remember { mutableStateOf(AppSettings.isAutoUpdateEnabled()) }
+
+ val info = App.getVersionReport()
+ val versionName = packageInfo.versionName
+
+ var versionClicks by remember { mutableIntStateOf(0) }
+
+ val uriHandler = LocalUriHandler.current
+ fun openUrl(url: String) {
+ uriHandler.openUri(url)
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.about),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceItem(
+ title = stringResource(R.string.readme),
+ description = stringResource(R.string.readme_desc),
+ icon = Icons.Outlined.Description,
+ ) { openUrl(repoUrl) }
+ }
+ item {
+ PreferenceSwitchWithDivider(
+ title = stringResource(R.string.auto_update),
+ description = stringResource(R.string.check_for_updates_desc),
+ icon = if (isAutoUpdateEnabled) Icons.Outlined.Update else Icons.Outlined.UpdateDisabled,
+ isChecked = isAutoUpdateEnabled,
+ onClick = { navigator.push(UpdateScreen) },
+ onChecked = {
+ isAutoUpdateEnabled = !isAutoUpdateEnabled
+ AppSettings.updateValue(AUTO_UPDATE, isAutoUpdateEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.version),
+ description = versionName,
+ icon = Icons.Outlined.Info,
+ onLongClick = {
+ clipboardManager.setText(AnnotatedString(info))
+ context.toast(R.string.info_copied)
+ }
+ ) {
+ if (versionClicks >= 7) {
+ context.toast("✧◝(⁰▿⁰)◜✧")
+ versionClicks = 0
+ } else versionClicks++
+ }
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.open_source_licenses),
+ icon = Icons.Outlined.DeveloperBoard
+ ) {
+ navigator.push(OpenSourceLicensesScreen())
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutView.kt
deleted file mode 100644
index 421ba8f..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/AboutView.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package org.xtimms.shirizu.sections.settings.about
-
-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.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
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-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.LocalClipboardManager
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalUriHandler
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import org.xtimms.shirizu.App
-import org.xtimms.shirizu.App.Companion.packageInfo
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceItem
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.prefs.AUTO_UPDATE
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.utils.system.toast
-
-const val ABOUT_DESTINATION = "about"
-
-private const val repoUrl = "https://git.kotatsu.app/Xtimms/Shirizu"
-const val weblate = "https://hosted.weblate.org/engage/shirizu/"
-
-@Composable
-fun AboutView(
- navigateBack: () -> Unit,
- navigateToLicensesPage: () -> Unit,
- navigateToUpdatePage: () -> Unit,
-) {
-
- val context = LocalContext.current
- val clipboardManager = LocalClipboardManager.current
- var isAutoUpdateEnabled by remember { mutableStateOf(AppSettings.isAutoUpdateEnabled()) }
-
- val info = App.getVersionReport()
- val versionName = packageInfo.versionName
-
- var versionClicks by remember { mutableIntStateOf(0) }
-
- val uriHandler = LocalUriHandler.current
- fun openUrl(url: String) {
- uriHandler.openUri(url)
- }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.about),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- PreferenceItem(
- title = stringResource(R.string.readme),
- description = stringResource(R.string.readme_desc),
- icon = Icons.Outlined.Description,
- ) { openUrl(repoUrl) }
- }
- item {
- PreferenceSwitchWithDivider(
- title = stringResource(R.string.auto_update),
- description = stringResource(R.string.check_for_updates_desc),
- icon = if (isAutoUpdateEnabled) Icons.Outlined.Update else Icons.Outlined.UpdateDisabled,
- isChecked = isAutoUpdateEnabled,
- onClick = navigateToUpdatePage,
- onChecked = {
- isAutoUpdateEnabled = !isAutoUpdateEnabled
- AppSettings.updateValue(AUTO_UPDATE, isAutoUpdateEnabled)
- }
- )
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.version),
- description = versionName,
- icon = Icons.Outlined.Info,
- onLongClick = {
- clipboardManager.setText(AnnotatedString(info))
- context.toast(R.string.info_copied)
- }
- ) {
- if (versionClicks >= 7) {
- context.toast("✧◝(⁰▿⁰)◜✧")
- versionClicks = 0
- } else versionClicks++
- }
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.open_source_licenses),
- icon = Icons.Outlined.DeveloperBoard
- ) {
- navigateToLicensesPage()
- }
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/LicenseScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/LicenseScreen.kt
new file mode 100644
index 0000000..334044b
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/LicenseScreen.kt
@@ -0,0 +1,87 @@
+package org.xtimms.shirizu.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.filled.Public
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.text.HtmlCompat
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.google.android.material.textview.MaterialTextView
+import kotlinx.collections.immutable.persistentListOf
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.AppBar
+import org.xtimms.shirizu.core.components.AppBarActions
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
+import org.xtimms.shirizu.utils.lang.Screen
+
+@OptIn(ExperimentalMaterial3Api::class)
+class LicenseScreen(
+ private val name: String,
+ private val website: String?,
+ private val license: String,
+) : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val uriHandler = LocalUriHandler.current
+
+ Scaffold(
+ topBar = {
+ AppBar(
+ title = name,
+ navigateUp = navigator::pop,
+ actions = {
+ if (!website.isNullOrEmpty()) {
+ AppBarActions(
+ persistentListOf(
+ AppBar.Action(
+ title = stringResource(R.string.website),
+ icon = Icons.Default.Public,
+ onClick = { uriHandler.openUri(website) },
+ ),
+ ),
+ )
+ }
+ },
+ scrollBehavior = it,
+ )
+ },
+ ) { contentPadding ->
+ Column(
+ modifier = Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(contentPadding)
+ .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/shirizu/sections/settings/about/LicenseView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/LicenseView.kt
deleted file mode 100644
index 995480e..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/LicenseView.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package org.xtimms.shirizu.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.shirizu.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/shirizu/sections/settings/about/OpenSourceLicensesScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/OpenSourceLicensesScreen.kt
new file mode 100644
index 0000000..2622842
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/OpenSourceLicensesScreen.kt
@@ -0,0 +1,48 @@
+package org.xtimms.shirizu.sections.settings.about
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
+import com.mikepenz.aboutlibraries.ui.compose.m3.util.htmlReadyLicenseContent
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.AppBar
+import org.xtimms.shirizu.core.components.Scaffold
+import org.xtimms.shirizu.utils.lang.Screen
+
+@OptIn(ExperimentalMaterial3Api::class)
+class OpenSourceLicensesScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ Scaffold(
+ topBar = { scrollBehavior ->
+ AppBar(
+ title = stringResource(R.string.open_source_licenses),
+ navigateUp = navigator::pop,
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { contentPadding ->
+ LibrariesContainer(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentPadding = contentPadding,
+ onLibraryClick = {
+ navigator.push(
+ LicenseScreen(
+ name = it.library.name,
+ website = it.library.website,
+ license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
+ )
+ )
+ },
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/OpenSourceLicensesView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/OpenSourceLicensesView.kt
deleted file mode 100644
index fd5bc1a..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/OpenSourceLicensesView.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.xtimms.shirizu.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.shirizu.R
-import org.xtimms.shirizu.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/shirizu/sections/settings/about/UpdateScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/UpdateScreen.kt
new file mode 100644
index 0000000..87031ed
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/UpdateScreen.kt
@@ -0,0 +1,204 @@
+package org.xtimms.shirizu.sections.settings.about
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceInfo
+import org.xtimms.shirizu.core.components.PreferenceSingleChoiceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.AUTO_UPDATE
+import org.xtimms.shirizu.core.prefs.AppSettings.updateBoolean
+import org.xtimms.shirizu.core.prefs.AppSettings.updateInt
+import org.xtimms.shirizu.core.prefs.PRE_RELEASE
+import org.xtimms.shirizu.core.prefs.STABLE
+import org.xtimms.shirizu.core.prefs.UPDATE_CHANNEL
+import org.xtimms.shirizu.core.ui.dialogs.UpdateDialog
+import org.xtimms.shirizu.core.updates.Updater
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.booleanState
+import org.xtimms.shirizu.utils.lang.intState
+import org.xtimms.shirizu.utils.system.suspendToast
+
+object UpdateScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var autoUpdate by AUTO_UPDATE.booleanState
+ var updateChannel by UPDATE_CHANNEL.intState
+
+ var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) }
+ var showUpdateDialog by remember { mutableStateOf(false) }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.auto_update),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSwitchWithContainer(
+ title = stringResource(id = R.string.enable_auto_update),
+ icon = null,
+ isChecked = autoUpdate
+ ) {
+ autoUpdate = !autoUpdate
+ AUTO_UPDATE.updateBoolean(autoUpdate)
+ }
+ }
+ item {
+ PreferenceSubtitle(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ text = stringResource(id = R.string.update_channel)
+ )
+ }
+ item {
+ PreferenceSingleChoiceItem(
+ text = stringResource(id = R.string.stable_channel),
+ selected = updateChannel == STABLE,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
+ ) {
+ updateChannel = STABLE
+ UPDATE_CHANNEL.updateInt(updateChannel)
+ }
+ }
+ item {
+ PreferenceSingleChoiceItem(
+ text = stringResource(id = R.string.pre_release_channel),
+ selected = updateChannel == PRE_RELEASE,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
+ ) {
+ updateChannel = PRE_RELEASE
+ UPDATE_CHANNEL.updateInt(updateChannel)
+ }
+ }
+ 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.check_for_updates
+ ),
+ icon = Icons.Outlined.Update,
+ isLoading = isLoading
+ ) {
+ if (!isLoading)
+ scope.launch {
+ runCatching {
+ isLoading = true
+ withContext(Dispatchers.IO) {
+ Updater.checkForUpdate(context)?.let {
+ latestRelease = it
+ showUpdateDialog = true
+ }
+ ?: context.suspendToast(R.string.app_up_to_date)
+ }
+ isLoading = false
+ }
+ .onFailure {
+ it.printStackTrace()
+ context.suspendToast(R.string.app_update_failed)
+ isLoading = false
+ }
+ }
+ }
+ }
+ HorizontalDivider()
+ }
+ item {
+ PreferenceInfo(
+ modifier = Modifier
+ .padding(horizontal = 4.dp),
+ text = stringResource(id = R.string.update_channel_desc)
+ )
+ }
+ }
+ }
+ if (showUpdateDialog)
+ UpdateDialog(onDismissRequest = { showUpdateDialog = false }, latestRelease = latestRelease)
+ }
+}
+
+@Composable
+fun ProgressIndicatorButton(
+ modifier: Modifier = Modifier,
+ isLoading: Boolean = false,
+ text: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+) {
+ FilledTonalButton(
+ modifier = modifier,
+ onClick = onClick,
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding
+ ) {
+ if (isLoading)
+ Box(modifier = Modifier.size(18.dp)) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ .align(Alignment.Center),
+ strokeWidth = 3.dp
+ )
+ }
+ else Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = text,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/UpdateView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/about/UpdateView.kt
deleted file mode 100644
index 3d72c68..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/about/UpdateView.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-package org.xtimms.shirizu.sections.settings.about
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-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.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Update
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.HorizontalDivider
-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.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceInfo
-import org.xtimms.shirizu.core.components.PreferenceSingleChoiceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.prefs.AUTO_UPDATE
-import org.xtimms.shirizu.core.prefs.AppSettings.updateBoolean
-import org.xtimms.shirizu.core.prefs.AppSettings.updateInt
-import org.xtimms.shirizu.core.prefs.PRE_RELEASE
-import org.xtimms.shirizu.core.prefs.STABLE
-import org.xtimms.shirizu.core.prefs.UPDATE_CHANNEL
-import org.xtimms.shirizu.core.ui.dialogs.UpdateDialog
-import org.xtimms.shirizu.core.updates.Updater
-import org.xtimms.shirizu.utils.lang.booleanState
-import org.xtimms.shirizu.utils.lang.intState
-import org.xtimms.shirizu.utils.system.suspendToast
-
-const val UPDATES_DESTINATION = "updates"
-
-@Composable
-fun UpdateView(
- navigateBack: () -> Unit,
-) {
-
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
-
- var autoUpdate by AUTO_UPDATE.booleanState
- var updateChannel by UPDATE_CHANNEL.intState
-
- var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) }
- var showUpdateDialog by remember { mutableStateOf(false) }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.auto_update),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- PreferenceSwitchWithContainer(
- title = stringResource(id = R.string.enable_auto_update),
- icon = null,
- isChecked = autoUpdate
- ) {
- autoUpdate = !autoUpdate
- AUTO_UPDATE.updateBoolean(autoUpdate)
- }
- }
- item {
- PreferenceSubtitle(
- modifier = Modifier.padding(horizontal = 4.dp),
- text = stringResource(id = R.string.update_channel)
- )
- }
- item {
- PreferenceSingleChoiceItem(
- text = stringResource(id = R.string.stable_channel),
- selected = updateChannel == STABLE,
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
- ) {
- updateChannel = STABLE
- UPDATE_CHANNEL.updateInt(updateChannel)
- }
- }
- item {
- PreferenceSingleChoiceItem(
- text = stringResource(id = R.string.pre_release_channel),
- selected = updateChannel == PRE_RELEASE,
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
- ) {
- updateChannel = PRE_RELEASE
- UPDATE_CHANNEL.updateInt(updateChannel)
- }
- }
- 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.check_for_updates
- ),
- icon = Icons.Outlined.Update,
- isLoading = isLoading
- ) {
- if (!isLoading)
- scope.launch {
- runCatching {
- isLoading = true
- withContext(Dispatchers.IO) {
- Updater.checkForUpdate(context)?.let {
- latestRelease = it
- showUpdateDialog = true
- }
- ?: context.suspendToast(R.string.app_up_to_date)
- }
- isLoading = false
- }
- .onFailure {
- it.printStackTrace()
- context.suspendToast(R.string.app_update_failed)
- isLoading = false
- }
- }
- }
- }
- HorizontalDivider()
- }
- item {
- PreferenceInfo(
- modifier = Modifier
- .padding(horizontal = 4.dp),
- text = stringResource(id = R.string.update_channel_desc)
- )
- }
- }
- }
- if (showUpdateDialog)
- UpdateDialog(onDismissRequest = { showUpdateDialog = false }, latestRelease = latestRelease)
-}
-
-@Composable
-fun ProgressIndicatorButton(
- modifier: Modifier = Modifier,
- isLoading: Boolean = false,
- text: String,
- icon: ImageVector,
- onClick: () -> Unit,
-) {
- FilledTonalButton(
- modifier = modifier,
- onClick = onClick,
- contentPadding = ButtonDefaults.ButtonWithIconContentPadding
- ) {
- if (isLoading)
- Box(modifier = Modifier.size(18.dp)) {
- CircularProgressIndicator(
- modifier = Modifier
- .size(16.dp)
- .align(Alignment.Center),
- strokeWidth = 3.dp
- )
- }
- else Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- Text(
- text = text,
- modifier = Modifier.padding(start = 8.dp)
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedScreen.kt
new file mode 100644
index 0000000..1a95a41
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedScreen.kt
@@ -0,0 +1,272 @@
+package org.xtimms.shirizu.sections.settings.advanced
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Build
+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.Print
+import androidx.compose.material.icons.outlined.PrintDisabled
+import androidx.compose.material.icons.outlined.Report
+import androidx.compose.material.icons.outlined.ReportOff
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+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.profileinstaller.ProfileVerifier
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.coroutines.guava.await
+import org.xtimms.shirizu.BuildConfig
+import org.xtimms.shirizu.LocalLoggers
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitch
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.ACRA
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.LOGGING
+import org.xtimms.shirizu.utils.DeviceUtil
+import org.xtimms.shirizu.utils.ShareHelper
+import org.xtimms.shirizu.utils.WebViewUtil
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.toDateTimestampString
+import org.xtimms.shirizu.utils.system.toast
+import java.text.DateFormat
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+object AdvancedScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val loggers = LocalLoggers.current
+
+ var isAcraEnabled by remember {
+ mutableStateOf(AppSettings.isACRAEnabled())
+ }
+
+ var isLoggingEnabled by remember {
+ mutableStateOf(AppSettings.isLoggingEnabled())
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.advanced),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.send_crash_reports),
+ description = stringResource(id = R.string.send_crash_reports_desc),
+ icon = if (isAcraEnabled) Icons.Outlined.Report else Icons.Outlined.ReportOff,
+ isChecked = isAcraEnabled,
+ onClick = {
+ isAcraEnabled = !isAcraEnabled
+ AppSettings.updateValue(ACRA, isAcraEnabled)
+ context.toast(R.string.restart_required)
+ }
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.enable_logging),
+ description = stringResource(id = R.string.enable_logging_desc),
+ icon = if (isLoggingEnabled) Icons.Outlined.Print else Icons.Outlined.PrintDisabled,
+ isChecked = isLoggingEnabled,
+ onClick = {
+ isLoggingEnabled = !isLoggingEnabled
+ AppSettings.updateValue(LOGGING, isLoggingEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.share_logs),
+ icon = Icons.Outlined.Share,
+ enabled = isLoggingEnabled,
+ onClick = {
+ ShareHelper(context).shareLogs(loggers)
+ }
+ )
+ }
+ if (BuildConfig.DEBUG) {
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.debug_info))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.worker_info),
+ onClick = {
+ val intent = Intent()
+ intent.component = ComponentName(
+ context,
+ "org.koitharu.workinspector.WorkInspectorActivity"
+ )
+ context.startActivity(intent)
+ }
+ )
+ }
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.app_info))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.app_version),
+ description = getVersionName(false)
+ )
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.build_time),
+ description = getFormattedBuildTime()
+ )
+ }
+ item {
+ GetProfileVerifierPreference()
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.webview_version),
+ description = getWebViewVersion()
+ )
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.device_info))
+ }
+ item {
+ PreferenceItem(
+ title = "Model",
+ description = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})"
+ )
+ }
+ if (DeviceUtil.oneUiVersion != null) {
+ item {
+ PreferenceItem(
+ title = "OneUI version",
+ description = "${DeviceUtil.oneUiVersion}"
+ )
+ }
+ }
+ if (DeviceUtil.miuiMajorVersion != null) {
+ item {
+ PreferenceItem(
+ title = "MIUI version",
+ description = "${DeviceUtil.miuiMajorVersion}",
+ )
+ }
+ }
+ val androidVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Build.VERSION.RELEASE_OR_PREVIEW_DISPLAY
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Build.VERSION.RELEASE_OR_CODENAME
+ } else {
+ Build.VERSION.RELEASE
+ }
+ item {
+ PreferenceItem(
+ title = "Android version",
+ description = "$androidVersion (${Build.DISPLAY})"
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+private fun getWebViewVersion(): String {
+ return WebViewUtil.getVersion(LocalContext.current)
+}
+
+@Composable
+private fun GetProfileVerifierPreference() {
+ val status by produceState(initialValue = "-") {
+ val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
+ value = when (result) {
+ ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE -> "No profile installed"
+ ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled"
+ ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING ->
+ "Compiled non-matching"
+
+ ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_CACHE_FILE_EXISTS_BUT_CANNOT_BE_READ,
+ ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_CANT_WRITE_PROFILE_VERIFICATION_RESULT_CACHE_FILE,
+ ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_PACKAGE_NAME_DOES_NOT_EXIST,
+ -> "Error $result"
+
+ ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_UNSUPPORTED_API_VERSION -> "Not supported"
+ ProfileVerifier.CompilationStatus.RESULT_CODE_PROFILE_ENQUEUED_FOR_COMPILATION -> "Pending compilation"
+ else -> "Unknown code $result"
+ }
+ }
+ return PreferenceItem(
+ title = "Profile compilation status",
+ description = status,
+ )
+}
+
+fun getVersionName(withBuildDate: Boolean): String {
+ return when {
+ BuildConfig.DEBUG -> {
+ "Debug ${BuildConfig.COMMIT_SHA}".let {
+ if (withBuildDate) {
+ "$it (${getFormattedBuildTime()})"
+ } else {
+ it
+ }
+ }
+ }
+
+ else -> {
+ "Stable ${BuildConfig.VERSION_NAME}".let {
+ if (withBuildDate) {
+ "$it (${getFormattedBuildTime()})"
+ } else {
+ it
+ }
+ }
+ }
+ }
+}
+
+internal fun getFormattedBuildTime(): String {
+ return try {
+ val inputDf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US)
+ inputDf.timeZone = TimeZone.getTimeZone("UTC")
+ val buildTime = inputDf.parse(BuildConfig.BUILD_TIME)
+
+ val outputDf = DateFormat.getDateTimeInstance(
+ DateFormat.MEDIUM,
+ DateFormat.SHORT,
+ Locale.getDefault(),
+ )
+ outputDf.timeZone = TimeZone.getDefault()
+
+ buildTime!!.toDateTimestampString(DateFormat.getDateTimeInstance())
+ } catch (e: Exception) {
+ BuildConfig.BUILD_TIME
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedView.kt
deleted file mode 100644
index 7c36a4c..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/advanced/AdvancedView.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-package org.xtimms.shirizu.sections.settings.advanced
-
-import android.os.Build
-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.Print
-import androidx.compose.material.icons.outlined.PrintDisabled
-import androidx.compose.material.icons.outlined.Report
-import androidx.compose.material.icons.outlined.ReportOff
-import androidx.compose.material.icons.outlined.Share
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.ReadOnlyComposable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
-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.profileinstaller.ProfileVerifier
-import kotlinx.coroutines.guava.await
-import org.xtimms.shirizu.BuildConfig
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitch
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.logs.FileLogger
-import org.xtimms.shirizu.core.prefs.ACRA
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.LOGGING
-import org.xtimms.shirizu.utils.DeviceUtil
-import org.xtimms.shirizu.utils.ShareHelper
-import org.xtimms.shirizu.utils.WebViewUtil
-import org.xtimms.shirizu.utils.lang.toDateTimestampString
-import org.xtimms.shirizu.utils.system.toast
-import java.text.DateFormat
-import java.text.SimpleDateFormat
-import java.util.Locale
-import java.util.TimeZone
-
-const val ADVANCED_DESTINATION = "advanced"
-
-@Composable
-fun AdvancedView(
- loggers: Set,
- navigateBack: () -> Unit,
- navigateToStats: () -> Unit,
-) {
-
- val context = LocalContext.current
-
- var isAcraEnabled by remember {
- mutableStateOf(AppSettings.isACRAEnabled())
- }
-
- var isLoggingEnabled by remember {
- mutableStateOf(AppSettings.isLoggingEnabled())
- }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.advanced),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- /*item {
- PreferenceItem(title = "STATS", onClick = { navigateToStats() } )
- }*/
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.send_crash_reports),
- description = stringResource(id = R.string.send_crash_reports_desc),
- icon = if (isAcraEnabled) Icons.Outlined.Report else Icons.Outlined.ReportOff,
- isChecked = isAcraEnabled,
- onClick = {
- isAcraEnabled = !isAcraEnabled
- AppSettings.updateValue(ACRA, isAcraEnabled)
- context.toast(R.string.restart_required)
- }
- )
- }
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.enable_logging),
- description = stringResource(id = R.string.enable_logging_desc),
- icon = if (isLoggingEnabled) Icons.Outlined.Print else Icons.Outlined.PrintDisabled,
- isChecked = isLoggingEnabled,
- onClick = {
- isLoggingEnabled = !isLoggingEnabled
- AppSettings.updateValue(LOGGING, isLoggingEnabled)
- }
- )
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.share_logs),
- icon = Icons.Outlined.Share,
- enabled = isLoggingEnabled,
- onClick = {
- ShareHelper(context).shareLogs(loggers)
- }
- )
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.app_info))
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.app_version),
- description = getVersionName(false)
- )
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.build_time),
- description = getFormattedBuildTime()
- )
- }
- item {
- getProfileVerifierPreference()
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.webview_version),
- description = getWebViewVersion()
- )
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.device_info))
- }
- item {
- PreferenceItem(
- title = "Model",
- description = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})"
- )
- }
- if (DeviceUtil.oneUiVersion != null) {
- item {
- PreferenceItem(
- title = "OneUI version",
- description = "${DeviceUtil.oneUiVersion}"
- )
- }
- }
- if (DeviceUtil.miuiMajorVersion != null) {
- item {
- PreferenceItem(
- title = "MIUI version",
- description = "${DeviceUtil.miuiMajorVersion}",
- )
- }
- }
- val androidVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- Build.VERSION.RELEASE_OR_PREVIEW_DISPLAY
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- Build.VERSION.RELEASE_OR_CODENAME
- } else {
- Build.VERSION.RELEASE
- }
- item {
- PreferenceItem(
- title = "Android version",
- description = "$androidVersion (${Build.DISPLAY})"
- )
- }
- }
- }
-}
-
-@Composable
-@ReadOnlyComposable
-private fun getWebViewVersion(): String {
- return WebViewUtil.getVersion(LocalContext.current)
-}
-
-@Composable
-private fun getProfileVerifierPreference() {
- val status by produceState(initialValue = "-") {
- val result = ProfileVerifier.getCompilationStatusAsync().await().profileInstallResultCode
- value = when (result) {
- ProfileVerifier.CompilationStatus.RESULT_CODE_NO_PROFILE -> "No profile installed"
- ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE -> "Compiled"
- ProfileVerifier.CompilationStatus.RESULT_CODE_COMPILED_WITH_PROFILE_NON_MATCHING ->
- "Compiled non-matching"
-
- ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_CACHE_FILE_EXISTS_BUT_CANNOT_BE_READ,
- ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_CANT_WRITE_PROFILE_VERIFICATION_RESULT_CACHE_FILE,
- ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_PACKAGE_NAME_DOES_NOT_EXIST,
- -> "Error $result"
-
- ProfileVerifier.CompilationStatus.RESULT_CODE_ERROR_UNSUPPORTED_API_VERSION -> "Not supported"
- ProfileVerifier.CompilationStatus.RESULT_CODE_PROFILE_ENQUEUED_FOR_COMPILATION -> "Pending compilation"
- else -> "Unknown code $result"
- }
- }
- return PreferenceItem(
- title = "Profile compilation status",
- description = status,
- )
-}
-
-fun getVersionName(withBuildDate: Boolean): String {
- return when {
- BuildConfig.DEBUG -> {
- "Debug ${BuildConfig.COMMIT_SHA}".let {
- if (withBuildDate) {
- "$it (${getFormattedBuildTime()})"
- } else {
- it
- }
- }
- }
-
- else -> {
- "Stable ${BuildConfig.VERSION_NAME}".let {
- if (withBuildDate) {
- "$it (${getFormattedBuildTime()})"
- } else {
- it
- }
- }
- }
- }
-}
-
-internal fun getFormattedBuildTime(): String {
- return try {
- val inputDf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US)
- inputDf.timeZone = TimeZone.getTimeZone("UTC")
- val buildTime = inputDf.parse(BuildConfig.BUILD_TIME)
-
- val outputDf = DateFormat.getDateTimeInstance(
- DateFormat.MEDIUM,
- DateFormat.SHORT,
- Locale.getDefault(),
- )
- outputDf.timeZone = TimeZone.getDefault()
-
- buildTime!!.toDateTimestampString(DateFormat.getDateTimeInstance())
- } catch (e: Exception) {
- BuildConfig.BUILD_TIME
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceScreen.kt
similarity index 51%
rename from app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceView.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceScreen.kt
index 999dad1..f61ce99 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceView.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/AppearanceScreen.kt
@@ -57,6 +57,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.android.material.color.DynamicColors
import org.xtimms.shirizu.LocalDarkTheme
@@ -85,178 +87,177 @@ import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.shirizu.ui.monet.a1
import org.xtimms.shirizu.ui.monet.a2
import org.xtimms.shirizu.ui.monet.a3
+import org.xtimms.shirizu.utils.lang.Screen
import org.xtimms.shirizu.utils.system.toDisplayName
import java.util.Locale
-const val APPEARANCE_DESTINATION = "appearance"
-
val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) }
-@Composable
-fun AppearanceView(
- navigateBack: () -> Unit,
- navigateToDarkTheme: () -> Unit,
- navigateToLanguages: () -> Unit
-) {
- val localDensity = LocalDensity.current
+object AppearanceScreen : Screen() {
- var isModernViewEnabled by remember {
- mutableStateOf(AppSettings.isModernViewEnabled())
- }
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val localDensity = LocalDensity.current
+
+ var isModernViewEnabled by remember {
+ mutableStateOf(AppSettings.isModernViewEnabled())
+ }
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.appearance),
- navigateBack = navigateBack
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .verticalScroll(rememberScrollState()),
- ) {
- Card(
- modifier = Modifier.padding(18.dp)
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.appearance),
+ navigateBack = navigator::pop
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .verticalScroll(rememberScrollState()),
) {
- var headerSize by remember { mutableStateOf(Size(0.dp, 0.dp)) }
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .onGloballyPositioned {
- headerSize = Size(
- width = with(localDensity) { it.size.width.toDp() },
- height = with(localDensity) { it.size.height.toDp() }
- )
- },
- contentAlignment = Alignment.Center,
+ Card(
+ modifier = Modifier.padding(18.dp)
) {
- val halfWidth = headerSize.width / 2
- val halfHeight = headerSize.height / 2
+ var headerSize by remember { mutableStateOf(Size(0.dp, 0.dp)) }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .onGloballyPositioned {
+ headerSize = Size(
+ width = with(localDensity) { it.size.width.toDp() },
+ height = with(localDensity) { it.size.height.toDp() }
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ val halfWidth = headerSize.width / 2
+ val halfHeight = headerSize.height / 2
- val angleStar1 by rememberInfiniteTransition("angleStar1").animateFloat(
- label = "angleStar1",
- initialValue = -20f,
- targetValue = 20f,
- animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Reverse)
- )
+ val angleStar1 by rememberInfiniteTransition("angleStar1").animateFloat(
+ label = "angleStar1",
+ initialValue = -20f,
+ targetValue = 20f,
+ animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Reverse)
+ )
- val angleStar2 by rememberInfiniteTransition("angleStar2").animateFloat(
- label = "angleStar2",
- initialValue = -50f,
- targetValue = 50f,
- animationSpec = infiniteRepeatable(tween(9000), RepeatMode.Reverse)
- )
+ val angleStar2 by rememberInfiniteTransition("angleStar2").animateFloat(
+ label = "angleStar2",
+ initialValue = -50f,
+ targetValue = 50f,
+ animationSpec = infiniteRepeatable(tween(9000), RepeatMode.Reverse)
+ )
- Icon(
- modifier = Modifier
- .requiredSize(256.dp)
- .absoluteOffset(
- x = halfWidth * 0.7f,
- y = -halfHeight * 0.6f
- )
- .rotate(angleStar1)
- .zIndex(-1f),
- painter = painterResource(R.drawable.shape_soft_star_1),
- tint = MaterialTheme.colorScheme.primary,
- contentDescription = null,
- )
- Icon(
- modifier = Modifier
- .requiredSize(256.dp)
- .absoluteOffset(
- x = -halfWidth * 0.7f,
- y = halfHeight * 0.6f
- )
- .rotate(angleStar2)
- .zIndex(-1f),
- painter = painterResource(R.drawable.shape_soft_star_2),
- tint = MaterialTheme.colorScheme.secondary,
- contentDescription = null,
- )
+ Icon(
+ modifier = Modifier
+ .requiredSize(256.dp)
+ .absoluteOffset(
+ x = halfWidth * 0.7f,
+ y = -halfHeight * 0.6f
+ )
+ .rotate(angleStar1)
+ .zIndex(-1f),
+ painter = painterResource(R.drawable.shape_soft_star_1),
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null,
+ )
+ Icon(
+ modifier = Modifier
+ .requiredSize(256.dp)
+ .absoluteOffset(
+ x = -halfWidth * 0.7f,
+ y = halfHeight * 0.6f
+ )
+ .rotate(angleStar2)
+ .zIndex(-1f),
+ painter = painterResource(R.drawable.shape_soft_star_2),
+ tint = MaterialTheme.colorScheme.secondary,
+ contentDescription = null,
+ )
+ }
}
- }
- val pageCount = colorList.size + 1
- val pagerState =
- rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf(
- Color(LocalSeedColor.current)
- ).run { if (this == -1) 0 else this }) {
- pageCount
- }
+ val pageCount = colorList.size + 1
+ val pagerState =
+ rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf(
+ Color(LocalSeedColor.current)
+ ).run { if (this == -1) 0 else this }) {
+ pageCount
+ }
- HorizontalPager(
- modifier = Modifier
- .fillMaxWidth()
- .clearAndSetSemantics { },
- state = pagerState,
- contentPadding = PaddingValues(horizontal = 12.dp)
- ) { page ->
- if (page < pageCount - 1) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center
- ) { ColorButtons(colorList[page]) }
- } else {
- val isSelected =
- LocalPaletteStyleIndex.current == STYLE_MONOCHROME && !LocalDynamicColorSwitch.current
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center
- ) {
- ColorButtonImpl(
- modifier = Modifier,
- isSelected = { isSelected },
- tonalPalettes = Color.Black.toTonalPalettes(PaletteStyle.Monochrome),
- onClick = {
- AppSettings.switchDynamicColor(enabled = false)
- AppSettings.modifyThemeSeedColor(
- Color.Black.toArgb(), STYLE_MONOCHROME
- )
- })
+ HorizontalPager(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clearAndSetSemantics { },
+ state = pagerState,
+ contentPadding = PaddingValues(horizontal = 12.dp)
+ ) { page ->
+ if (page < pageCount - 1) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) { ColorButtons(colorList[page]) }
+ } else {
+ val isSelected =
+ LocalPaletteStyleIndex.current == STYLE_MONOCHROME && !LocalDynamicColorSwitch.current
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ ColorButtonImpl(
+ modifier = Modifier,
+ isSelected = { isSelected },
+ tonalPalettes = Color.Black.toTonalPalettes(PaletteStyle.Monochrome),
+ onClick = {
+ AppSettings.switchDynamicColor(enabled = false)
+ AppSettings.modifyThemeSeedColor(
+ Color.Black.toArgb(), STYLE_MONOCHROME
+ )
+ })
+ }
}
}
- }
- HorizontalPagerIndicator(pagerState = pagerState,
- pageCount = pageCount,
- modifier = Modifier
- .clearAndSetSemantics { }
- .align(Alignment.CenterHorizontally)
- .padding(vertical = 12.dp),
- activeColor = MaterialTheme.colorScheme.primary,
- inactiveColor = MaterialTheme.colorScheme.outlineVariant,
- indicatorHeight = 6.dp,
- indicatorWidth = 6.dp)
+ HorizontalPagerIndicator(pagerState = pagerState,
+ pageCount = pageCount,
+ modifier = Modifier
+ .clearAndSetSemantics { }
+ .align(Alignment.CenterHorizontally)
+ .padding(vertical = 12.dp),
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.outlineVariant,
+ indicatorHeight = 6.dp,
+ indicatorWidth = 6.dp)
- if (DynamicColors.isDynamicColorAvailable()) {
+ if (DynamicColors.isDynamicColorAvailable()) {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.dynamic_color),
+ description = stringResource(id = R.string.dynamic_color_desc),
+ icon = Icons.Outlined.ColorLens,
+ isChecked = LocalDynamicColorSwitch.current,
+ onClick = {
+ AppSettings.switchDynamicColor()
+ })
+ }
+ val isDarkTheme = LocalDarkTheme.current.isDarkTheme()
+ PreferenceSwitchWithDivider(
+ title = stringResource(id = R.string.dark_theme),
+ icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
+ isChecked = isDarkTheme,
+ description = LocalDarkTheme.current.getDarkThemeDesc(),
+ onChecked = { AppSettings.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) },
+ onClick = { navigator.push(DarkThemeScreen) })
+ PreferenceItem(
+ title = stringResource(id = R.string.language),
+ icon = Icons.Outlined.Language,
+ description = Locale.getDefault().toDisplayName(),
+ onClick = { navigator.push(LanguagesScreen) })
+ PreferenceSubtitle(text = stringResource(id = R.string.user_interface))
PreferenceSwitch(
- title = stringResource(id = R.string.dynamic_color),
- description = stringResource(id = R.string.dynamic_color_desc),
- icon = Icons.Outlined.ColorLens,
- isChecked = LocalDynamicColorSwitch.current,
- onClick = {
- AppSettings.switchDynamicColor()
- })
- }
- val isDarkTheme = LocalDarkTheme.current.isDarkTheme()
- PreferenceSwitchWithDivider(
- title = stringResource(id = R.string.dark_theme),
- icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
- isChecked = isDarkTheme,
- description = LocalDarkTheme.current.getDarkThemeDesc(),
- onChecked = { AppSettings.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) },
- onClick = { navigateToDarkTheme() })
- PreferenceItem(
- title = stringResource(id = R.string.language),
- icon = Icons.Outlined.Language,
- description = Locale.getDefault().toDisplayName(),
- onClick = { navigateToLanguages() })
- PreferenceSubtitle(text = stringResource(id = R.string.user_interface))
- PreferenceSwitch(
- icon = Icons.Outlined.Layers,
- title = stringResource(id = R.string.details_modern_look),
- isChecked = isModernViewEnabled,
- ) {
- isModernViewEnabled = !isModernViewEnabled
- AppSettings.updateValue(MODERN_VIEW, isModernViewEnabled)
+ icon = Icons.Outlined.Layers,
+ title = stringResource(id = R.string.details_modern_look),
+ isChecked = isModernViewEnabled,
+ ) {
+ isModernViewEnabled = !isModernViewEnabled
+ AppSettings.updateValue(MODERN_VIEW, isModernViewEnabled)
+ }
}
}
}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeScreen.kt
new file mode 100644
index 0000000..0ddb264
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeScreen.kt
@@ -0,0 +1,87 @@
+package org.xtimms.shirizu.sections.settings.appearance
+
+import android.os.Build
+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.Contrast
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.LocalDarkTheme
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceSingleChoiceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitch
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.FOLLOW_SYSTEM
+import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.OFF
+import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.ON
+import org.xtimms.shirizu.utils.lang.Screen
+
+object DarkThemeScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val darkThemePreference = LocalDarkTheme.current
+ val isHighContrastModeEnabled = darkThemePreference.isHighContrastModeEnabled
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.dark_theme),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ if (Build.VERSION.SDK_INT >= 29)
+ item {
+ PreferenceSingleChoiceItem(
+ text = stringResource(id = R.string.follow_system),
+ selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM
+ ) {
+ AppSettings.modifyDarkThemePreference(FOLLOW_SYSTEM)
+ }
+ }
+ item {
+ PreferenceSingleChoiceItem(
+ text = stringResource(id = R.string.on),
+ selected = darkThemePreference.darkThemeValue == ON
+ ) {
+ AppSettings.modifyDarkThemePreference(ON)
+ }
+ }
+ item {
+ PreferenceSingleChoiceItem(
+ text = stringResource(id = R.string.off),
+ selected = darkThemePreference.darkThemeValue == OFF
+ ) {
+ AppSettings.modifyDarkThemePreference(OFF)
+ }
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(R.string.additional_settings))
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.high_contrast),
+ icon = Icons.Outlined.Contrast,
+ isChecked = isHighContrastModeEnabled,
+ onClick = {
+ AppSettings.modifyDarkThemePreference(isHighContrastModeEnabled = !isHighContrastModeEnabled)
+ })
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeView.kt
deleted file mode 100644
index e2e47ec..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/DarkThemeView.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package org.xtimms.shirizu.sections.settings.appearance
-
-import android.os.Build
-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.Contrast
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import org.xtimms.shirizu.LocalDarkTheme
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceSingleChoiceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitch
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.FOLLOW_SYSTEM
-import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.OFF
-import org.xtimms.shirizu.core.prefs.DarkThemePreference.Companion.ON
-
-const val DARK_THEME_DESTINATION = "dark_theme"
-
-@Composable
-fun DarkThemeView(
- navigateBack: () -> Unit
-) {
-
- val darkThemePreference = LocalDarkTheme.current
- val isHighContrastModeEnabled = darkThemePreference.isHighContrastModeEnabled
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.dark_theme),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- if (Build.VERSION.SDK_INT >= 29)
- item {
- PreferenceSingleChoiceItem(
- text = stringResource(id = R.string.follow_system),
- selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM
- ) {
- AppSettings.modifyDarkThemePreference(FOLLOW_SYSTEM)
- }
- }
- item {
- PreferenceSingleChoiceItem(
- text = stringResource(id = R.string.on),
- selected = darkThemePreference.darkThemeValue == ON
- ) {
- AppSettings.modifyDarkThemePreference(ON)
- }
- }
- item {
- PreferenceSingleChoiceItem(
- text = stringResource(id = R.string.off),
- selected = darkThemePreference.darkThemeValue == OFF
- ) {
- AppSettings.modifyDarkThemePreference(OFF)
- }
- }
- item {
- PreferenceSubtitle(text = stringResource(R.string.additional_settings))
- }
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.high_contrast),
- icon = Icons.Outlined.Contrast,
- isChecked = isHighContrastModeEnabled,
- onClick = {
- AppSettings.modifyDarkThemePreference(isHighContrastModeEnabled = !isHighContrastModeEnabled)
- })
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesScreen.kt
similarity index 79%
rename from app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesView.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesScreen.kt
index 4857ca3..b421158 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesView.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/appearance/LanguagesScreen.kt
@@ -38,6 +38,8 @@ 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 cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.PreferenceSingleChoiceItem
import org.xtimms.shirizu.core.components.PreferencesHintCard
@@ -45,55 +47,56 @@ import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.sections.settings.about.weblate
import org.xtimms.shirizu.ui.theme.ShirizuTheme
+import org.xtimms.shirizu.utils.lang.Screen
import org.xtimms.shirizu.utils.system.LocaleLanguageCodeMap
import org.xtimms.shirizu.utils.system.setLanguage
import org.xtimms.shirizu.utils.system.toDisplayName
import java.util.Locale
-const val LANGUAGES_DESTINATION = "languages"
+object LanguagesScreen : Screen() {
-@Composable
-fun LanguagesView(
- navigateBack: () -> Unit
-) {
- val selectedLocale by remember { mutableStateOf(Locale.getDefault()) }
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply {
- val uri = Uri.fromParts("package", context.packageName, null)
- data = uri
- }
- } else {
- Intent()
- }
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val selectedLocale by remember { mutableStateOf(Locale.getDefault()) }
+ val context = LocalContext.current
- val isSystemLocaleSettingsAvailable =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.packageManager.queryIntentActivities(
- intent, PackageManager.MATCH_ALL
- ).isNotEmpty()
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply {
+ val uri = Uri.fromParts("package", context.packageName, null)
+ data = uri
+ }
} else {
- false
+ Intent()
}
- LanguageViewImpl(
- navigateBack = navigateBack,
- localeSet = LocaleLanguageCodeMap.keys,
- isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable,
- onNavigateToSystemLocaleSettings = {
- if (isSystemLocaleSettingsAvailable) {
- context.startActivity(intent)
+
+ val isSystemLocaleSettingsAvailable =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.packageManager.queryIntentActivities(
+ intent, PackageManager.MATCH_ALL
+ ).isNotEmpty()
+ } else {
+ false
}
- },
- selectedLocale = selectedLocale,
- ) {
- AppSettings.saveLocalePreference(it)
- setLanguage(it)
+ LanguageScreenImpl(
+ navigateBack = navigator::pop,
+ localeSet = LocaleLanguageCodeMap.keys,
+ isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable,
+ onNavigateToSystemLocaleSettings = {
+ if (isSystemLocaleSettingsAvailable) {
+ context.startActivity(intent)
+ }
+ },
+ selectedLocale = selectedLocale,
+ ) {
+ AppSettings.saveLocalePreference(it)
+ setLanguage(it)
+ }
}
}
@Composable
-private fun LanguageViewImpl(
+private fun LanguageScreenImpl(
navigateBack: () -> Unit = {},
localeSet: Set,
isSystemLocaleSettingsAvailable: Boolean = false,
@@ -181,13 +184,13 @@ private fun LanguageViewImpl(
@Preview
@Composable
-private fun LanguagePagePreview() {
+private fun LanguageScreenPreview() {
var language by remember {
mutableStateOf(Locale.KOREAN)
}
val map = setOf(Locale.forLanguageTag("ru"))
ShirizuTheme {
- LanguageViewImpl(
+ LanguageScreenImpl(
localeSet = map,
isSystemLocaleSettingsAvailable = true,
onNavigateToSystemLocaleSettings = { /*TODO*/ },
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/BackupRestoreScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/BackupRestoreScreen.kt
new file mode 100644
index 0000000..c975ee2
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/BackupRestoreScreen.kt
@@ -0,0 +1,247 @@
+package org.xtimms.shirizu.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.Immutable
+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 cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceInfo
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
+import org.xtimms.shirizu.core.components.PreferencesHintCard
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.components.icons.Kotatsu
+import org.xtimms.shirizu.data.repository.backup.BackupRepository
+import org.xtimms.shirizu.data.repository.backup.BackupZipOutput
+import org.xtimms.shirizu.utils.lang.MutableEventFlow
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.call
+import org.xtimms.shirizu.utils.system.toast
+import org.xtimms.shirizu.utils.system.tryLaunch
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+
+object BackupRestoreScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var backup: File? = null
+
+ val chooseBackupDir = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/zip"),
+ ) {
+ if (it != null && backup != null) {
+ context.contentResolver.takePersistableUriPermission(
+ it,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
+ )
+ }
+ }
+
+ val chooseBackupToRestore = 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
+ }
+
+ navigator.push(RestoreBackupScreen(uri.toString()))
+ }
+
+ val showDirectoryAlert =
+ Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager()
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.backup_and_restore),
+ navigateBack = navigator::pop,
+ snackbarHost = {
+ SnackbarHost(
+ modifier = Modifier.systemBarsPadding(),
+ hostState = snackbarHostState
+ )
+ }
+ ) { 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() }
+ ) {
+ chooseBackupDir
+ }
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.restore_from_backup),
+ description = stringResource(id = R.string.restore_from_backup_desc),
+ icon = Icons.Outlined.Restore
+ ) {
+ chooseBackupToRestore.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)
+ )
+}
+
+private class BackupRestoreScreenModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val repository: BackupRepository,
+) : ScreenModel {
+
+ val progress = MutableStateFlow(-1f)
+ val onBackupDone = MutableEventFlow()
+
+ init {
+ screenModelScope.launch {
+ 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/shirizu/sections/settings/backup/BackupRestoreView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/BackupRestoreView.kt
deleted file mode 100644
index d4be39e..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/BackupRestoreView.kt
+++ /dev/null
@@ -1,218 +0,0 @@
-package org.xtimms.shirizu.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.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceInfo
-import org.xtimms.shirizu.core.components.PreferenceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
-import org.xtimms.shirizu.core.components.PreferencesHintCard
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.components.icons.Kotatsu
-import org.xtimms.shirizu.utils.system.toast
-import org.xtimms.shirizu.utils.system.tryLaunch
-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
- }
-
- restoreViewModel.restore(uri)
- }
-
- val showDirectoryAlert =
- Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager()
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.backup_and_restore),
- navigateBack = navigateBack,
- snackbarHost = {
- SnackbarHost(
- modifier = Modifier.systemBarsPadding(),
- hostState = snackbarHostState
- )
- }
- ) { 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/shirizu/sections/settings/backup/RestoreBackupScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreBackupScreen.kt
new file mode 100644
index 0000000..f98606d
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreBackupScreen.kt
@@ -0,0 +1,362 @@
+package org.xtimms.shirizu.sections.settings.backup
+
+import android.content.Context
+import android.net.Uri
+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.material.icons.Icons
+import androidx.compose.material.icons.outlined.AccessTime
+import androidx.compose.material.icons.outlined.Restore
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+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
+import androidx.compose.runtime.setValue
+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.core.net.toUri
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.hilt.ScreenModelFactory
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
+import org.koitharu.kotatsu.parsers.util.SuspendLazy
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferencesHintCard
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.data.repository.backup.BackupEntry
+import org.xtimms.shirizu.data.repository.backup.BackupRepository
+import org.xtimms.shirizu.data.repository.backup.BackupZipInput
+import org.xtimms.shirizu.data.repository.backup.CompositeResult
+import org.xtimms.shirizu.sections.settings.about.ProgressIndicatorButton
+import org.xtimms.shirizu.utils.DeviceUtil
+import org.xtimms.shirizu.utils.lang.Screen
+import java.io.File
+import java.io.FileNotFoundException
+import java.util.Date
+import java.util.EnumMap
+import java.util.EnumSet
+
+class RestoreBackupScreen(
+ private val uri: String,
+) : Screen() {
+
+ @Composable
+ override fun Content() {
+ val context = LocalContext.current
+ val navigator = LocalNavigator.currentOrThrow
+ val model = getScreenModel { factory ->
+ factory.create(uri)
+ }
+ val state by model.state.collectAsState()
+
+ /*Scaffold(
+ topBar = {
+ AppBar(
+ title = stringResource(MR.strings.pref_restore_backup),
+ navigateUp = navigator::pop,
+ scrollBehavior = it,
+ )
+ },
+ ) { contentPadding ->
+ LazyColumnWithAction(
+ contentPadding = contentPadding,
+ actionLabel = stringResource(MR.strings.action_restore),
+ actionEnabled = state.canRestore && state.options.anyEnabled(),
+ onClickAction = {
+ model.startRestore()
+ navigator.pop()
+ },
+ ) {
+ if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
+ item {
+ WarningBanner(MR.strings.restore_miui_warning)
+ }
+ }
+
+ if (state.canRestore) {
+ item {
+ SectionCard {
+ RestoreOptions.options.forEach { option ->
+ LabeledCheckbox(
+ label = stringResource(option.label),
+ checked = option.getter(state.options),
+ onCheckedChange = {
+ model.toggle(option.setter, it)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ if (state.error != null) {
+ errorMessageItem(state.error)
+ }
+ }
+ }*/
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.restore_from_backup),
+ navigateBack = navigator::pop
+ ) { 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 = state.backupDate.toString(),
+ icon = Icons.Outlined.AccessTime
+ )
+ }
+ for (item in model.availableEntries.value) {
+ item {
+ BackupItem(
+ title = item.name.name
+ )
+ }
+ }
+ 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
+ ) {
+ model.restore(uri.toUri())
+ }
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ model.events.collectLatest { event ->
+ when (event) {
+ RestoreBackupScreenModel.Event.RestoreDone -> run {
+ navigator::pop
+ }
+ RestoreBackupScreenModel.Event.InternalError -> {
+ launch { }
+ }
+ }
+ }
+ }
+ }
+}
+
+class RestoreBackupScreenModel @AssistedInject constructor(
+ @ApplicationContext private val context: Context,
+ @Assisted private val uri: String,
+ private val repository: BackupRepository,
+) : StateScreenModel(State()) {
+
+ private val _events: Channel = Channel(Channel.UNLIMITED)
+ val events: Flow = _events.receiveAsFlow()
+
+ private val backupInput = SuspendLazy {
+ val contentResolver = context.contentResolver
+ runInterruptible(Dispatchers.IO) {
+ val tempFile = File.createTempFile("backup_", ".tmp")
+ (contentResolver.openInputStream(uri.toUri()) ?: throw FileNotFoundException()).use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ BackupZipInput(tempFile)
+ }
+ }
+
+ val progress = MutableStateFlow(-1f)
+
+ val availableEntries = MutableStateFlow>(emptyList())
+ val backupDate = MutableStateFlow(null)
+
+ init {
+ screenModelScope.launch(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))
+ }
+ }
+
+ 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(uri: Uri) {
+ screenModelScope.launch {
+ val contentResolver = context.contentResolver
+ val tempFile = File.createTempFile("backup_", ".tmp")
+ (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
+ tempFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ val backupInput = BackupZipInput(tempFile)
+ val backup: BackupZipInput = backupInput
+ 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
+ backup.cleanupAsync()
+ _events.send(Event.RestoreDone)
+ }
+ }
+
+ /**
+ * 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)
+ }
+ }
+ }
+
+ private fun setError(error: Any?, canRestore: Boolean) {
+ mutableState.update {
+ it.copy(
+ error = error,
+ canRestore = canRestore,
+ )
+ }
+ }
+
+ @Immutable
+ data class State(
+ val error: Any? = null,
+ val canRestore: Boolean = false,
+ val backupDate: Date? = null,
+ )
+
+ sealed interface Event {
+ data object InternalError : Event
+ data object RestoreDone : Event
+ }
+
+ @AssistedFactory
+ interface Factory : ScreenModelFactory {
+ fun create(uri: String): RestoreBackupScreenModel
+ }
+}
+
+private data class MissingRestoreComponents(
+ val uri: Uri,
+ val sources: List,
+ val trackers: List,
+)
+
+private data class InvalidRestore(
+ val uri: Uri? = null,
+ val message: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreItemsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreItemsView.kt
deleted file mode 100644
index 22db5fa..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreItemsView.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package org.xtimms.shirizu.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.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.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.shirizu.R
-import org.xtimms.shirizu.core.components.PreferencesHintCard
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.sections.settings.about.ProgressIndicatorButton
-import org.xtimms.shirizu.utils.DeviceUtil
-
-const val RESTORE_ARGUMENT = "{file}"
-const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}"
-
-@Composable
-fun RestoreItemsView(
- uri: String,
- restoreViewModel: RestoreViewModel = hiltViewModel(),
- navigateBack: () -> Unit,
-) {
-
- val items by restoreViewModel.availableEntries.collectAsStateWithLifecycle(emptyList())
- val backupDate by restoreViewModel.backupDate.collectAsStateWithLifecycle(null)
-
- 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 = backupDate.toString(),
- icon = Icons.Outlined.AccessTime
- )
- }
- for (item in items) {
- item {
- BackupItem(
- title = item.name.name
- )
- }
- }
- 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/shirizu/sections/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreViewModel.kt
deleted file mode 100644
index 0daba21..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/backup/RestoreViewModel.kt
+++ /dev/null
@@ -1,163 +0,0 @@
-package org.xtimms.shirizu.sections.settings.backup
-
-import android.content.Context
-import android.net.Uri
-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.shirizu.core.base.viewmodel.KotatsuBaseViewModel
-import org.xtimms.shirizu.data.repository.backup.BackupEntry
-import org.xtimms.shirizu.data.repository.backup.BackupRepository
-import org.xtimms.shirizu.data.repository.backup.BackupZipInput
-import org.xtimms.shirizu.data.repository.backup.CompositeResult
-import org.xtimms.shirizu.utils.lang.MutableEventFlow
-import org.xtimms.shirizu.utils.lang.call
-import org.xtimms.shirizu.utils.system.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(
- private val savedStateHandle: SavedStateHandle,
- private val repository: BackupRepository,
- @ApplicationContext val 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(uri: Uri) {
- launchLoadingJob {
- val contentResolver = context.contentResolver
- val tempFile = File.createTempFile("backup_", ".tmp")
- (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
- tempFile.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- val backupInput = BackupZipInput(tempFile)
- val backup: BackupZipInput = backupInput
- 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
- backup.cleanupAsync()
- 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/shirizu/sections/settings/network/NetworkView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/network/NetworkScreen.kt
similarity index 60%
rename from app/src/main/java/org/xtimms/shirizu/sections/settings/network/NetworkView.kt
rename to app/src/main/java/org/xtimms/shirizu/sections/settings/network/NetworkScreen.kt
index feb2dd6..e533e9c 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/network/NetworkView.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/network/NetworkScreen.kt
@@ -37,6 +37,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.DialogSingleChoiceItem
import org.xtimms.shirizu.core.components.PreferenceItem
@@ -66,189 +68,199 @@ import org.xtimms.shirizu.utils.MaskVisualTransformation
import org.xtimms.shirizu.utils.NumberDefaults.INPUT_LENGTH
import org.xtimms.shirizu.utils.NumberDefaults.MASK
import org.xtimms.shirizu.utils.NumberDefaults.MAX_PORT
+import org.xtimms.shirizu.utils.lang.Screen
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
import org.xtimms.shirizu.utils.lang.intState
import java.net.Proxy
-const val NETWORK_DESTINATION = "network"
+object NetworkScreen : Screen() {
-@Composable
-fun NetworkView(
- navigateBack: () -> Unit,
-) {
+ @Composable
+ override fun Content() {
- var showDOHDialog by remember { mutableStateOf(false) }
- var showProxyDialog by remember { mutableStateOf(false) }
- var showProxyAddressDialog by remember { mutableStateOf(false) }
- var showProxyPortDialog by remember { mutableStateOf(false) }
- var showProxyUsernameDialog by remember { mutableStateOf(false) }
- var showProxyPasswordDialog by remember { mutableStateOf(false) }
+ val navigator = LocalNavigator.currentOrThrow
- var doh by DOH.intState
- var proxy by PROXY_TYPE.intState
- var address by remember(showProxyAddressDialog) { mutableStateOf(PROXY_ADDRESS.getString()) }
- var port by remember(showProxyPortDialog) { mutableIntStateOf(PROXY_PORT.getInt()) }
- var username by remember(showProxyUsernameDialog) { mutableStateOf(PROXY_USER.getString()) }
- var password by remember(showProxyPasswordDialog) { mutableStateOf(PROXY_PASSWORD.getString()) }
+ var showDOHDialog by remember { mutableStateOf(false) }
+ var showProxyDialog by remember { mutableStateOf(false) }
+ var showProxyAddressDialog by remember { mutableStateOf(false) }
+ var showProxyPortDialog by remember { mutableStateOf(false) }
+ var showProxyUsernameDialog by remember { mutableStateOf(false) }
+ var showProxyPasswordDialog by remember { mutableStateOf(false) }
- var isSSLBypassEnabled by remember {
- mutableStateOf(AppSettings.isSSLBypassEnabled())
- }
+ var doh by DOH.intState
+ var proxy by PROXY_TYPE.intState
+ var address by remember(showProxyAddressDialog) { mutableStateOf(PROXY_ADDRESS.getString()) }
+ var port by remember(showProxyPortDialog) { mutableIntStateOf(PROXY_PORT.getInt()) }
+ var username by remember(showProxyUsernameDialog) { mutableStateOf(PROXY_USER.getString()) }
+ var password by remember(showProxyPasswordDialog) { mutableStateOf(PROXY_PASSWORD.getString()) }
- var isImageOptimizationEnabled by remember {
- mutableStateOf(AppSettings.isImagesProxyEnabled())
- }
+ 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 {
- PreferenceSubtitle(text = stringResource(id = R.string.general))
- }
- item {
- var isDownloadWithCellularEnabled by remember {
- mutableStateOf(getValue(CELLULAR_DOWNLOAD))
+ var isImageOptimizationEnabled by remember {
+ mutableStateOf(AppSettings.isImagesProxyEnabled())
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.network),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.general))
}
- PreferenceSwitch(
- title = stringResource(R.string.download_with_cellular),
- description = stringResource(R.string.download_with_cellular_desc),
- icon = if (isDownloadWithCellularEnabled) Icons.Outlined.SignalCellular4Bar
- else Icons.Outlined.SignalCellularConnectedNoInternet4Bar,
- isChecked = isDownloadWithCellularEnabled,
- onClick = {
- isDownloadWithCellularEnabled = !isDownloadWithCellularEnabled
- updateValue(
- CELLULAR_DOWNLOAD,
- isDownloadWithCellularEnabled
- )
+ item {
+ var isDownloadWithCellularEnabled by remember {
+ mutableStateOf(getValue(CELLULAR_DOWNLOAD))
}
- )
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.advanced))
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.dns_over_https),
- description = PreferenceStrings.getDOHDescRes(doh),
- icon = Icons.Outlined.Dns
- ) { showDOHDialog = true }
- }
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.images_optimization_proxy),
- description = stringResource(id = R.string.images_optimization_proxy_desc),
- icon = Icons.Outlined.PhotoSizeSelectSmall,
- isChecked = isImageOptimizationEnabled,
- ) {
- isImageOptimizationEnabled = !isImageOptimizationEnabled
- updateValue(WSRV, isImageOptimizationEnabled)
+ PreferenceSwitch(
+ title = stringResource(R.string.download_with_cellular),
+ description = stringResource(R.string.download_with_cellular_desc),
+ icon = if (isDownloadWithCellularEnabled) Icons.Outlined.SignalCellular4Bar
+ else Icons.Outlined.SignalCellularConnectedNoInternet4Bar,
+ isChecked = isDownloadWithCellularEnabled,
+ onClick = {
+ isDownloadWithCellularEnabled = !isDownloadWithCellularEnabled
+ updateValue(
+ CELLULAR_DOWNLOAD,
+ isDownloadWithCellularEnabled
+ )
+ }
+ )
}
- }
- 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
- updateValue(SSL_BYPASS, isSSLBypassEnabled)
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.advanced))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.dns_over_https),
+ description = PreferenceStrings.getDOHDescRes(doh),
+ icon = Icons.Outlined.Dns
+ ) { showDOHDialog = true }
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.images_optimization_proxy),
+ description = stringResource(id = R.string.images_optimization_proxy_desc),
+ icon = Icons.Outlined.PhotoSizeSelectSmall,
+ isChecked = isImageOptimizationEnabled,
+ ) {
+ isImageOptimizationEnabled = !isImageOptimizationEnabled
+ updateValue(WSRV, isImageOptimizationEnabled)
+ }
+ }
+ 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
+ updateValue(SSL_BYPASS, isSSLBypassEnabled)
+ }
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.proxy))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.proxy_type),
+ description = PreferenceStrings.getProxyDescRes(proxy),
+ ) { showProxyDialog = true }
+ }
+ item {
+ PreferenceItem(
+ enabled = proxy != Proxy.Type.DIRECT.ordinal,
+ title = stringResource(id = R.string.proxy_address),
+ description = address.ifNullOrEmpty { stringResource(id = R.string.not_set) },
+ ) { showProxyAddressDialog = true }
+ }
+ item {
+ PreferenceItem(
+ enabled = proxy != Proxy.Type.DIRECT.ordinal,
+ title = stringResource(id = R.string.proxy_port),
+ description = if (port == 0) stringResource(id = R.string.not_set) else port.toString()
+ ) { showProxyPortDialog = true }
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.proxy_authorization))
+ }
+ item {
+ PreferenceItem(
+ enabled = proxy != Proxy.Type.DIRECT.ordinal,
+ title = stringResource(id = R.string.proxy_username),
+ description = username.ifNullOrEmpty { stringResource(id = R.string.not_set) },
+ ) { showProxyUsernameDialog = true }
+ }
+ item {
+ PreferenceItem(
+ enabled = proxy != Proxy.Type.DIRECT.ordinal,
+ title = stringResource(id = R.string.proxy_password),
+ description = String(CharArray(password.length) { '\u2022' }).ifNullOrEmpty { stringResource(id = R.string.not_set) },
+ ) { showProxyPasswordDialog = true }
}
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.proxy))
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.proxy_type),
- description = PreferenceStrings.getProxyDescRes(proxy),
- ) { showProxyDialog = true }
- }
- item {
- PreferenceItem(
- enabled = proxy != Proxy.Type.DIRECT.ordinal,
- title = stringResource(id = R.string.proxy_address),
- description = address.ifNullOrEmpty { stringResource(id = R.string.not_set) },
- ) { showProxyAddressDialog = true }
- }
- item {
- PreferenceItem(
- enabled = proxy != Proxy.Type.DIRECT.ordinal,
- title = stringResource(id = R.string.proxy_port),
- description = if (port == 0) stringResource(id = R.string.not_set) else port.toString()
- ) { showProxyPortDialog = true }
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.proxy_authorization))
- }
- item {
- PreferenceItem(
- enabled = proxy != Proxy.Type.DIRECT.ordinal,
- title = stringResource(id = R.string.proxy_username),
- description = username.ifNullOrEmpty { stringResource(id = R.string.not_set) },
- ) { showProxyUsernameDialog = true }
- }
- item {
- PreferenceItem(
- enabled = proxy != Proxy.Type.DIRECT.ordinal,
- title = stringResource(id = R.string.proxy_password),
- description = String(CharArray(password.length) { '\u2022' }).ifNullOrEmpty { stringResource(id = R.string.not_set) },
- ) { showProxyPasswordDialog = true }
}
}
- }
- if (showDOHDialog) {
- DOHSettingDialog(provider = doh,
- onDismissRequest = { showDOHDialog = false }) {
- doh = it
- DOH.updateInt(it)
+ if (showDOHDialog) {
+ DOHSettingDialog(provider = doh,
+ onDismissRequest = { showDOHDialog = false }) {
+ doh = it
+ DOH.updateInt(it)
+ }
}
- }
- if (showProxyDialog) {
- ProxySettingDialog(type = proxy,
- onDismissRequest = { showProxyDialog = false }) {
- proxy = it
- PROXY_TYPE.updateInt(it)
+ if (showProxyDialog) {
+ ProxySettingDialog(type = proxy,
+ onDismissRequest = { showProxyDialog = false }) {
+ proxy = it
+ PROXY_TYPE.updateInt(it)
+ }
}
- }
- if (showProxyAddressDialog) {
- ProxyAddressSettingDialog(address = address,
- onDismissRequest = { showProxyAddressDialog = false }) {
- address = it
- PROXY_ADDRESS.updateString(it)
+ if (showProxyAddressDialog) {
+ ProxyAddressSettingDialog(address = address,
+ onDismissRequest = { showProxyAddressDialog = false }) {
+ address = it
+ PROXY_ADDRESS.updateString(it)
+ }
}
- }
- if (showProxyPortDialog) {
- ProxyPortSettingDialog(port = port.toString(),
- onDismissRequest = { showProxyPortDialog = false }) {
- port = it
- PROXY_PORT.updateInt(it)
+ if (showProxyPortDialog) {
+ ProxyPortSettingDialog(port = port.toString(),
+ onDismissRequest = { showProxyPortDialog = false }) {
+ port = it
+ PROXY_PORT.updateInt(it)
+ }
}
- }
- if (showProxyUsernameDialog) {
- ProxyUsernameSettingDialog(username = username,
- onDismissRequest = { showProxyUsernameDialog = false }) {
- username = it
- PROXY_USER.updateString(it)
+ if (showProxyUsernameDialog) {
+ ProxyUsernameSettingDialog(username = username,
+ onDismissRequest = { showProxyUsernameDialog = false }) {
+ username = it
+ PROXY_USER.updateString(it)
+ }
}
- }
- if (showProxyPasswordDialog) {
- ProxyPasswordSettingDialog(password = password,
- onDismissRequest = { showProxyPasswordDialog = false }) {
- password = it
- PROXY_PASSWORD.updateString(it)
+ if (showProxyPasswordDialog) {
+ ProxyPasswordSettingDialog(password = password,
+ onDismissRequest = { showProxyPasswordDialog = false }) {
+ password = it
+ PROXY_PASSWORD.updateString(it)
+ }
}
}
}
+@Composable
+fun NetworkView(
+ navigateBack: () -> Unit,
+) {
+
+
+}
+
@Composable
fun DOHSettingDialog(
provider: Int = 0,
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesScreen.kt
new file mode 100644
index 0000000..999f587
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesScreen.kt
@@ -0,0 +1,182 @@
+package org.xtimms.shirizu.sections.settings.services
+
+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.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CollectionsBookmark
+import androidx.compose.material.icons.outlined.Done
+import androidx.compose.material.icons.outlined.QueryStats
+import androidx.compose.material.icons.outlined.Timelapse
+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.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.LocalKitsuRepository
+import org.xtimms.shirizu.LocalShikimoriRepository
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.ShirizuAsyncImage
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitch
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.components.icons.Creation
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.READING_TIME
+import org.xtimms.shirizu.core.prefs.RELATED
+import org.xtimms.shirizu.core.prefs.STATISTICS
+import org.xtimms.shirizu.core.prefs.SUGGESTIONS
+import org.xtimms.shirizu.sections.settings.services.suggestions.SuggestionsSettingsScreen
+import org.xtimms.shirizu.sections.stats.StatsScreen
+import org.xtimms.shirizu.utils.lang.Screen
+
+object ServicesScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+
+ val kitsuRepository = LocalKitsuRepository.current
+ val shikimoriRepository = LocalShikimoriRepository.current
+
+ var isSuggestionsEnabled by remember { mutableStateOf(AppSettings.isSuggestionsEnabled()) }
+ var isRelatedEnabled by remember { mutableStateOf(AppSettings.isRelatedMangaEnabled()) }
+ var isStatisticsEnabled by remember { mutableStateOf(AppSettings.isStatisticsEnabled()) }
+ var isReadingTimeEstimationEnabled by remember { mutableStateOf(AppSettings.isReadingTimeEstimationEnabled()) }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.services),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.manga))
+ }
+ item {
+ PreferenceSwitchWithDivider(
+ title = stringResource(R.string.suggestions),
+ description = stringResource(id = R.string.suggestions_summary),
+ icon = Icons.Outlined.Creation,
+ isChecked = isSuggestionsEnabled,
+ onClick = { navigator.push(SuggestionsSettingsScreen) },
+ onChecked = {
+ isSuggestionsEnabled = !isSuggestionsEnabled
+ AppSettings.updateValue(SUGGESTIONS, isSuggestionsEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.related_manga),
+ description = stringResource(id = R.string.related_manga_summary),
+ icon = Icons.Outlined.CollectionsBookmark,
+ isChecked = isRelatedEnabled,
+ onClick = {
+ isRelatedEnabled = !isRelatedEnabled
+ AppSettings.updateValue(RELATED, isRelatedEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.statistics))
+ }
+ item {
+ PreferenceSwitchWithDivider(
+ title = stringResource(R.string.recording_statistics),
+ description = if (isStatisticsEnabled) stringResource(id = R.string.enabled) else stringResource(
+ id = R.string.disabled
+ ),
+ icon = Icons.Outlined.QueryStats,
+ isChecked = isStatisticsEnabled,
+ onClick = {
+ navigator.push(StatsScreen)
+ },
+ onChecked = {
+ isStatisticsEnabled = !isStatisticsEnabled
+ AppSettings.updateValue(STATISTICS, isStatisticsEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceSwitch(
+ title = stringResource(id = R.string.show_estimated_read_time),
+ description = stringResource(id = R.string.show_estimated_read_time_desc),
+ icon = Icons.Outlined.Timelapse,
+ isChecked = isReadingTimeEstimationEnabled,
+ onClick = {
+ isReadingTimeEstimationEnabled = !isReadingTimeEstimationEnabled
+ AppSettings.updateValue(READING_TIME, isReadingTimeEstimationEnabled)
+ }
+ )
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.scrobbling))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.kitsu),
+ description = if (kitsuRepository.isAuthorized) kitsuRepository.cachedUser?.nickname else stringResource(
+ id = R.string.disabled
+ ),
+ trailingIcon = {
+ ShirizuAsyncImage(
+ modifier = Modifier
+ .padding(start = 8.dp, end = 16.dp)
+ .size(42.dp)
+ .padding(2.dp)
+ .clip(CircleShape),
+ contentDescription = null,
+ model = "https://mangadex.org/img/avatar.png"
+ )
+ }
+ ) {
+ // TODO
+ }
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.shikimori),
+ description = if (shikimoriRepository.isAuthorized) shikimoriRepository.cachedUser?.nickname else stringResource(
+ id = R.string.disabled
+ ),
+ trailingIcon = {
+ ShirizuAsyncImage(
+ modifier = Modifier
+ .padding(start = 8.dp, end = 16.dp)
+ .size(42.dp)
+ .padding(2.dp)
+ .clip(CircleShape),
+ contentDescription = null,
+ model = "https://mangadex.org/img/avatar.png"
+ )
+ }
+ ) {
+ // TODO
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesView.kt
deleted file mode 100644
index 96fed64..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/ServicesView.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package org.xtimms.shirizu.sections.settings.services
-
-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.CollectionsBookmark
-import androidx.compose.material.icons.outlined.QueryStats
-import androidx.compose.material.icons.outlined.Timelapse
-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.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitch
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.components.icons.Creation
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.READING_TIME
-import org.xtimms.shirizu.core.prefs.RELATED
-import org.xtimms.shirizu.core.prefs.STATISTICS
-import org.xtimms.shirizu.core.prefs.SUGGESTIONS
-
-const val SERVICES_DESTINATION = "services"
-
-@Composable
-fun ServicesView(
- navigateBack: () -> Unit,
- navigateToSuggestionsSettings: () -> Unit,
- navigateToStatistics: () -> Unit
-) {
-
- var isSuggestionsEnabled by remember { mutableStateOf(AppSettings.isSuggestionsEnabled()) }
- var isRelatedEnabled by remember { mutableStateOf(AppSettings.isRelatedMangaEnabled()) }
- var isStatisticsEnabled by remember { mutableStateOf(AppSettings.isStatisticsEnabled()) }
- var isReadingTimeEstimationEnabled by remember { mutableStateOf(AppSettings.isReadingTimeEstimationEnabled()) }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.services),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.manga))
- }
- item {
- PreferenceSwitchWithDivider(
- title = stringResource(R.string.suggestions),
- description = stringResource(id = R.string.suggestions_summary),
- icon = Icons.Outlined.Creation,
- isChecked = isSuggestionsEnabled,
- onClick = navigateToSuggestionsSettings,
- onChecked = {
- isSuggestionsEnabled = !isSuggestionsEnabled
- AppSettings.updateValue(SUGGESTIONS, isSuggestionsEnabled)
- }
- )
- }
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.related_manga),
- description = stringResource(id = R.string.related_manga_summary),
- icon = Icons.Outlined.CollectionsBookmark,
- isChecked = isRelatedEnabled,
- onClick = {
- isRelatedEnabled = !isRelatedEnabled
- AppSettings.updateValue(RELATED, isRelatedEnabled)
- }
- )
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.statistics))
- }
- item {
- PreferenceSwitchWithDivider(
- title = stringResource(R.string.recording_statistics),
- description = if (isStatisticsEnabled) stringResource(id = R.string.enabled) else stringResource(
- id = R.string.disabled
- ),
- icon = Icons.Outlined.QueryStats,
- isChecked = isStatisticsEnabled,
- onClick = navigateToStatistics,
- onChecked = {
- isStatisticsEnabled = !isStatisticsEnabled
- AppSettings.updateValue(STATISTICS, isStatisticsEnabled)
- }
- )
- }
- item {
- PreferenceSwitch(
- title = stringResource(id = R.string.show_estimated_read_time),
- description = stringResource(id = R.string.show_estimated_read_time_desc),
- icon = Icons.Outlined.Timelapse,
- isChecked = isReadingTimeEstimationEnabled,
- onClick = {
- isReadingTimeEstimationEnabled = !isReadingTimeEstimationEnabled
- AppSettings.updateValue(READING_TIME, isReadingTimeEstimationEnabled)
- }
- )
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsScreen.kt
new file mode 100644
index 0000000..d7c05fe
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsScreen.kt
@@ -0,0 +1,156 @@
+package org.xtimms.shirizu.sections.settings.services.suggestions
+
+import android.Manifest
+import android.os.Build
+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.FilterAlt
+import androidx.compose.material.icons.outlined.NoAdultContent
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.Wifi
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.core.app.NotificationManagerCompat
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.PermissionStatus
+import com.google.accompanist.permissions.rememberPermissionState
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceInfo
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitch
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.AppSettings.updateBoolean
+import org.xtimms.shirizu.core.prefs.SUGGESTIONS
+import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NONMETERED
+import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NOTIFICATIONS
+import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NSFW
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.lang.booleanState
+import org.xtimms.shirizu.utils.system.toast
+
+@OptIn(ExperimentalPermissionsApi::class)
+object SuggestionsSettingsScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val navigator = LocalNavigator.currentOrThrow
+ val context = LocalContext.current
+
+ var suggestionsEnabled by SUGGESTIONS.booleanState
+ var nonMeteredNetwork by SUGGESTIONS_NONMETERED.booleanState
+ var notifications by SUGGESTIONS_NOTIFICATIONS.booleanState
+ var nsfwSuggestions by SUGGESTIONS_NSFW.booleanState
+
+ val enableSuggestionsNotifications = {
+ notifications = !notifications
+ SUGGESTIONS_NOTIFICATIONS.updateBoolean(notifications)
+ }
+
+ val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ rememberPermissionState(
+ permission = Manifest.permission.POST_NOTIFICATIONS
+ ) { b: Boolean ->
+ if (b) {
+ enableSuggestionsNotifications()
+ } else {
+ context.toast(R.string.permission_denied)
+ }
+ }
+ } else null
+
+ val checkPermission = {
+ if (notificationPermission?.status == PermissionStatus.Granted) {
+ enableSuggestionsNotifications()
+ } else {
+ notificationPermission?.launchPermissionRequest()
+ }
+ }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.suggestions),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSwitchWithContainer(
+ title = stringResource(id = R.string.enable_suggestions),
+ isChecked = suggestionsEnabled
+ ) {
+ suggestionsEnabled = !suggestionsEnabled
+ SUGGESTIONS.updateBoolean(suggestionsEnabled)
+ }
+ }
+ item {
+ PreferenceSwitch(
+ enabled = suggestionsEnabled,
+ icon = Icons.Outlined.Wifi,
+ title = stringResource(id = R.string.only_on_wifi),
+ description = stringResource(id = R.string.only_on_wifi_desc),
+ isChecked = nonMeteredNetwork
+ ) {
+ nonMeteredNetwork = !nonMeteredNetwork
+ SUGGESTIONS_NONMETERED.updateBoolean(nonMeteredNetwork)
+ }
+ }
+ item {
+ PreferenceSwitch(
+ enabled = suggestionsEnabled,
+ icon = Icons.Outlined.Notifications,
+ title = stringResource(id = R.string.suggestions_notifications),
+ description = stringResource(id = R.string.suggestions_notifications_desc),
+ isChecked = notifications
+ ) {
+ checkPermission()
+ }
+ }
+ item{
+ PreferenceSubtitle(text = stringResource(id = R.string.advanced))
+ }
+ item {
+ PreferenceItem(
+ enabled = suggestionsEnabled,
+ title = stringResource(id = R.string.exclude_genres),
+ description = stringResource(id = R.string.exclude_genres_desc),
+ icon = Icons.Outlined.FilterAlt
+ )
+ }
+ item {
+ PreferenceSwitch(
+ enabled = suggestionsEnabled,
+ title = stringResource(id = R.string.do_not_suggest_nsfw_manga),
+ icon = Icons.Outlined.NoAdultContent,
+ isChecked = nsfwSuggestions
+ ) {
+ nsfwSuggestions = !nsfwSuggestions
+ SUGGESTIONS_NSFW.updateBoolean(nsfwSuggestions)
+ }
+ }
+ item {
+ HorizontalDivider()
+ }
+ item {
+ PreferenceInfo(text = stringResource(id = R.string.suggestions_info))
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsView.kt
deleted file mode 100644
index 02f5432..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/services/suggestions/SuggestionsSettingsView.kt
+++ /dev/null
@@ -1,156 +0,0 @@
-package org.xtimms.shirizu.sections.settings.services.suggestions
-
-import android.Manifest
-import android.os.Build
-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.FilterAlt
-import androidx.compose.material.icons.outlined.NoAdultContent
-import androidx.compose.material.icons.outlined.Notifications
-import androidx.compose.material.icons.outlined.Wifi
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.PermissionStatus
-import com.google.accompanist.permissions.rememberPermissionState
-import kotlinx.coroutines.launch
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceInfo
-import org.xtimms.shirizu.core.components.PreferenceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitch
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithContainer
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.AppSettings.updateBoolean
-import org.xtimms.shirizu.core.prefs.SUGGESTIONS
-import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NONMETERED
-import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NOTIFICATIONS
-import org.xtimms.shirizu.core.prefs.SUGGESTIONS_NSFW
-import org.xtimms.shirizu.utils.lang.booleanState
-import org.xtimms.shirizu.utils.system.toast
-
-const val SUGGESTIONS_SETTINGS_DESTINATION = "suggestions_settings"
-
-@OptIn(ExperimentalPermissionsApi::class)
-@Composable
-fun SuggestionsSettingsView(
- navigateBack: () -> Unit
-) {
-
- val context = LocalContext.current
- var suggestionsEnabled by SUGGESTIONS.booleanState
- var nonMeteredNetwork by SUGGESTIONS_NONMETERED.booleanState
- var notifications by SUGGESTIONS_NOTIFICATIONS.booleanState
- var nsfwSuggestions by SUGGESTIONS_NSFW.booleanState
-
- val enableSuggestionsNotifications = {
- notifications = !notifications
- SUGGESTIONS_NOTIFICATIONS.updateBoolean(notifications)
- }
-
- val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- rememberPermissionState(
- permission = Manifest.permission.POST_NOTIFICATIONS
- ) { b: Boolean ->
- if (b) {
- enableSuggestionsNotifications()
- } else {
- context.toast(R.string.permission_denied)
- }
- }
- } else {
- TODO("VERSION.SDK_INT < TIRAMISU")
- }
-
- val checkPermission = {
- if (notificationPermission.status == PermissionStatus.Granted) {
- enableSuggestionsNotifications()
- } else {
- notificationPermission.launchPermissionRequest()
- }
- }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.suggestions),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- PreferenceSwitchWithContainer(
- title = stringResource(id = R.string.enable_suggestions),
- isChecked = suggestionsEnabled
- ) {
- suggestionsEnabled = !suggestionsEnabled
- SUGGESTIONS.updateBoolean(suggestionsEnabled)
- }
- }
- item {
- PreferenceSwitch(
- enabled = suggestionsEnabled,
- icon = Icons.Outlined.Wifi,
- title = stringResource(id = R.string.only_on_wifi),
- description = stringResource(id = R.string.only_on_wifi_desc),
- isChecked = nonMeteredNetwork
- ) {
- nonMeteredNetwork = !nonMeteredNetwork
- SUGGESTIONS_NONMETERED.updateBoolean(nonMeteredNetwork)
- }
- }
- item {
- PreferenceSwitch(
- enabled = suggestionsEnabled,
- icon = Icons.Outlined.Notifications,
- title = stringResource(id = R.string.suggestions_notifications),
- description = stringResource(id = R.string.suggestions_notifications_desc)
- ) {
- checkPermission()
- }
- }
- item{
- PreferenceSubtitle(text = stringResource(id = R.string.advanced))
- }
- item {
- PreferenceItem(
- enabled = suggestionsEnabled,
- title = stringResource(id = R.string.exclude_genres),
- description = stringResource(id = R.string.exclude_genres_desc),
- icon = Icons.Outlined.FilterAlt
- )
- }
- item {
- PreferenceSwitch(
- enabled = suggestionsEnabled,
- title = stringResource(id = R.string.do_not_suggest_nsfw_manga),
- icon = Icons.Outlined.NoAdultContent,
- isChecked = nsfwSuggestions
- ) {
- nsfwSuggestions = !nsfwSuggestions
- SUGGESTIONS_NSFW.updateBoolean(nsfwSuggestions)
- }
- }
- item {
- HorizontalDivider()
- }
- item {
- PreferenceInfo(text = stringResource(id = R.string.suggestions_info))
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsScreen.kt
new file mode 100644
index 0000000..8134b79
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsScreen.kt
@@ -0,0 +1,189 @@
+package org.xtimms.shirizu.sections.settings.shelf
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.asPaddingValues
+import androidx.compose.foundation.layout.height
+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.Category
+import androidx.compose.material.icons.outlined.GridView
+import androidx.compose.material.icons.outlined.Numbers
+import androidx.compose.material.icons.outlined.RssFeed
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+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 cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.PreferenceItem
+import org.xtimms.shirizu.core.components.PreferenceSubtitle
+import org.xtimms.shirizu.core.components.PreferenceSwitch
+import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.prefs.AppSettings
+import org.xtimms.shirizu.core.prefs.AppSettings.getInt
+import org.xtimms.shirizu.core.prefs.GRID_COLUMNS
+import org.xtimms.shirizu.core.prefs.TABS_MANGA_COUNT
+import org.xtimms.shirizu.core.prefs.TRACKER
+import org.xtimms.shirizu.sections.settings.shelf.categories.CategoryScreen
+import org.xtimms.shirizu.utils.lang.Screen
+
+object ShelfSettingsScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+
+ val navigator = LocalNavigator.currentOrThrow
+ var isTrackerEnabled by remember { mutableStateOf(AppSettings.isTrackerEnabled()) }
+ var showGridColumnsDialog by remember { mutableStateOf(false) }
+
+ var isMangaCountInTabsEnabled by remember {
+ mutableStateOf(AppSettings.isMangaCountInTabsEnabled())
+ }
+ var gridColumns by remember(showGridColumnsDialog) { mutableIntStateOf(GRID_COLUMNS.getInt()) }
+
+ ScaffoldWithTopAppBar(
+ title = stringResource(R.string.nav_shelf),
+ navigateBack = navigator::pop
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.padding(padding),
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ )
+ ) {
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.categories))
+ }
+ item {
+ PreferenceItem(
+ title = stringResource(id = R.string.edit_categories),
+ /*description = pluralStringResource(
+ id = R.plurals.categories_count,
+ count = categories.size,
+ categories.size
+ ),*/
+ icon = Icons.Outlined.Category,
+ onClick = {
+ navigator.push(CategoryScreen())
+ }
+ )
+ }
+ 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 {
+ PreferenceItem(
+ title = stringResource(id = R.string.grid_columns_count),
+ description = stringResource(
+ id = R.string.grid_columns_count_desc,
+ gridColumns
+ ),
+ icon = Icons.Outlined.GridView
+ ) { showGridColumnsDialog = true }
+ }
+ item {
+ PreferenceSubtitle(text = stringResource(id = R.string.updates))
+ }
+ item {
+ PreferenceSwitchWithDivider(
+ title = stringResource(id = R.string.auto_update),
+ description = if (isTrackerEnabled) stringResource(id = R.string.on)
+ else stringResource(id = R.string.off),
+ icon = Icons.Outlined.RssFeed,
+ isChecked = isTrackerEnabled,
+ onClick = {
+ // TODO
+ },
+ onChecked = {
+ isTrackerEnabled = !isTrackerEnabled
+ AppSettings.updateValue(TRACKER, isTrackerEnabled)
+ }
+ )
+ }
+ }
+ }
+
+ if (showGridColumnsDialog) {
+ GridColumnsDialog(
+ gridCount = gridColumns.toFloat()
+ ) {
+ showGridColumnsDialog = false
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GridColumnsDialog(
+ gridCount: Float,
+ onDismissRequest: () -> Unit,
+) {
+ var count by remember { mutableFloatStateOf(gridCount) }
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.dismiss))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ onDismissRequest()
+ AppSettings.encodeInt(GRID_COLUMNS, count.toInt())
+ }) {
+ Text(stringResource(R.string.confirm))
+ }
+ },
+ icon = { Icon(Icons.Outlined.GridView, null) },
+ title = { Text(stringResource(R.string.grid_columns_count)) },
+ text = {
+ Column {
+ val interactionSource = remember { MutableInteractionSource() }
+ Text(text = stringResource(R.string.grid_columns_count_desc, count.toInt()))
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Slider(
+ value = count,
+ onValueChange = { count = it },
+ steps = 3,
+ valueRange = 1f..5f,
+ thumb = {
+ SliderDefaults.Thumb(
+ modifier = Modifier,
+ interactionSource = interactionSource,
+ )
+ }
+ )
+ }
+ })
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsView.kt
deleted file mode 100644
index bd3fa97..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/ShelfSettingsView.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-package org.xtimms.shirizu.sections.settings.shelf
-
-import androidx.compose.foundation.interaction.MutableInteractionSource
-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.asPaddingValues
-import androidx.compose.foundation.layout.height
-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.Category
-import androidx.compose.material.icons.outlined.GridView
-import androidx.compose.material.icons.outlined.Numbers
-import androidx.compose.material.icons.outlined.RssFeed
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-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.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.PreferenceItem
-import org.xtimms.shirizu.core.components.PreferenceSubtitle
-import org.xtimms.shirizu.core.components.PreferenceSwitch
-import org.xtimms.shirizu.core.components.PreferenceSwitchWithDivider
-import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
-import org.xtimms.shirizu.core.prefs.AppSettings
-import org.xtimms.shirizu.core.prefs.AppSettings.getInt
-import org.xtimms.shirizu.core.prefs.GRID_COLUMNS
-import org.xtimms.shirizu.core.prefs.TABS_MANGA_COUNT
-import org.xtimms.shirizu.core.prefs.TRACKER
-import org.xtimms.shirizu.sections.shelf.ShelfViewModel
-
-const val SHELF_SETTINGS_DESTINATION = "shelf_settings"
-
-@Composable
-fun ShelfSettingsView(
- shelfViewModel: ShelfViewModel = hiltViewModel(),
- navigateBack: () -> Unit,
- navigateToCategories: () -> Unit,
- navigateToTrackerSettings: () -> Unit = {}
-) {
-
- var isTrackerEnabled by remember { mutableStateOf(AppSettings.isTrackerEnabled()) }
- var showGridColumnsDialog by remember { mutableStateOf(false) }
-
- val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
-
- var isMangaCountInTabsEnabled by remember {
- mutableStateOf(AppSettings.isMangaCountInTabsEnabled())
- }
- var gridColumns by remember(showGridColumnsDialog) { mutableIntStateOf(GRID_COLUMNS.getInt()) }
-
- ScaffoldWithTopAppBar(
- title = stringResource(R.string.nav_shelf),
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- modifier = Modifier.padding(padding),
- contentPadding = PaddingValues(
- bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
- )
- ) {
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.categories))
- }
- item {
- PreferenceItem(
- title = stringResource(id = R.string.edit_categories),
- description = pluralStringResource(
- id = R.plurals.categories_count,
- count = categories.size,
- categories.size
- ),
- icon = Icons.Outlined.Category,
- onClick = {
- navigateToCategories()
- }
- )
- }
- 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 {
- PreferenceItem(
- title = stringResource(id = R.string.grid_columns_count),
- description = stringResource(
- id = R.string.grid_columns_count_desc,
- gridColumns
- ),
- icon = Icons.Outlined.GridView
- ) { showGridColumnsDialog = true }
- }
- item {
- PreferenceSubtitle(text = stringResource(id = R.string.updates))
- }
- item {
- PreferenceSwitchWithDivider(
- title = stringResource(id = R.string.auto_update),
- description = if (isTrackerEnabled) stringResource(id = R.string.on)
- else stringResource(id = R.string.off),
- icon = Icons.Outlined.RssFeed,
- isChecked = isTrackerEnabled,
- onClick = navigateToTrackerSettings,
- onChecked = {
- isTrackerEnabled = !isTrackerEnabled
- AppSettings.updateValue(TRACKER, isTrackerEnabled)
- }
- )
- }
- }
- }
-
- if (showGridColumnsDialog) {
- GridColumnsDialog(
- gridCount = gridColumns.toFloat()
- ) {
- showGridColumnsDialog = false
- }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun GridColumnsDialog(
- gridCount: Float,
- onDismissRequest: () -> Unit,
-) {
- var count by remember { mutableFloatStateOf(gridCount) }
- AlertDialog(
- onDismissRequest = onDismissRequest,
- dismissButton = {
- TextButton(onClick = onDismissRequest) {
- Text(stringResource(R.string.dismiss))
- }
- },
- confirmButton = {
- TextButton(onClick = {
- onDismissRequest()
- AppSettings.encodeInt(GRID_COLUMNS, count.toInt())
- }) {
- Text(stringResource(R.string.confirm))
- }
- },
- icon = { Icon(Icons.Outlined.GridView, null) },
- title = { Text(stringResource(R.string.grid_columns_count)) },
- text = {
- Column {
- val interactionSource = remember { MutableInteractionSource() }
- Text(text = stringResource(R.string.grid_columns_count_desc, count.toInt()))
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Slider(
- value = count,
- onValueChange = { count = it },
- steps = 3,
- valueRange = 1f..5f,
- thumb = {
- SliderDefaults.Thumb(
- modifier = Modifier,
- interactionSource = interactionSource,
- )
- }
- )
- }
- })
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/AddCategoryDialog.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/AddCategoryDialog.kt
deleted file mode 100644
index d66fd7a..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/AddCategoryDialog.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package org.xtimms.shirizu.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.shirizu.R
-import org.xtimms.shirizu.core.components.ConfirmButton
-import org.xtimms.shirizu.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/shirizu/sections/settings/shelf/categories/CategoriesScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesScreen.kt
new file mode 100644
index 0000000..92337be
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesScreen.kt
@@ -0,0 +1,112 @@
+package org.xtimms.shirizu.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.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.Category
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+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.shirizu.R
+import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
+import org.xtimms.shirizu.core.model.FavouriteCategory
+import org.xtimms.shirizu.core.ui.screens.EmptyScreen
+import org.xtimms.shirizu.utils.system.plus
+
+@Composable
+fun CategoriesScreen(
+ state: CategoryScreenState.Success,
+ onClickCreate: () -> Unit,
+ onClickRename: (FavouriteCategory) -> Unit,
+ onClickDelete: (FavouriteCategory) -> Unit,
+ onReorder: (List) -> Unit,
+ navigateUp: () -> Unit,
+) {
+
+ val lazyListState = rememberLazyListState()
+
+ ScaffoldWithClassicTopAppBar(
+ title = stringResource(R.string.edit_categories),
+ floatingActionButton = {
+ CategoryFloatingActionButton(onCreate = { onClickCreate() })
+ },
+ navigateBack = navigateUp
+ ) { paddingValues ->
+ if (state.isEmpty) {
+ EmptyScreen(
+ icon = Icons.Outlined.Category,
+ title = R.string.information_empty_category,
+ description = R.string.on,
+ modifier = Modifier.padding(paddingValues),
+ )
+ return@ScaffoldWithClassicTopAppBar
+ }
+
+ CategoryContent(
+ categories = state.categories,
+ lazyListState = lazyListState,
+ paddingValues = paddingValues +
+ PaddingValues(top = 8.dp) +
+ PaddingValues(horizontal = 8.dp),
+ onClickRename = onClickRename,
+ onClickDelete = onClickDelete,
+ onReorder = onReorder,
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun CategoryContent(
+ categories: List,
+ lazyListState: LazyListState,
+ paddingValues: PaddingValues,
+ onClickRename: (FavouriteCategory) -> Unit,
+ onClickDelete: (FavouriteCategory) -> Unit,
+ onReorder: (List) -> Unit,
+) {
+ LazyColumn(
+ state = lazyListState,
+ contentPadding = paddingValues,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ itemsIndexed(
+ items = categories,
+ key = { _, category -> "category-${category.id}" },
+ ) { index, category ->
+ CategoryListItem(
+ modifier = Modifier.animateItem(),
+ category = category,
+ canMoveUp = index != 0,
+ canMoveDown = index != categories.lastIndex,
+ onReorder = onReorder,
+ onRename = { onClickRename(category) },
+ onDelete = { onClickDelete(category) },
+ )
+ }
+ }
+}
+
+@Composable
+fun CategoryFloatingActionButton(
+ onCreate: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ExtendedFloatingActionButton(
+ text = { Text(text = stringResource(R.string.add_category)) },
+ icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
+ onClick = onCreate,
+ modifier = modifier,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesScreenModel.kt
new file mode 100644
index 0000000..d3b9ce2
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesScreenModel.kt
@@ -0,0 +1,114 @@
+package org.xtimms.shirizu.sections.settings.shelf.categories
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import cafe.adriel.voyager.core.model.StateScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.model.FavouriteCategory
+import org.xtimms.shirizu.core.model.ListModel
+import org.xtimms.shirizu.core.model.ListSortOrder
+import org.xtimms.shirizu.data.repository.FavouritesRepository
+import java.util.ArrayList
+import javax.inject.Inject
+
+class CategoriesScreenModel @Inject constructor(
+ private val favouritesRepository: FavouritesRepository,
+) : StateScreenModel(CategoryScreenState.Loading) {
+
+ private val _events: Channel = Channel()
+ val events = _events.receiveAsFlow()
+
+ init {
+ screenModelScope.launch {
+ favouritesRepository.observeCategoriesForLibrary()
+ .collectLatest { categories ->
+ mutableState.update {
+ CategoryScreenState.Success(
+ categories = categories
+ .toImmutableList(),
+ )
+ }
+ }
+ }
+ }
+
+ fun createCategory(name: String) {
+ screenModelScope.launch {
+ favouritesRepository.createCategory(name, ListSortOrder.NEWEST, isTrackerEnabled = true, isVisibleOnShelf = true)
+ }
+ }
+
+ fun deleteCategory(ids: Set) {
+ screenModelScope.launch {
+ favouritesRepository.removeCategories(ids)
+ }
+ }
+
+ fun reorder(snapshot: List) {
+ screenModelScope.launch {
+ val ids = snapshot.mapTo(ArrayList(snapshot.size)) { it.id }
+ if (ids.isNotEmpty()) {
+ favouritesRepository.reorderCategories(ids)
+ }
+ }
+ }
+
+ fun renameCategory(category: FavouriteCategory, name: String) {
+ screenModelScope.launch {
+ favouritesRepository.updateCategory(category.id, name, ListSortOrder.NEWEST, isVisibleOnShelf = true, isTrackerEnabled = true)
+ }
+ }
+
+ fun showDialog(dialog: CategoryDialog) {
+ mutableState.update {
+ when (it) {
+ CategoryScreenState.Loading -> it
+ is CategoryScreenState.Success -> it.copy(dialog = dialog)
+ }
+ }
+ }
+
+ fun dismissDialog() {
+ mutableState.update {
+ when (it) {
+ CategoryScreenState.Loading -> it
+ is CategoryScreenState.Success -> it.copy(dialog = null)
+ }
+ }
+ }
+}
+
+sealed interface CategoryDialog {
+ data object Create : CategoryDialog
+ data class Rename(val category: FavouriteCategory) : CategoryDialog
+ data class Delete(val category: FavouriteCategory) : CategoryDialog
+}
+
+sealed interface CategoryEvent {
+ sealed class LocalizedMessage(@StringRes val stringRes: Int) : CategoryEvent
+ data object InternalError : LocalizedMessage(R.string.error_occured)
+}
+
+sealed interface CategoryScreenState {
+
+ @Immutable
+ data object Loading : CategoryScreenState
+
+ @Immutable
+ data class Success(
+ val categories: ImmutableList,
+ val dialog: CategoryDialog? = null,
+ ) : CategoryScreenState {
+
+ val isEmpty: Boolean
+ get() = categories.isEmpty()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesView.kt
deleted file mode 100644
index 0e6c28b..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoriesView.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package org.xtimms.shirizu.sections.settings.shelf.categories
-
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.VisibilityThreshold
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-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.IntOffset
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.xtimms.shirizu.R
-import org.xtimms.shirizu.core.components.ScaffoldWithClassicTopAppBar
-import org.xtimms.shirizu.sections.shelf.ShelfViewModel
-import org.xtimms.shirizu.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 = {
- showAddCategoryDialog = true
- }
- ) {
- Icon(
- imageVector = Icons.Outlined.NewLabel,
- contentDescription = "New category"
- )
- Text(
- text = stringResource(R.string.add),
- modifier = Modifier.padding(start = 16.dp, end = 8.dp)
- )
- }
- },
- navigateBack = navigateBack
- ) { padding ->
- LazyColumn(
- 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/shirizu/sections/settings/shelf/categories/CategoryDialogs.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryDialogs.kt
new file mode 100644
index 0000000..518f9ab
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryDialogs.kt
@@ -0,0 +1,192 @@
+package org.xtimms.shirizu.sections.settings.shelf.categories
+
+import androidx.compose.foundation.layout.Column
+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.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+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.material3.TextButton
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.delay
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.components.ConfirmButton
+import org.xtimms.shirizu.core.components.DismissButton
+import kotlin.time.Duration.Companion.seconds
+
+@Composable
+fun CategoryCreateDialog(
+ onDismissRequest: () -> Unit,
+ onCreate: (String) -> Unit,
+ categories: ImmutableList,
+) {
+ var name by remember { mutableStateOf("") }
+
+ val focusRequester = remember { FocusRequester() }
+ val nameAlreadyExists = remember(name) { categories.contains(name) }
+
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(
+ enabled = name.isNotEmpty() && !nameAlreadyExists,
+ onClick = {
+ onCreate(name)
+ onDismissRequest()
+ },
+ ) {
+ Text(text = stringResource(R.string.add))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.add_category))
+ },
+ text = {
+ OutlinedTextField(
+ modifier = Modifier
+ .focusRequester(focusRequester),
+ value = name,
+ onValueChange = { name = it },
+ label = {
+ Text(text = stringResource(R.string.name))
+ },
+ supportingText = {
+ val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
+ R.string.error_category_exists
+ } else {
+ R.string.information_required_plain
+ }
+ Text(text = stringResource(msgRes))
+ },
+ isError = name.isNotEmpty() && nameAlreadyExists,
+ singleLine = true,
+ )
+ },
+ )
+
+ LaunchedEffect(focusRequester) {
+ // TODO: https://issuetracker.google.com/issues/204502668
+ delay(0.1.seconds)
+ focusRequester.requestFocus()
+ }
+}
+
+@Composable
+fun CategoryRenameDialog(
+ onDismissRequest: () -> Unit,
+ onRename: (String) -> Unit,
+ categories: ImmutableList,
+ category: String,
+) {
+ var name by remember { mutableStateOf(category) }
+ var valueHasChanged by remember { mutableStateOf(false) }
+
+ val focusRequester = remember { FocusRequester() }
+ val nameAlreadyExists = remember(name) { categories.contains(name) }
+
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(
+ enabled = valueHasChanged && !nameAlreadyExists,
+ onClick = {
+ onRename(name)
+ onDismissRequest()
+ },
+ ) {
+ Text(text = stringResource(R.string.okay))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.action_rename_category))
+ },
+ text = {
+ OutlinedTextField(
+ modifier = Modifier.focusRequester(focusRequester),
+ value = name,
+ onValueChange = {
+ valueHasChanged = name != it
+ name = it
+ },
+ label = { Text(text = stringResource(R.string.name)) },
+ supportingText = {
+ val msgRes = if (valueHasChanged && nameAlreadyExists) {
+ R.string.error_category_exists
+ } else {
+ R.string.information_required_plain
+ }
+ Text(text = stringResource(msgRes))
+ },
+ isError = valueHasChanged && nameAlreadyExists,
+ singleLine = true,
+ )
+ },
+ )
+
+ LaunchedEffect(focusRequester) {
+ // TODO: https://issuetracker.google.com/issues/204502668
+ delay(0.1.seconds)
+ focusRequester.requestFocus()
+ }
+}
+
+@Composable
+fun CategoryDeleteDialog(
+ onDismissRequest: () -> Unit,
+ onDelete: () -> Unit,
+ category: String,
+) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(onClick = {
+ onDelete()
+ onDismissRequest()
+ }) {
+ Text(text = stringResource(R.string.okay))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ title = {
+ Text(text = stringResource(R.string.delete_category))
+ },
+ text = {
+ Text(text = stringResource(R.string.delete_category_confirmation, category))
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryListItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryListItem.kt
index 54036c0..eacca03 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryListItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryListItem.kt
@@ -12,10 +12,8 @@ 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
@@ -23,15 +21,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.R
-import org.xtimms.shirizu.sections.shelf.FavouriteTabModel
+import org.xtimms.shirizu.core.model.FavouriteCategory
+import org.xtimms.shirizu.sections.shelf.ShelfCategory
@Composable
fun CategoryListItem(
- category: FavouriteTabModel,
+ category: FavouriteCategory,
canMoveUp: Boolean,
canMoveDown: Boolean,
- onMoveUp: (FavouriteTabModel) -> Unit,
- onMoveDown: (FavouriteTabModel) -> Unit,
+ onReorder: (List) -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
@@ -59,13 +57,13 @@ fun CategoryListItem(
}
Row {
IconButton(
- onClick = { onMoveUp(category) },
+ onClick = { onReorder(listOf(category)) },
enabled = canMoveUp,
) {
Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null)
}
IconButton(
- onClick = { onMoveDown(category) },
+ onClick = { onReorder(listOf(category)) },
enabled = canMoveDown,
) {
Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null)
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryScreen.kt
new file mode 100644
index 0000000..27227f8
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/CategoryScreen.kt
@@ -0,0 +1,78 @@
+package org.xtimms.shirizu.sections.settings.shelf.categories
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.util.fastMap
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.collectLatest
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.utils.lang.Screen
+import org.xtimms.shirizu.utils.system.toast
+
+class CategoryScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+ val context = LocalContext.current
+ val navigator = LocalNavigator.currentOrThrow
+ val screenModel = getScreenModel()
+
+ val state by screenModel.state.collectAsState()
+
+ if (state is CategoryScreenState.Loading) {
+ LoadingScreen()
+ return
+ }
+
+ val successState = state as CategoryScreenState.Success
+
+ CategoriesScreen(
+ state = successState,
+ onClickCreate = { screenModel.showDialog(CategoryDialog.Create) },
+ onClickRename = { screenModel.showDialog(CategoryDialog.Rename(it)) },
+ onClickDelete = { screenModel.showDialog(CategoryDialog.Delete(it)) },
+ onReorder = screenModel::reorder,
+ navigateUp = navigator::pop,
+ )
+
+ when (val dialog = successState.dialog) {
+ null -> {}
+ CategoryDialog.Create -> {
+ CategoryCreateDialog(
+ onDismissRequest = screenModel::dismissDialog,
+ onCreate = screenModel::createCategory,
+ categories = successState.categories.fastMap { it.title }.toImmutableList(),
+ )
+ }
+ is CategoryDialog.Rename -> {
+ CategoryRenameDialog(
+ onDismissRequest = screenModel::dismissDialog,
+ onRename = { screenModel.renameCategory(dialog.category, it) },
+ categories = successState.categories.fastMap { it.title }.toImmutableList(),
+ category = dialog.category.title,
+ )
+ }
+ is CategoryDialog.Delete -> {
+ CategoryDeleteDialog(
+ onDismissRequest = screenModel::dismissDialog,
+ onDelete = { screenModel.deleteCategory(setOf(dialog.category.id)) },
+ category = dialog.category.title,
+ )
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ screenModel.events.collectLatest { event ->
+ if (event is CategoryEvent.LocalizedMessage) {
+ context.toast(event.stringRes)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/interactor/ReorderCategory.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/interactor/ReorderCategory.kt
deleted file mode 100644
index 70c9579..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/shelf/categories/interactor/ReorderCategory.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.xtimms.shirizu.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.shirizu.core.model.FavouriteCategory
-import org.xtimms.shirizu.data.repository.FavouritesRepository
-import org.xtimms.shirizu.utils.lang.processLifecycleScope
-import org.xtimms.shirizu.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/shirizu/sections/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourceCatalogItem.kt
index c701735..b4a9f57 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourceCatalogItem.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourceCatalogItem.kt
@@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Add
-import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
@@ -19,13 +17,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.xtimms.shirizu.core.AsyncImageImpl
+import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.core.parser.favicon.faviconUri
import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable
fun SourceCatalogItem(
- coil: ImageLoader,
source: MangaSource,
) {
@@ -35,9 +32,8 @@ fun SourceCatalogItem(
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- AsyncImageImpl(
+ ShirizuAsyncImage(
modifier = Modifier.size(42.dp),
- coil = coil,
contentDescription = null,
model = source.faviconUri()
)
@@ -57,6 +53,6 @@ fun SourceCatalogItem(
@Composable
fun SourceCatalogItemPreview() {
ShirizuTheme {
- SourceCatalogItem(coil = ImageLoader(LocalContext.current), source = MangaSource.MANGADEX)
+ SourceCatalogItem(source = MangaSource.MANGADEX)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourcesCatalogPager.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourcesCatalogPager.kt
index f92795c..dc20b5b 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourcesCatalogPager.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/sources/catalog/SourcesCatalogPager.kt
@@ -59,7 +59,6 @@ fun SourcesCatalogPager(
) { item ->
item.items.forEach { source ->
SourceCatalogItem(
- coil = coil,
source = source.source,
)
}
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/CleanDialog.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/CleanDialog.kt
index 9e89301..134e447 100644
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/CleanDialog.kt
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/CleanDialog.kt
@@ -11,6 +11,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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
@@ -22,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cafe.adriel.voyager.hilt.getScreenModel
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.ConfirmButton
import org.xtimms.shirizu.core.components.DialogCheckBoxItem
@@ -38,8 +40,8 @@ fun CleanDialog(
onConfirm: (isPagesCacheSelected: Boolean, isThumbnailCacheSelected: Boolean, isNetworkCacheSelected: Boolean) -> Unit = { _, _, _ -> }
) {
- val viewModel: StorageViewModel = hiltViewModel()
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ // val screenModel = getScreenModel()
+ // val state by screenModel.state.collectAsState()
var pagesCache by remember {
mutableStateOf(isPagesCacheSelected)
@@ -95,32 +97,21 @@ fun CleanDialog(
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
Spacer(modifier = Modifier.height(4.dp))
- val summary = StringBuilder().run {
- append(
- FileSize.BYTES.format(
- LocalContext.current,
- (uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize).toFloat()
- )
- )
- append("")
- }
- Text(
- text = stringResource(R.string.free_up_space_summary) + " " + summary,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp),
- )
+ //val summary = StringBuilder().run {
+ // append(
+ // FileSize.BYTES.format(
+ // LocalContext.current,
+ // (uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize).toFloat()
+ // )
+ // )
+ // append("")
+ // }
+ //Text(
+ // text = stringResource(R.string.free_up_space_summary) + " " + summary,
+ // modifier = Modifier
+ // .fillMaxWidth()
+ // .padding(horizontal = 24.dp),
+ //)
}
})
-}
-
-@Preview
-@Composable
-private fun CleanDialogPreview() {
- CleanDialog(
- onDismissRequest = {},
- isPagesCacheSelected = false,
- isThumbnailsCacheSelected = false,
- isNetworkCacheSelected = false
- )
}
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageEvent.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageEvent.kt
deleted file mode 100644
index f3c399a..0000000
--- a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageEvent.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.xtimms.shirizu.sections.settings.storage
-
-import org.xtimms.shirizu.core.base.event.UiEvent
-
-interface StorageEvent : UiEvent
\ No newline at end of file
diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreen.kt
new file mode 100644
index 0000000..6ec1038
--- /dev/null
+++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreen.kt
@@ -0,0 +1,129 @@
+package org.xtimms.shirizu.sections.settings.storage
+
+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.AutoStories
+import androidx.compose.material.icons.outlined.CleaningServices
+import androidx.compose.material.icons.outlined.Image
+import androidx.compose.material.icons.outlined.NetworkWifi
+import androidx.compose.material.icons.outlined.SdStorage
+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.res.stringResource
+import cafe.adriel.voyager.hilt.getScreenModel
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import org.xtimms.shirizu.R
+import org.xtimms.shirizu.core.cache.CacheDir
+import org.xtimms.shirizu.core.components.PreferenceStorageHeader
+import org.xtimms.shirizu.core.components.PreferenceStorageItem
+import org.xtimms.shirizu.core.components.PreferencesHintCard
+import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar
+import org.xtimms.shirizu.core.ui.screens.LoadingScreen
+import org.xtimms.shirizu.data.CACHE_SIZE_MAX
+import org.xtimms.shirizu.utils.lang.Screen
+
+object StorageScreen : Screen() {
+
+ @Composable
+ override fun Content() {
+
+ val navigator = LocalNavigator.currentOrThrow
+ val screenModel = getScreenModel