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 | -|:--------------------------------------------:|:------------------------------------------------:|:------------------------------------------------:| -| ![Shelf light theme](./images/shelf.png) | ![Details light theme](./images/details.png) | ![History light theme](./images/history.png) | -| ![Shelf dark theme](./images/shelf_dark.png) | ![Details dark theme](./images/details_dark.png) | ![History dark theme](./images/history_dark.png) | +| Library | Explore | Search | +|:------------------------------------------:|:--------------------------------------------:|:-------------------------------------------:| +| ![Shelf light theme](./images/library.png) | ![Details light theme](./images/explore.png) | ![History light theme](./images/search.png) | ## 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() + val state by screenModel.state.collectAsState() + + ScaffoldWithTopAppBar( + title = stringResource(R.string.storage), + navigateBack = navigator::pop + ) { padding -> + state.let { + if (it.isLoading) { + LoadingScreen(Modifier.padding(padding)) + } else { + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ) + ) { + item { + PreferenceStorageHeader( + used = (state.httpCacheSize + state.thumbnailsCache + state.pagesCache).toFloat(), + total = state.availableSpace.toFloat() + ) + } + item { + PreferencesHintCard( + title = stringResource(id = R.string.free_up_space), + description = stringResource(id = R.string.free_up_space_hint), + icon = Icons.Outlined.CleaningServices + ) { + screenModel.showCleanDialog( + state.pagesCache, + state.thumbnailsCache, + state.availableSpace, + state.httpCacheSize + ) + } + } + item { + PreferenceStorageItem( + total = state.availableSpace.toFloat(), + title = stringResource(id = R.string.saved_manga), + icon = Icons.Outlined.SdStorage, + ) + } + item { + PreferenceStorageItem( + total = state.availableSpace.toFloat(), + title = stringResource(id = R.string.pages_cache), + icon = Icons.Outlined.AutoStories, + used = state.pagesCache.toFloat() + ) + } + item { + PreferenceStorageItem( + total = state.availableSpace.toFloat(), + title = stringResource(id = R.string.thumbnails_cache), + icon = Icons.Outlined.Image, + used = state.thumbnailsCache.toFloat() + ) + } + item { + PreferenceStorageItem( + total = CACHE_SIZE_MAX.toFloat(), + title = stringResource(id = R.string.network_cache), + icon = Icons.Outlined.NetworkWifi, + used = state.httpCacheSize.toFloat() + ) + } + } + } + } + state.dialog?.let { + CleanDialog( + isPagesCacheSelected = false, + isNetworkCacheSelected = false, + isThumbnailsCacheSelected = false, + onDismissRequest = screenModel::closeDialog, + onConfirm = { isPagesCacheSelected, isThumbnailCacheSelected, isNetworkCacheSelected -> + if (isPagesCacheSelected) screenModel.clearCache(CacheDir.PAGES) + if (isThumbnailCacheSelected) screenModel.clearCache(CacheDir.THUMBS) + if (isNetworkCacheSelected) screenModel.clearHttpCache() + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreenModel.kt similarity index 59% rename from app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageViewModel.kt rename to app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreenModel.kt index fa4fa21..6926dbf 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageViewModel.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageScreenModel.kt @@ -1,29 +1,26 @@ package org.xtimms.shirizu.sections.settings.storage -import dagger.hilt.android.lifecycle.HiltViewModel +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.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okhttp3.Cache -import org.xtimms.shirizu.core.base.viewmodel.BaseViewModel import org.xtimms.shirizu.core.cache.CacheDir import org.xtimms.shirizu.data.LocalStorageManager import javax.inject.Inject -@HiltViewModel -class StorageViewModel @Inject constructor( +class StorageScreenModel @Inject constructor( private val storageManager: LocalStorageManager, - private val httpCache: Cache, -) : BaseViewModel(), StorageEvent { + private val httpCache: Cache +) : StateScreenModel(State()) { - private var storageUsageJob: Job? = null init { - storageUsageJob = launchJob(Dispatchers.Default) { - setLoading(true) - mutableUiState.update { + screenModelScope.launch(Dispatchers.Default) { + mutableState.update { it.copy( availableSpace = storageManager.computeAvailableSize(), pagesCache = storageManager.computeCacheSize(CacheDir.PAGES), @@ -32,16 +29,15 @@ class StorageViewModel @Inject constructor( isLoading = false ) } - setLoading(false) } } fun clearCache(cache: CacheDir) { - launchJob(Dispatchers.Default) { + screenModelScope.launch(Dispatchers.Default) { try { storageManager.clearCache(cache) storageManager.computeCacheSize(cache) - mutableUiState.update { + mutableState.update { it.copy( availableSpace = storageManager.computeAvailableSize(), pagesCache = storageManager.computeCacheSize(CacheDir.PAGES), @@ -57,13 +53,13 @@ class StorageViewModel @Inject constructor( } fun clearHttpCache() { - launchJob(Dispatchers.Default) { + screenModelScope.launch(Dispatchers.Default) { try { val size = runInterruptible(Dispatchers.IO) { httpCache.evictAll() httpCache.size() } - mutableUiState.update { + mutableState.update { it.copy( availableSpace = storageManager.computeAvailableSize(), pagesCache = storageManager.computeCacheSize(CacheDir.PAGES), @@ -78,6 +74,39 @@ class StorageViewModel @Inject constructor( } } - override val mutableUiState = MutableStateFlow(StorageUiState()) + fun showCleanDialog( + pagesCache: Long, + thumbnailsCache: Long, + availableSpace: Long, + httpCacheSize: Long, + ) { + mutableState.update { + it.copy( + dialog = Dialog( + pagesCache, thumbnailsCache, availableSpace, httpCacheSize + ) + ) + } + } + + fun closeDialog() { + mutableState.update { it.copy(dialog = null) } + } + + data class Dialog( + val pagesCache: Long = -1L, + val thumbnailsCache: Long = -1L, + val availableSpace: Long = -1L, + val httpCacheSize: Long = -1L, + ) + @Immutable + data class State( + val pagesCache: Long = -1L, + val thumbnailsCache: Long = -1L, + val availableSpace: Long = -1L, + val httpCacheSize: Long = -1L, + val dialog: Dialog? = null, + val isLoading: Boolean = true, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageUiState.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageUiState.kt deleted file mode 100644 index 809febc..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageUiState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.xtimms.shirizu.sections.settings.storage - -import org.xtimms.shirizu.core.base.state.UiState - -data class StorageUiState( - val pagesCache: Long = -1L, - val thumbnailsCache: Long = -1L, - val availableSpace: Long = -1L, - val httpCacheSize: Long = -1L, - 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/settings/storage/StorageView.kt b/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageView.kt deleted file mode 100644 index b6b8dca..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/settings/storage/StorageView.kt +++ /dev/null @@ -1,116 +0,0 @@ -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.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.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.data.CACHE_SIZE_MAX - -const val STORAGE_DESTINATION = "storage" - -@Composable -fun StorageView( - viewModel: StorageViewModel = hiltViewModel(), - navigateBack: () -> Unit, -) { - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - var showCleanDialog by remember { mutableStateOf(false) } - - ScaffoldWithTopAppBar( - title = stringResource(R.string.storage), - navigateBack = navigateBack - ) { padding -> - LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues( - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - ) - ) { - item { - PreferenceStorageHeader( - used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(), - total = uiState.availableSpace.toFloat() - ) - } - item { - PreferencesHintCard( - title = stringResource(id = R.string.free_up_space), - description = stringResource(id = R.string.free_up_space_hint), - icon = Icons.Outlined.CleaningServices - ) { - showCleanDialog = true - } - } - item { - PreferenceStorageItem( - total = uiState.availableSpace.toFloat(), - title = stringResource(id = R.string.saved_manga), - icon = Icons.Outlined.SdStorage, - ) - } - item { - PreferenceStorageItem( - total = uiState.availableSpace.toFloat(), - title = stringResource(id = R.string.pages_cache), - icon = Icons.Outlined.AutoStories, - used = uiState.pagesCache.toFloat() - ) - } - item { - PreferenceStorageItem( - total = uiState.availableSpace.toFloat(), - title = stringResource(id = R.string.thumbnails_cache), - icon = Icons.Outlined.Image, - used = uiState.thumbnailsCache.toFloat() - ) - } - item { - PreferenceStorageItem( - total = CACHE_SIZE_MAX.toFloat(), - title = stringResource(id = R.string.network_cache), - icon = Icons.Outlined.NetworkWifi, - used = uiState.httpCacheSize.toFloat() - ) - } - } - } - if (showCleanDialog) { - CleanDialog( - onDismissRequest = { showCleanDialog = false }, - isPagesCacheSelected = false, - isNetworkCacheSelected = false, - isThumbnailsCacheSelected = false, - onConfirm = { isPagesCacheSelected, isThumbnailCacheSelected, isNetworkCacheSelected -> - if (isPagesCacheSelected) viewModel.clearCache(CacheDir.PAGES) - if (isThumbnailCacheSelected) viewModel.clearCache(CacheDir.THUMBS) - if (isNetworkCacheSelected) viewModel.clearHttpCache() - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/LazyShelfGrid.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/LazyShelfGrid.kt index a0da672..b25b899 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/LazyShelfGrid.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/LazyShelfGrid.kt @@ -4,10 +4,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridScope -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.xtimms.shirizu.core.components.FastScrollLazyVerticalGrid import org.xtimms.shirizu.utils.system.plus @Composable @@ -17,7 +17,7 @@ internal fun LazyShelfGrid( contentPadding: PaddingValues, content: LazyGridScope.() -> Unit, ) { - LazyVerticalGrid( + FastScrollLazyVerticalGrid( columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns), modifier = modifier, contentPadding = contentPadding + PaddingValues(8.dp), diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/FavouriteTabModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfCategory.kt similarity index 72% rename from app/src/main/java/org/xtimms/shirizu/sections/shelf/FavouriteTabModel.kt rename to app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfCategory.kt index 6634fbd..dbcfadc 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/FavouriteTabModel.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfCategory.kt @@ -2,13 +2,13 @@ package org.xtimms.shirizu.sections.shelf import org.xtimms.shirizu.core.model.ListModel -data class FavouriteTabModel( +data class ShelfCategory( val id: Long, val title: String, val mangaCount: Int, ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { - return other is FavouriteTabModel && other.id == id + return other is ShelfCategory && other.id == id } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfContent.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfContent.kt new file mode 100644 index 0000000..5b456b4 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfContent.kt @@ -0,0 +1,88 @@ +package org.xtimms.shirizu.sections.shelf + +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.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import coil.ImageLoader +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.shirizu.core.model.FavouriteCategory +import kotlin.time.Duration.Companion.seconds + +@Composable +fun ShelfContent( + categories: List, + searchQuery: String?, + selection: List, + contentPadding: PaddingValues, + currentPage: () -> Int, + hasActiveFilters: Boolean, + onChangeCurrentPage: (Int) -> Unit, + onMangaClicked: (Manga) -> Unit, + onToggleSelection: (Manga) -> Unit, + onToggleRangeSelection: (Manga) -> Unit, + onRefresh: (FavouriteCategory?) -> Boolean, + onGlobalSearchClicked: () -> Unit, + getNumberOfMangaForCategory: (FavouriteCategory) -> Int?, + getShelfForPage: (Int) -> List, +) { + Column( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ) { + val pagerState = rememberPagerState(1) { categories.size } + + val scope = rememberCoroutineScope() + var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } + + if (categories.size > 1) { + LaunchedEffect(categories) { + if (categories.size <= pagerState.currentPage) { + pagerState.scrollToPage(categories.size - 1) + } + } + ShelfTabs( + categories = categories, + pagerState = pagerState, + ) { scope.launch { pagerState.animateScrollToPage(it) } } + } + + val notSelectionMode = selection.isEmpty() + val onClickManga = { manga: Manga -> + if (notSelectionMode) { + onMangaClicked(manga) + } else { + onToggleSelection(manga) + } + } + + ShelfPager( + state = pagerState, + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + selectedManga = selection, + getShelfForPage = getShelfForPage, + onClickManga = onClickManga, + onLongClickManga = onToggleRangeSelection, + ) + + LaunchedEffect(pagerState.currentPage) { + onChangeCurrentPage(pagerState.currentPage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfGrid.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfGrid.kt index b9597ad..4d3cec3 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfGrid.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfGrid.kt @@ -7,17 +7,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.util.fastAny import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.shirizu.core.components.MangaGridItem +import org.xtimms.shirizu.core.model.MangaCover @Composable internal fun ShelfGrid( - coil: ImageLoader, - items: List, + items: List, columns: Int, contentPadding: PaddingValues, selection: List, - onClick: (ShelfManga) -> Unit, - onLongClick: (ShelfManga) -> Unit, + onClick: (Manga) -> Unit, + onLongClick: (Manga) -> Unit, ) { LazyShelfGrid( modifier = Modifier.fillMaxSize(), @@ -26,15 +27,15 @@ internal fun ShelfGrid( ) { items( items = items, - contentType = { "shelf_grid_item" }, - ) { shelfItem -> - val manga = shelfItem.manga + contentType = { "library_comfortable_grid_item" }, + ) { libraryItem -> + val shelfManga = libraryItem.shelfManga MangaGridItem( - coil = coil, - manga = manga, - isSelected = selection.fastAny { it.id == shelfItem.id }, - onLongClick = { onLongClick(shelfItem) }, - onClick = { onClick(shelfItem) }, + isSelected = selection.fastAny { it.id == libraryItem.shelfManga.id }, + title = shelfManga.manga.title, + manga = shelfManga.manga, + onLongClick = { onLongClick(libraryItem.shelfManga.manga) }, + onClick = { onClick(libraryItem.shelfManga.manga) }, ) } } diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfItem.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfItem.kt index 380c309..00ab939 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfItem.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfItem.kt @@ -1,9 +1,6 @@ package org.xtimms.shirizu.sections.shelf data class ShelfItem( - val libraryManga: ShelfManga, - val downloadCount: Long = -1, - val unreadCount: Long = -1, + val shelfManga: ShelfManga, val isLocal: Boolean = false, - val sourceLanguage: String = "", ) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfManga.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfManga.kt index 3572a5e..c27b745 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfManga.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfManga.kt @@ -4,6 +4,7 @@ import org.koitharu.kotatsu.parsers.model.Manga data class ShelfManga( val manga: Manga, + val category: Long, ) { val id: Long = manga.id } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfPager.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfPager.kt index 8c96334..dfd4006 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfPager.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfPager.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.shirizu.R import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.ui.screens.EmptyScreen @@ -22,11 +23,12 @@ import org.xtimms.shirizu.utils.system.plus @Composable fun ShelfPager( - coil: ImageLoader, state: PagerState, contentPadding: PaddingValues, - getShelfForPage: (Int) -> List, - navigateToDetails: (ShelfManga) -> Unit, + selectedManga: List, + getShelfForPage: (Int) -> List, + onClickManga: (Manga) -> Unit, + onLongClickManga: (Manga) -> Unit, ) { HorizontalPager( modifier = Modifier.fillMaxSize(), @@ -46,13 +48,12 @@ fun ShelfPager( } ShelfGrid( - coil = coil, items = library, columns = AppSettings.getGridColumnsCount().toInt(), contentPadding = contentPadding, - selection = listOf(), - onClick = navigateToDetails, - onLongClick = { }, + selection = selectedManga, + onClick = onClickManga, + onLongClick = onLongClickManga, ) } } diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfScreenModel.kt new file mode 100644 index 0000000..2a25db8 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfScreenModel.kt @@ -0,0 +1,117 @@ +package org.xtimms.shirizu.sections.shelf + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.util.fastDistinctBy +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.shirizu.core.model.FavouriteCategory +import org.xtimms.shirizu.core.model.ListSortOrder +import org.xtimms.shirizu.core.model.isLocal +import org.xtimms.shirizu.data.repository.FavouritesRepository +import javax.inject.Inject + +/** + * Typealias for the library manga, using the category as keys, and list of manga as values. + */ +typealias ShelfMap = Map> + +class ShelfScreenModel @Inject constructor( + private val favouritesRepository: FavouritesRepository, +) : StateScreenModel(State()) { + + var activeCategoryIndex: Int = 0 + + init { + screenModelScope.launch(Dispatchers.IO) { + getShelfFlow().collectLatest { + mutableState.update { state -> + state.copy( + isLoading = false, + shelf = it + ) + } + } + } + } + + private fun getShelfFlow(): Flow { + val shelfMangasFlow = favouritesRepository.observeAllShelfManga(ListSortOrder.NEWEST) + .map { shelfManga -> shelfManga.map { + ShelfItem( + it, + isLocal = it.manga.isLocal, + ) + }.groupBy { it.shelfManga.category } + } + return combine(favouritesRepository.observeCategories(), shelfMangasFlow) { categories, mangas -> + categories.associateWith { mangas[it.id].orEmpty() } + } + } + + sealed interface Dialog { + data object SettingsSheet : Dialog + data object ChangeCategory : Dialog + data class DeleteManga(val manga: List) : Dialog + } + + @Immutable + private data class ItemPreferences( + val downloadBadge: Boolean, + val localBadge: Boolean, + val languageBadge: Boolean, + ) + + @Immutable + data class State( + val isLoading: Boolean = true, + val shelf: ShelfMap = emptyMap(), + val searchQuery: String? = null, + val selection: PersistentList = persistentListOf(), + val hasActiveFilters: Boolean = false, + val showMangaCount: Boolean = false, + val showMangaContinueButton: Boolean = false, + val dialog: Dialog? = null, + ) { + + private val shelfCount by lazy { + shelf.values + .flatten() + .fastDistinctBy { it.shelfManga.manga.id } + .size + } + + val isShelfEmpty by lazy { shelfCount == 0 } + + val selectionMode = selection.isNotEmpty() + + val categories = shelf.keys.toList() + + fun getShelfItemsByCategoryId(categoryId: Long): List? { + return shelf.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } } + } + + fun getShelfItemsByPage(page: Int): List { + return shelf.values.toTypedArray().getOrNull(page).orEmpty() + } + + fun getMangaCountForCategory(category: FavouriteCategory): Int? { + return if (showMangaCount || !searchQuery.isNullOrEmpty()) shelf[category]?.size else null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTab.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTab.kt new file mode 100644 index 0000000..c0679a0 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTab.kt @@ -0,0 +1,101 @@ +package org.xtimms.shirizu.sections.shelf + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.LocalLibrary +import androidx.compose.material3.ExperimentalMaterial3Api +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.graphics.vector.rememberVectorPainter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +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 cafe.adriel.voyager.navigator.tab.TabOptions +import org.xtimms.shirizu.R +import org.xtimms.shirizu.core.components.Scaffold +import org.xtimms.shirizu.core.components.LibraryBottomActionMenu +import org.xtimms.shirizu.core.ui.screens.EmptyScreen +import org.xtimms.shirizu.core.ui.screens.LoadingScreen +import org.xtimms.shirizu.sections.details.DetailsScreen +import org.xtimms.shirizu.utils.lang.NoLiftingAppBarScreen +import org.xtimms.shirizu.utils.lang.Tab + +@OptIn(ExperimentalMaterial3Api::class) +object ShelfTab : Tab, NoLiftingAppBarScreen { + + override val options: TabOptions + @Composable + get() { + val image = Icons.Outlined.LocalLibrary + return TabOptions( + index = 0u, + title = stringResource(R.string.nav_shelf), + icon = rememberVectorPainter(image), + ) + } + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + val screenModel = getScreenModel() + val state by screenModel.state.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + bottomBar = { + LibraryBottomActionMenu( + visible = state.selectionMode, + onDeleteClicked = {} + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { contentPadding -> + when { + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) + state.isShelfEmpty -> { + EmptyScreen( + icon = Icons.Outlined.Close, + title = R.string.empty_here, + description = R.string.information_no_manga_category, + modifier = Modifier.padding(contentPadding), + ) + } + else -> { + ShelfContent( + categories = state.categories, + searchQuery = state.searchQuery, + selection = state.selection, + contentPadding = contentPadding, + currentPage = { screenModel.activeCategoryIndex }, + hasActiveFilters = state.hasActiveFilters, + onChangeCurrentPage = { }, + onMangaClicked = { navigator.push(DetailsScreen(it)) }, + onToggleSelection = { }, + onToggleRangeSelection = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onRefresh = { false }, + onGlobalSearchClicked = { }, + getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, + ) { state.getShelfItemsByPage(it) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTabs.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTabs.kt index 068c6d2..0425755 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTabs.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfTabs.kt @@ -16,10 +16,10 @@ import org.xtimms.shirizu.core.components.TabText import org.xtimms.shirizu.core.model.FavouriteCategory import org.xtimms.shirizu.core.prefs.AppSettings -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ShelfTabs( - categories: List, + categories: List, pagerState: PagerState, onTabItemClick: (Int) -> Unit, ) { @@ -40,7 +40,7 @@ internal fun ShelfTabs( text = { TabText( text = category.title, - badgeCount = if (AppSettings.isMangaCountInTabsEnabled()) category.mangaCount else null + badgeCount = null ) }, unselectedContentColor = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfView.kt deleted file mode 100644 index 16cc184..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfView.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.xtimms.shirizu.sections.shelf - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -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.Modifier -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.ImageLoader -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.xtimms.shirizu.core.collapsable -import org.xtimms.shirizu.core.components.PullRefresh -import kotlin.time.Duration.Companion.seconds - -const val SHELF_DESTINATION = "shelf" - -@Composable -fun ShelfView( - coil: ImageLoader, - currentPage: () -> Int, - showPageTabs: Boolean, - padding: PaddingValues, - navigateToDetails: (Long) -> Unit, - onRefresh: (FavouriteTabModel?) -> Boolean, -) { - - ShelfViewContent( - coil = coil, - currentPage = currentPage, - showPageTabs = showPageTabs, - padding = padding, - navigateToDetails = navigateToDetails, - onRefresh = onRefresh, - ) -} - -@Composable -fun ShelfViewContent( - coil: ImageLoader, - viewModel: ShelfViewModel = hiltViewModel(), - currentPage: () -> Int, - showPageTabs: Boolean, - padding: PaddingValues, - navigateToDetails: (Long) -> Unit, - onRefresh: (FavouriteTabModel?) -> Boolean, -) { - - val categories by viewModel.categories.collectAsStateWithLifecycle(emptyList()) - val mangas by viewModel.mangas.collectAsStateWithLifecycle(emptyList()) - - Column( - modifier = Modifier - .padding(padding) - ) { - val pagerState = rememberPagerState(0) { categories.size } - val scope = rememberCoroutineScope() - - var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } - - if (categories.isNotEmpty()) { - if (showPageTabs) { - ShelfTabs( - categories = categories, - pagerState = pagerState, - ) { scope.launch { pagerState.animateScrollToPage(it) } } - } - } - - val onClickManga = { manga: ShelfManga -> - navigateToDetails(manga.id) - } - - PullRefresh( - refreshing = isRefreshing, - onRefresh = { - val started = onRefresh(categories[currentPage()]) - if (!started) return@PullRefresh - scope.launch { - // Fake refresh status but hide it after a second as it's a long running task - isRefreshing = true - delay(1.seconds) - isRefreshing = false - } - }, - enabled = { true } - ) { - ShelfPager( - coil = coil, - state = pagerState, - contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()), - getShelfForPage = { mangas }, - navigateToDetails = onClickManga - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfViewModel.kt deleted file mode 100644 index a328efe..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/shelf/ShelfViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.xtimms.shirizu.sections.shelf - -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.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel -import org.xtimms.shirizu.data.repository.FavouritesRepository -import org.xtimms.shirizu.utils.lang.mapItems -import javax.inject.Inject - -@HiltViewModel -class ShelfViewModel @Inject constructor( - favouritesRepository: FavouritesRepository, -) : KotatsuBaseViewModel() { - - private val mangasStateFlow = favouritesRepository.observeAll(1) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val mangaCount = favouritesRepository.observeMangaCountInCategory(1) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val categories = categoriesStateFlow - .filterNotNull() - .mapItems { FavouriteTabModel(it.id, it.title, mangaCount.value ?: 0) } - .distinctUntilChanged() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - val mangas = mangasStateFlow - .filterNotNull() - .mapItems { ShelfManga(it) } - .distinctUntilChanged() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - val isEmpty = categoriesStateFlow.map { - it?.isEmpty() == true - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsScreen.kt new file mode 100644 index 0000000..84f1911 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsScreen.kt @@ -0,0 +1,214 @@ +package org.xtimms.shirizu.sections.stats + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import org.xtimms.shirizu.LocalBottomSheetScrollState +import org.xtimms.shirizu.LocalWindowInsets +import org.xtimms.shirizu.R +import org.xtimms.shirizu.utils.lang.Screen +import org.xtimms.shirizu.utils.material.combineColors + +data class Size(val width: Dp, val height: Dp) + +object StatsScreen : Screen() { + + @Composable + override fun Content() { + + val navigator = LocalNavigator.currentOrThrow + val localDensity = LocalDensity.current + val localBottomSheetScrollState = LocalBottomSheetScrollState.current + + val scrollState = rememberScrollState() + val scroll = with(localDensity) { scrollState.value.toDp() } + + val navigationBarHeight = + LocalWindowInsets.current.calculateBottomPadding().coerceAtLeast(16.dp) + + Surface(Modifier.height(IntrinsicSize.Min)) { + Column( + modifier = Modifier.verticalScroll(scrollState) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + var headerSize by remember { mutableStateOf(Size(0.dp, 0.dp)) } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = localBottomSheetScrollState.topPadding.coerceAtLeast(36.dp)) + .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 + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .absoluteOffset(y = scroll * 0.25f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(36.dp)) + Text( + text = stringResource(R.string.reading_based_stats), + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.reading_based_stats_desc), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(64.dp)) + } + + val starColor1 = combineColors( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.surface, + 0.5f, + ) + + val starColor2 = combineColors( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.surface, + 0.5f, + ) + + 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) + ) + + Icon( + modifier = Modifier + .requiredSize(256.dp) + .absoluteOffset( + x = halfWidth * 0.7f, + y = -halfHeight * 0.6f + scroll * 0.35f + ) + .rotate(angleStar1) + .zIndex(-1f), + painter = painterResource(R.drawable.shape_soft_star_1), + tint = starColor1, + contentDescription = null, + ) + Icon( + modifier = Modifier + .requiredSize(256.dp) + .absoluteOffset( + x = -halfWidth * 0.7f, + y = halfHeight * 0.6f + scroll * 0.6f + ) + .rotate(angleStar2) + .zIndex(-1f), + painter = painterResource(R.drawable.shape_soft_star_2), + tint = starColor2, + contentDescription = null, + ) + } + Column(Modifier.fillMaxWidth()) { + TimeCard() + Spacer(modifier = Modifier.height(1000.dp)) + } + } + + Spacer( + Modifier + .height(60.dp + navigationBarHeight) + .fillMaxWidth()) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding( + bottom = WindowInsets.navigationBars + .asPaddingValues() + .calculateBottomPadding() + ), + contentAlignment = Alignment.BottomCenter, + ) { + Button( + modifier = Modifier + .fillMaxWidth() + .heightIn(60.dp) + .padding(horizontal = 16.dp), + onClick = navigator::pop + ) { + Text( + text = stringResource(R.string.got_it), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsView.kt deleted file mode 100644 index 12b5c6f..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsView.kt +++ /dev/null @@ -1,230 +0,0 @@ -package org.xtimms.shirizu.sections.stats - -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.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.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.absoluteOffset -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowForward -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.xtimms.shirizu.LocalBottomSheetScrollState -import org.xtimms.shirizu.LocalWindowInsets -import org.xtimms.shirizu.R -import org.xtimms.shirizu.sections.stats.categories.CategoriesChart -import org.xtimms.shirizu.ui.theme.ShirizuTheme -import org.xtimms.shirizu.utils.material.combineColors - -data class Size(val width: Dp, val height: Dp) - -const val STATS_DESTINATION = "stats" - -@Composable -fun StatsView( - navigateBack: () -> Unit, -) { - - val localDensity = LocalDensity.current - val localBottomSheetScrollState = LocalBottomSheetScrollState.current - - val scrollState = rememberScrollState() - val scroll = with(localDensity) { scrollState.value.toDp() } - - val navigationBarHeight = - LocalWindowInsets.current.calculateBottomPadding().coerceAtLeast(16.dp) - - Surface(Modifier.height(IntrinsicSize.Min)) { - Column( - modifier = Modifier.verticalScroll(scrollState) - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - var headerSize by remember { mutableStateOf(Size(0.dp, 0.dp)) } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = localBottomSheetScrollState.topPadding.coerceAtLeast(36.dp)) - .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 - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .absoluteOffset(y = scroll * 0.25f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(36.dp)) - Text( - text = stringResource(R.string.reading_based_stats), - style = MaterialTheme.typography.headlineLarge, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.reading_based_stats_desc), - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(64.dp)) - } - - val starColor1 = combineColors( - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.surface, - 0.5f, - ) - - val starColor2 = combineColors( - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.surface, - 0.5f, - ) - - 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) - ) - - Icon( - modifier = Modifier - .requiredSize(256.dp) - .absoluteOffset( - x = halfWidth * 0.7f, - y = -halfHeight * 0.6f + scroll * 0.35f - ) - .rotate(angleStar1) - .zIndex(-1f), - painter = painterResource(R.drawable.shape_soft_star_1), - tint = starColor1, - contentDescription = null, - ) - Icon( - modifier = Modifier - .requiredSize(256.dp) - .absoluteOffset( - x = -halfWidth * 0.7f, - y = halfHeight * 0.6f + scroll * 0.6f - ) - .rotate(angleStar2) - .zIndex(-1f), - painter = painterResource(R.drawable.shape_soft_star_2), - tint = starColor2, - contentDescription = null, - ) - } - Column(Modifier.fillMaxWidth()) { - TimeCard() - Spacer(modifier = Modifier.height(1000.dp)) - } - } - - Spacer( - Modifier - .height(60.dp + navigationBarHeight) - .fillMaxWidth()) - } - - Box( - modifier = Modifier - .fillMaxSize() - .padding( - bottom = WindowInsets.navigationBars - .asPaddingValues() - .calculateBottomPadding() - ), - contentAlignment = Alignment.BottomCenter, - ) { - Button( - modifier = Modifier - .fillMaxWidth() - .heightIn(60.dp) - .padding(horizontal = 16.dp), - onClick = { - navigateBack() - } - ) { - Text( - text = stringResource(R.string.got_it), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.width(8.dp)) - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowForward, - contentDescription = null, - ) - } - } - } -} - -@Preview -@Composable -private fun Preview() { - ShirizuTheme { - StatsView( - navigateBack = { } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsViewModel.kt index 6392b22..b7d9da0 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsViewModel.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/stats/StatsViewModel.kt @@ -55,12 +55,4 @@ class StatsViewModel @Inject constructor( } selectedCategories.value = snapshot } - - fun clear() { - launchLoadingJob(Dispatchers.Default) { - repository.clearStats() - readingStats.value = emptyList() - onActionDone.call(ReversibleAction(R.string.stats_cleared, null)) - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/stats/categories/DonutChart.kt b/app/src/main/java/org/xtimms/shirizu/sections/stats/categories/DonutChart.kt index 50719c0..8efbaf3 100644 --- a/app/src/main/java/org/xtimms/shirizu/sections/stats/categories/DonutChart.kt +++ b/app/src/main/java/org/xtimms/shirizu/sections/stats/categories/DonutChart.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import org.xtimms.shirizu.core.model.ShelfCategory +import org.xtimms.shirizu.sections.shelf.ShelfCategory @Composable fun DonutChart( diff --git a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreen.kt b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreen.kt new file mode 100644 index 0000000..355a555 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreen.kt @@ -0,0 +1,159 @@ +package org.xtimms.shirizu.sections.suggestions + +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.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +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.padding +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.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +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 kotlinx.coroutines.flow.collectLatest +import org.xtimms.shirizu.R +import org.xtimms.shirizu.core.components.MangaGridItem +import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar +import org.xtimms.shirizu.core.components.icons.Creation +import org.xtimms.shirizu.core.ui.screens.EmptyScreen +import org.xtimms.shirizu.core.ui.screens.LoadingScreen +import org.xtimms.shirizu.sections.details.DetailsScreen +import org.xtimms.shirizu.utils.lang.Screen + +object SuggestionsScreen : Screen() { + + private val snackbarHostState = SnackbarHostState() + + @Composable + override fun Content() { + + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val screenModel = getScreenModel() + val state by screenModel.state.collectAsState() + + ScaffoldWithTopAppBar( + title = stringResource(id = R.string.suggestions), + navigateBack = navigator::pop, + actions = { + IconButton(onClick = { screenModel.updateSuggestions() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(id = R.string.refresh) + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { padding -> + state.list.let { + if (it == null) { + LoadingScreen(Modifier.padding(padding)) + } else if (it.isEmpty()) { + EmptyScreen( + icon = Icons.Outlined.Creation, + title = R.string.nothing_here, + description = R.string.empty_suggestions_hint + ) + } else { + SuggestionsScreenContent( + suggestions = it, + contentPadding = padding, + onClick = { suggestion -> navigator.push(DetailsScreen(suggestion.manga)) } + ) + } + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { e -> + when (e) { + SuggestionsScreenModel.Event.GettingSuggestions -> { + state.isLoading = true + snackbarHostState.showSnackbar(context.resources.getString(R.string.suggestions_updating)) + } + SuggestionsScreenModel.Event.InternalError -> + snackbarHostState.showSnackbar(context.resources.getString(R.string.error_occured)) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SuggestionsScreenContent( + suggestions: List, + contentPadding: PaddingValues, + onClick: (SuggestionMangaModel) -> Unit, +) { + val listState = rememberLazyGridState() + Box( + modifier = Modifier + .padding(contentPadding), + contentAlignment = Alignment.Center + ) { + 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 = suggestions, + key = { it.manga.id }, + contentType = { it } + ) { item -> + Box( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + contentAlignment = Alignment.TopCenter + ) { + MangaGridItem( + manga = item.manga, + title = item.manga.title, + onClick = { onClick(item) }, + onLongClick = { }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreenModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreenModel.kt new file mode 100644 index 0000000..cce0e75 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsScreenModel.kt @@ -0,0 +1,70 @@ +package org.xtimms.shirizu.sections.suggestions + +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.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.xtimms.shirizu.data.repository.SuggestionRepository +import org.xtimms.shirizu.utils.lang.mapItems +import org.xtimms.shirizu.work.suggestions.SuggestionsWorker +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestionsScreenModel @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() + .filterNotNull() + .catch { error -> + error.printStackTrace() + _events.send(Event.InternalError) + } + .mapItems { SuggestionMangaModel(it) } + .flowOn(Dispatchers.IO) + }.collect { newList -> + mutableState.update { + it.copy(isLoading = false, list = newList) + } + } + } + } + + fun updateSuggestions() { + screenModelScope.launch(Dispatchers.IO) { + suggestionsScheduler.startNow() + _events.send(Event.GettingSuggestions) + } + } + + @Immutable + data class State( + var isLoading: Boolean = true, + val list: List? = null, + ) + + sealed interface Event { + data object GettingSuggestions : Event + data object InternalError : Event + } + +} diff --git a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsView.kt b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsView.kt deleted file mode 100644 index c055c61..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsView.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.xtimms.shirizu.sections.suggestions - -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.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -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.padding -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.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -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 coil.ImageLoader -import org.koitharu.kotatsu.parsers.model.Manga -import org.xtimms.shirizu.R -import org.xtimms.shirizu.core.components.MangaGridItem -import org.xtimms.shirizu.core.components.ScaffoldWithTopAppBar - -const val SUGGESTIONS_DESTINATION = "suggestions" - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun SuggestionsView( - viewModel: SuggestionsViewModel = hiltViewModel(), - coil: ImageLoader, - navigateBack: () -> Unit, - navigateToDetails: (Long) -> Unit -) { - - val suggestions by viewModel.content.collectAsStateWithLifecycle(emptyList()) - - ScaffoldWithTopAppBar( - title = stringResource(id = R.string.suggestions), - navigateBack = navigateBack, - actions = { - IconButton(onClick = { viewModel.updateSuggestions() }) { - Icon( - imageVector = Icons.Filled.Refresh, - contentDescription = "Refresh" - ) - } - }, - ) { padding -> - val listState = rememberLazyGridState() - Box( - modifier = Modifier - .padding(padding), - contentAlignment = Alignment.Center - ) { - 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 = suggestions, - key = { it.manga.id }, - contentType = { it } - ) { item -> - Box( - modifier = Modifier.fillMaxWidth().animateItemPlacement( - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium / 4, - visibilityThreshold = IntOffset.VisibilityThreshold - ) - ), - contentAlignment = Alignment.TopCenter - ) { - val onClickManga = { manga: Manga -> - navigateToDetails(manga.id) - } - MangaGridItem( - coil = coil, - manga = item.manga, - onClick = onClickManga, - onLongClick = { }, - ) - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsViewModel.kt b/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsViewModel.kt deleted file mode 100644 index 9576c64..0000000 --- a/app/src/main/java/org/xtimms/shirizu/sections/suggestions/SuggestionsViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.xtimms.shirizu.sections.suggestions - -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.koitharu.kotatsu.parsers.model.Manga -import org.xtimms.shirizu.core.base.viewmodel.KotatsuBaseViewModel -import org.xtimms.shirizu.data.repository.SuggestionRepository -import org.xtimms.shirizu.sections.history.HistoryItemModel -import org.xtimms.shirizu.utils.lang.mapItems -import org.xtimms.shirizu.work.suggestions.SuggestionsWorker -import javax.inject.Inject - -@HiltViewModel -class SuggestionsViewModel @Inject constructor( - repository: SuggestionRepository, - private val suggestionsScheduler: SuggestionsWorker.Scheduler, -) : KotatsuBaseViewModel() { - - private val suggestionsStateFlow = repository.observeAll() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val content = suggestionsStateFlow - .filterNotNull() - .mapItems { SuggestionMangaModel(it) } - .distinctUntilChanged() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - - fun updateSuggestions() { - suggestionsScheduler.startNow() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/ui/monet/TonalPalettes.kt b/app/src/main/java/org/xtimms/shirizu/ui/monet/TonalPalettes.kt index f53395a..701dbd8 100644 --- a/app/src/main/java/org/xtimms/shirizu/ui/monet/TonalPalettes.kt +++ b/app/src/main/java/org/xtimms/shirizu/ui/monet/TonalPalettes.kt @@ -110,8 +110,8 @@ class TonalPalettes( private fun Color.transform(tone: Double, spec: ColorSpec): Color { return Color(Hct.fromInt(this.toArgb()).apply { setTone(tone) - setChroma(spec.chroma(this.chroma)) - setHue(spec.hueShift(this.hue) + this.hue) + chroma = spec.chroma(this.chroma) + hue = spec.hueShift(this.hue) + this.hue }.toInt()) } diff --git a/app/src/main/java/org/xtimms/shirizu/ui/theme/Type.kt b/app/src/main/java/org/xtimms/shirizu/ui/theme/Type.kt index f66d4ee..d9be149 100644 --- a/app/src/main/java/org/xtimms/shirizu/ui/theme/Type.kt +++ b/app/src/main/java/org/xtimms/shirizu/ui/theme/Type.kt @@ -16,8 +16,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontVariation -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp diff --git a/app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex.kt b/app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex.kt deleted file mode 100644 index eac7b19..0000000 --- a/app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.xtimms.shirizu.utils - -import androidx.collection.ArrayMap -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.coroutineContext - -@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2")) -class CompositeMutex : Set { - - private val state = ArrayMap>() - private val mutex = Mutex() - - override val size: Int - get() = state.size - - override fun contains(element: T): Boolean { - return state.containsKey(element) - } - - override fun containsAll(elements: Collection): Boolean { - return elements.all { x -> state.containsKey(x) } - } - - override fun isEmpty(): Boolean { - return state.isEmpty() - } - - override fun iterator(): Iterator { - return state.keys.iterator() - } - - suspend fun lock(element: T) { - while (coroutineContext.isActive) { - waitForRemoval(element) - mutex.withLock { - if (state[element] == null) { - state[element] = MutableStateFlow(false) - return - } - } - } - } - - fun unlock(element: T) { - checkNotNull(state.remove(element)) { - "CompositeMutex is not locked for $element" - }.value = true - } - - private suspend fun waitForRemoval(element: T) { - val flow = state[element] ?: return - flow.first { it } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/LocaleHelper.kt b/app/src/main/java/org/xtimms/shirizu/utils/LocaleHelper.kt new file mode 100644 index 0000000..7585f8a --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/LocaleHelper.kt @@ -0,0 +1,56 @@ +package org.xtimms.shirizu.utils + +import android.content.Context +import androidx.core.os.LocaleListCompat +import org.xtimms.shirizu.R +import org.xtimms.shirizu.sections.explore.sources.SourcesScreenModel +import java.util.Locale + +object LocaleHelper { + + /** + * Sorts by display name, except keeps the "all" (displayed as "Multi") locale at the top. + */ + val comparator = { a: String?, b: String? -> + if (a == "all") { + -1 + } else if (b == "all") { + 1 + } else { + getLocalizedDisplayName(a).compareTo(getLocalizedDisplayName(b)) + } + } + + /** + * Returns display name of a string language code. + */ + fun getSourceDisplayName(lang: String?, context: Context): String { + return when (lang) { + SourcesScreenModel.LAST_USED_KEY -> context.resources.getString(R.string.last_used_source) + SourcesScreenModel.PINNED_KEY -> context.resources.getString(R.string.pinned_sources) + "other" -> context.resources.getString(R.string.other_source) + "all" -> context.resources.getString(R.string.multi_lang) + "null" -> context.resources.getString(R.string.multi_lang) + else -> getLocalizedDisplayName(lang) + } + } + + /** + * Returns display name of a string language code. + * + * @param lang empty for system language + */ + fun getLocalizedDisplayName(lang: String?): String { + if (lang == null) { + return "" + } + + val locale = when (lang) { + "" -> LocaleListCompat.getAdjustedDefault()[0] + "zh-CN" -> Locale.forLanguageTag("zh-Hans") + "zh-TW" -> Locale.forLanguageTag("zh-Hant") + else -> Locale.forLanguageTag(lang) + } + return locale!!.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex2.kt b/app/src/main/java/org/xtimms/shirizu/utils/MultiMutex.kt similarity index 95% rename from app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex2.kt rename to app/src/main/java/org/xtimms/shirizu/utils/MultiMutex.kt index f7e00ac..f270d9d 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/CompositeMutex2.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/MultiMutex.kt @@ -3,7 +3,7 @@ package org.xtimms.shirizu.utils import androidx.collection.ArrayMap import kotlinx.coroutines.sync.Mutex -class CompositeMutex2 : Set { +class MultiMutex : Set { private val delegates = ArrayMap() diff --git a/app/src/main/java/org/xtimms/shirizu/utils/composable/Modifier.kt b/app/src/main/java/org/xtimms/shirizu/utils/composable/Modifier.kt index 6d5ff05..cdd3073 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/composable/Modifier.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/composable/Modifier.kt @@ -4,6 +4,12 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -37,4 +43,12 @@ fun Modifier.clickableNoIndication( onLongClick = onLongClick, onClick = onClick, ) -} \ No newline at end of file +} + +fun Modifier.bodyWidth() = fillMaxWidth() + .composed { + windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal), + ) + } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/composable/Scrollbar.kt b/app/src/main/java/org/xtimms/shirizu/utils/composable/Scrollbar.kt new file mode 100644 index 0000000..5cb9e70 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/composable/Scrollbar.kt @@ -0,0 +1,249 @@ +package org.xtimms.shirizu.utils.composable + +import android.view.ViewConfiguration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +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.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastSumBy +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 + +/** + * Draws horizontal scrollbar to a LazyList. + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + */ +fun Modifier.drawHorizontalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the top of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling, positionOffsetPx) + +/** + * Draws vertical scrollbar to a LazyList. + * + * Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list. + */ +fun Modifier.drawVerticalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false, + // The amount of offset the scrollbar position towards the start of the layout + positionOffsetPx: Float = 0f, +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling, positionOffsetPx) + +private fun Modifier.drawScrollbar( + state: LazyListState, + orientation: Orientation, + reverseScrolling: Boolean, + positionOffset: Float, +): Modifier = drawScrollbar( + orientation, + reverseScrolling, +) { reverseDirection, atEnd, thickness, color, alpha -> + val layoutInfo = state.layoutInfo + val viewportSize = if (orientation == Orientation.Horizontal) { + layoutInfo.viewportSize.width + } else { + layoutInfo.viewportSize.height + } - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + val items = layoutInfo.visibleItemsInfo + val itemsSize = items.fastSumBy { it.size } + val showScrollbar = items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize + val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size + val totalSize = estimatedItemSize * layoutInfo.totalItemsCount + val thumbSize = viewportSize / totalSize * viewportSize + val startOffset = if (items.isEmpty()) { + 0f + } else { + items + .fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!! + .run { + val startPadding = if (reverseDirection) { + layoutInfo.afterContentPadding + } else { + layoutInfo.beforeContentPadding + } + startPadding + ((estimatedItemSize * index - offset) / totalSize * viewportSize) + } + } + val drawScrollbar = onDrawScrollbar( + orientation, reverseDirection, atEnd, showScrollbar, + thickness, color, alpha, thumbSize, startOffset, positionOffset, + ) + drawContent() + drawScrollbar() +} + +private fun ContentDrawScope.onDrawScrollbar( + orientation: Orientation, + reverseDirection: Boolean, + atEnd: Boolean, + showScrollbar: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + thumbSize: Float, + scrollOffset: Float, + positionOffset: Float, +): DrawScope.() -> Unit { + val topLeft = if (orientation == Orientation.Horizontal) { + Offset( + if (reverseDirection) size.width - scrollOffset - thumbSize else scrollOffset, + if (atEnd) size.height - positionOffset - thickness else positionOffset, + ) + } else { + Offset( + if (atEnd) size.width - positionOffset - thickness else positionOffset, + if (reverseDirection) size.height - scrollOffset - thumbSize else scrollOffset, + ) + } + val size = if (orientation == Orientation.Horizontal) { + Size(thumbSize, thickness) + } else { + Size(thickness, thumbSize) + } + + return { + if (showScrollbar) { + drawRect( + color = color, + topLeft = topLeft, + size = size, + alpha = alpha(), + ) + } + } +} + +private fun Modifier.drawScrollbar( + orientation: Orientation, + reverseScrolling: Boolean, + onDraw: ContentDrawScope.( + reverseDirection: Boolean, + atEnd: Boolean, + thickness: Float, + color: Color, + alpha: () -> Float, + ) -> Unit, +): Modifier = composed { + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + val nestedScrollConnection = remember(orientation, scrolled) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y + if (delta != 0f) scrolled.tryEmit(Unit) + return Offset.Zero + } + } + } + + val alpha = remember { Animatable(0f) } + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val reverseDirection = if (orientation == Orientation.Horizontal) { + if (isLtr) reverseScrolling else !reverseScrolling + } else { + reverseScrolling + } + val atEnd = if (orientation == Orientation.Vertical) isLtr else true + + val context = LocalContext.current + val thickness = remember { ViewConfiguration.get(context).scaledScrollBarSize.toFloat() } + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.364f) + Modifier + .nestedScroll(nestedScrollConnection) + .drawWithContent { + onDraw(reverseDirection, atEnd, thickness, color, alpha::value) + } +} + +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = ViewConfiguration.getScrollDefaultDelay(), +) + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +fun LazyListScrollbarPreview() { + val state = rememberLazyListState() + LazyColumn( + modifier = Modifier.drawVerticalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = "Item ${it + 1}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } +} + +@Preview(widthDp = 400, showBackground = true) +@Composable +fun LazyListHorizontalScrollbarPreview() { + val state = rememberLazyListState() + LazyRow( + modifier = Modifier.drawHorizontalScrollbar(state), + state = state, + ) { + items(50) { + Text( + text = (it + 1).toString(), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Collections.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Collections.kt index 019ea51..9cafd1e 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/lang/Collections.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Collections.kt @@ -1,6 +1,7 @@ package org.xtimms.shirizu.utils.lang import androidx.collection.ArrayMap +import java.util.EnumSet fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList @@ -39,4 +40,29 @@ fun List.insertSeparators( separator?.let(newList::add) } return newList +} + +fun Map.findKeyByValue(value: V): K? { + for ((k, v) in entries) { + if (v == value) { + return k + } + } + return null +} + +fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size + +inline fun > Collection.toEnumSet(): EnumSet = if (isEmpty()) { + EnumSet.noneOf(E::class.java) +} else { + EnumSet.copyOf(this) +} + +fun HashSet.addOrRemove(value: E, shouldAdd: Boolean) { + if (shouldAdd) { + add(value) + } else { + remove(value) + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Date.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Date.kt index 696081e..f0275ca 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/lang/Date.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Date.kt @@ -29,7 +29,10 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo val diffDays = localDate.until(now, ChronoUnit.DAYS) return when { - diffDays == 0L -> DateTimeAgo.Today + diffDays == 0L -> { + if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow + else DateTimeAgo.Today + } diffDays == 1L -> DateTimeAgo.Yesterday diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt()) else -> { diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Display.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Display.kt new file mode 100644 index 0000000..05c77d3 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Display.kt @@ -0,0 +1,15 @@ +package org.xtimms.shirizu.utils.lang + +import android.content.res.Configuration + +private const val TABLET_UI_REQUIRED_SCREEN_WIDTH_DP = 720 + +// some tablets have screen width like 711dp = 1600px / 2.25 +private const val TABLET_UI_MIN_SCREEN_WIDTH_PORTRAIT_DP = 700 + +// make sure icons on the nav rail fit +private const val TABLET_UI_MIN_SCREEN_WIDTH_LANDSCAPE_DP = 600 + +fun Configuration.isTabletUi(): Boolean { + return smallestScreenWidthDp >= TABLET_UI_REQUIRED_SCREEN_WIDTH_DP +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Flow.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Flow.kt index b6efea8..3ab73bc 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/lang/Flow.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Flow.kt @@ -1,6 +1,7 @@ package org.xtimms.shirizu.utils.lang import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion @@ -37,4 +38,24 @@ fun Flow>.flatten(): Flow = flow { emit(item) } } +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Navigator.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Navigator.kt new file mode 100644 index 0000000..110325f --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Navigator.kt @@ -0,0 +1,125 @@ +package org.xtimms.shirizu.utils.lang + +import android.graphics.Path +import android.view.animation.PathInterpolator +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +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.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntOffset +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.ScreenModelStore +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.core.screen.uniqueScreenKey +import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.ScreenTransition +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.plus + +interface Tab : cafe.adriel.voyager.navigator.tab.Tab { + suspend fun onReselect(navigator: Navigator) {} +} + +abstract class Screen : Screen { + + override val key: ScreenKey = uniqueScreenKey +} + +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) } +} + +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) + +val enter = + slideInHorizontally(enterTween, initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn( + fadeTween + ) +val exit = + slideOutHorizontally(exitTween, targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut( + fadeTween + ) + +fun materialSharedAxisXIn( + forward: Boolean, +): EnterTransition = + slideInHorizontally( + enterTween, + initialOffsetX = { + if (forward) (it * initialOffset).toInt() else -(it * initialOffset).toInt() + } + ) + fadeIn(fadeTween) + +fun materialSharedAxisXOut( + forward: Boolean, +): ExitTransition = + slideOutHorizontally( + exitTween, + targetOffsetX = { + if (forward) -(it * initialOffset).toInt() else (it * initialOffset).toInt() + } + ) + fadeOut(fadeTween) + +fun materialSharedAxisX( + forward: Boolean, +): ContentTransform = materialSharedAxisXIn( + forward = forward, +) togetherWith materialSharedAxisXOut( + forward = forward, +) + +@Composable +fun DefaultNavigatorScreenTransition(navigator: Navigator) { + ScreenTransition( + navigator = navigator, + transition = { + materialSharedAxisX( + forward = navigator.lastEvent != StackEvent.Pop, + ) + }, + ) +} + +val ScreenModel.ioCoroutineScope: CoroutineScope + get() = ScreenModelStore.getOrPutDependency( + screenModel = this, + name = "ScreenModelIoCoroutineScope", + factory = { key -> CoroutineScope(Dispatchers.IO + SupervisorJob()) + CoroutineName(key) }, + onDispose = { scope -> scope.cancel() }, + ) + +interface AssistContentScreen { + fun onProvideAssistUrl(): String? +} + +interface NoLiftingAppBarScreen \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/String.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/String.kt index cb0a4ee..339a778 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/lang/String.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/String.kt @@ -3,6 +3,7 @@ package org.xtimms.shirizu.utils.lang import android.net.Uri import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlin.math.floor inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { return if (this.isNullOrEmpty()) defaultValue() else this @@ -30,4 +31,14 @@ fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' fun Float?.toStringPositiveValueOrUnknown() = if (this == 0f) "─" else this.toStringOrUnknown() -fun Float?.toStringOrUnknown() = this?.toString() ?: "─" \ No newline at end of file +fun Float?.toStringOrUnknown() = this?.toString() ?: "─" + +fun String.truncateCenter(count: Int, replacement: String = "..."): String { + if (length <= count) { + return this + } + + val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt() + + return "${take(pieceLength)}$replacement${takeLast(pieceLength)}" +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/lang/Window.kt b/app/src/main/java/org/xtimms/shirizu/utils/lang/Window.kt new file mode 100644 index 0000000..2a251cf --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/lang/Window.kt @@ -0,0 +1,11 @@ +package org.xtimms.shirizu.utils.lang + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration + +@Composable +@ReadOnlyComposable +fun isTabletUi(): Boolean { + return LocalConfiguration.current.isTabletUi() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/system/Android.kt b/app/src/main/java/org/xtimms/shirizu/utils/system/Android.kt index 89d9800..efa67bb 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/system/Android.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/system/Android.kt @@ -9,11 +9,14 @@ import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.PowerManager +import android.webkit.WebView import androidx.activity.result.ActivityResultLauncher import androidx.annotation.WorkerThread import androidx.core.app.ActivityOptionsCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import androidx.work.CoroutineWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible @@ -70,4 +73,17 @@ fun Context.ensureRamAtLeast(requiredSize: Long) { fun Context.isPowerSaveMode(): Boolean { return powerManager?.isPowerSaveMode == true +} + +fun WebView.configureForParser(userAgentOverride: String?) = with(settings) { + javaScriptEnabled = true + domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false + if (WebViewFeature.isFeatureSupported(WebViewFeature.MUTE_AUDIO)) { + WebViewCompat.setAudioMuted(this@configureForParser, true) + } + databaseEnabled = true + if (userAgentOverride != null) { + userAgentString = userAgentOverride + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/system/Context.kt b/app/src/main/java/org/xtimms/shirizu/utils/system/Context.kt index b2010af..50f5d09 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/system/Context.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/system/Context.kt @@ -1,13 +1,46 @@ package org.xtimms.shirizu.utils.system import android.app.ActivityManager +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Context.ACTIVITY_SERVICE +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.work.WorkManager +import org.xtimms.shirizu.R +import org.xtimms.shirizu.utils.DeviceUtil +import org.xtimms.shirizu.utils.lang.truncateCenter import java.io.File val Context.activityManager: ActivityManager? get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager +val Context.workManager: WorkManager + get() = WorkManager.getInstance(this) + +fun Context.copyToClipboard(label: String, content: String) { + if (content.isBlank()) return + + try { + val clipboard = getSystemService()!! + clipboard.setPrimaryClip(ClipData.newPlainText(label, content)) + + // Android 13 and higher shows a visual confirmation of copied contents + // https://developer.android.com/about/versions/13/features/copy-paste + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + toast(resources.getString(R.string.copied_to_clipboard, content.truncateCenter(50))) + } + } catch (e: Throwable) { + e.printStackTrace() + toast(R.string.clipboard_copy_error) + } +} + fun Context.createFileInCacheDir(name: String): File { val file = File(externalCacheDir, name) if (file.exists()) { @@ -20,4 +53,36 @@ fun Context.createFileInCacheDir(name: String): File { fun Context.isLowRamDevice(): Boolean { return activityManager?.isLowRamDevice ?: false +} + +fun Context.openInBrowser(url: String, forceDefaultBrowser: Boolean = false) { + this.openInBrowser(url.toUri(), forceDefaultBrowser) +} + +fun Context.openInBrowser(uri: Uri, forceDefaultBrowser: Boolean = false) { + try { + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + if (forceDefaultBrowser) { + defaultBrowserPackageName()?.let { setPackage(it) } + } + } + startActivity(intent) + } catch (e: Exception) { + toast(e.getDisplayMessage(resources)) + } +} + +private fun Context.defaultBrowserPackageName(): String? { + val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri()) + val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.resolveActivity( + browserIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()), + ) + } else { + packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) + } + return resolveInfo + ?.activityInfo?.packageName + ?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/system/Density.kt b/app/src/main/java/org/xtimms/shirizu/utils/system/Density.kt new file mode 100644 index 0000000..936cd46 --- /dev/null +++ b/app/src/main/java/org/xtimms/shirizu/utils/system/Density.kt @@ -0,0 +1,6 @@ +package org.xtimms.shirizu.utils.system + +import android.content.res.Resources + +val Int.dpToPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/system/Http.kt b/app/src/main/java/org/xtimms/shirizu/utils/system/Http.kt index e3cf811..178f5fc 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/system/Http.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/system/Http.kt @@ -58,4 +58,17 @@ fun Response.ensureSuccess() = apply { closeQuietly() throw HttpStatusException(message, code, request.url.toString()) } +} + +fun String.sanitizeHeaderValue(): String { + return if (all(Char::isValidForHeaderValue)) { + this // fast path + } else { + filter(Char::isValidForHeaderValue) + } +} + +private fun Char.isValidForHeaderValue(): Boolean { + // from okhttp3.Headers$Companion.checkValue + return this == '\t' || this in '\u0020'..'\u007e' } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/utils/system/Network.kt b/app/src/main/java/org/xtimms/shirizu/utils/system/Network.kt index 54f5c78..1bd913d 100644 --- a/app/src/main/java/org/xtimms/shirizu/utils/system/Network.kt +++ b/app/src/main/java/org/xtimms/shirizu/utils/system/Network.kt @@ -13,6 +13,8 @@ fun ConnectivityManager.isOnline(): Boolean { } private fun ConnectivityManager.isOnline(network: Network): Boolean { - val capabilities = getNetworkCapabilities(network) - return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val capabilities = getNetworkCapabilities(network) ?: return false + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/shirizu/work/tracker/TrackWorker.kt b/app/src/main/java/org/xtimms/shirizu/work/tracker/TrackWorker.kt index e2de4c1..599cfb3 100644 --- a/app/src/main/java/org/xtimms/shirizu/work/tracker/TrackWorker.kt +++ b/app/src/main/java/org/xtimms/shirizu/work/tracker/TrackWorker.kt @@ -43,6 +43,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp +import org.xtimms.shirizu.BuildConfig import org.xtimms.shirizu.R import org.xtimms.shirizu.core.database.ShirizuDatabase import org.xtimms.shirizu.core.exceptions.CloudflareProtectedException @@ -269,7 +270,7 @@ class TrackWorker @AssistedInject constructor( override suspend fun schedule() { val constraints = createConstraints() val runCount = dbProvider.get().getTracksDao().getTracksCount() - val runsPerFullCheck = (runCount / BATCH_SIZE.toFloat()).toIntUp() + val runsPerFullCheck = (runCount / BATCH_SIZE.toFloat()).toIntUp().coerceAtLeast(1) val interval = (6 / runsPerFullCheck).coerceAtLeast(2) val request = PeriodicWorkRequestBuilder(interval.toLong(), TimeUnit.HOURS) .setConstraints(constraints) @@ -326,6 +327,6 @@ class TrackWorker @AssistedInject constructor( const val MAX_PARALLELISM = 6 const val DATA_KEY_SUCCESS = "success" const val DATA_KEY_FAILED = "failed" - const val BATCH_SIZE = 20 + val BATCH_SIZE = if (BuildConfig.DEBUG) 20 else 46 } } \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-hdpi/ic_shikimori_raw.png new file mode 100644 index 0000000..28bf050 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_shikimori_raw.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-mdpi/ic_shikimori_raw.png new file mode 100644 index 0000000..d16d155 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_shikimori_raw.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xhdpi/ic_shikimori_raw.png new file mode 100644 index 0000000..69ab191 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_shikimori_raw.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xxhdpi/ic_shikimori_raw.png new file mode 100644 index 0000000..f400241 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_shikimori_raw.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xxxhdpi/ic_shikimori_raw.png new file mode 100644 index 0000000..cd989ae Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_shikimori_raw.png differ diff --git a/app/src/main/res/drawable/ic_kitsu.xml b/app/src/main/res/drawable/ic_kitsu.xml new file mode 100644 index 0000000..6941f5d --- /dev/null +++ b/app/src/main/res/drawable/ic_kitsu.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shikimori.xml b/app/src/main/res/drawable/ic_shikimori.xml new file mode 100644 index 0000000..5e0ec89 --- /dev/null +++ b/app/src/main/res/drawable/ic_shikimori.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shirizu.xml b/app/src/main/res/drawable/shirizu.xml new file mode 100644 index 0000000..fb15def --- /dev/null +++ b/app/src/main/res/drawable/shirizu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml index e365960..b2d9d0e 100644 --- a/app/src/main/res/values/bools.xml +++ b/app/src/main/res/values/bools.xml @@ -1,4 +1,5 @@ - + true + false \ No newline at end of file diff --git a/app/src/main/res/values/font_certs.xml b/app/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..141bfc0 --- /dev/null +++ b/app/src/main/res/values/font_certs.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 17d669c..db3bc77 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -40,4 +40,8 @@ %1$d new chapter %1$d new chapters + + %1$d manga + %1$d mangas + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 56060b3..12a2430 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,8 +212,8 @@ Yesterday This month Long ago - Are you sure you want to remove %s from your reading history? - Continue reading + Are you sure you want to remove this from your reading history? + Continue Updating suggestions Images optimization proxy Use the wsrv.nl service to reduce traffic usage and speed up image loading if possible @@ -308,4 +308,57 @@ Logs about this bug have been sent to the developers, thank you. User interface Modern look of the manga information screen + Set categories + Mark as read + More options + More + Website + Refresh + Sources + Last used source + Pinned sources + Other + All + Unpin + Pin + Remove + Filter %1$d sources + No enabled sources + Try searching for the sources you need under the «Catalog» tab + Debug info + Copied to clipboard:\n%1$s + Failed to copy to clipboard + Worker info + Retry + Reset + Shikimori + Scrobbling + Kitsu + Login + Log in to %1$s + Password + Try updating the list of suggestions manually + Recommendations will come a little later + Genres + Recent queries + Suggested queries + Content type + Authors + Finish + Next + Welcome! + Let\'s set some things up first. You can always change these in the settings later too. + Delete category + Category already exists + *required + Do you wish to delete the category \"%s\"? + Library + Shelves + History cleared + Search by reading history + A-Z + Date added + Show NSFW + Save + Mark as completed \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f0c5829..49cd25b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,6 @@ plugins { buildscript { dependencies { - classpath("com.google.dagger:hilt-android-gradle-plugin:2.50") + classpath("com.google.dagger:hilt-android-gradle-plugin:2.51.1") } } \ No newline at end of file diff --git a/images/details.png b/images/details.png deleted file mode 100644 index 77738c6..0000000 Binary files a/images/details.png and /dev/null differ diff --git a/images/details_dark.png b/images/details_dark.png deleted file mode 100644 index 1e4572c..0000000 Binary files a/images/details_dark.png and /dev/null differ diff --git a/images/explore.png b/images/explore.png new file mode 100644 index 0000000..7ac019d Binary files /dev/null and b/images/explore.png differ diff --git a/images/history.png b/images/history.png deleted file mode 100644 index ee8b5bd..0000000 Binary files a/images/history.png and /dev/null differ diff --git a/images/history_dark.png b/images/history_dark.png deleted file mode 100644 index 4042dfe..0000000 Binary files a/images/history_dark.png and /dev/null differ diff --git a/images/library.png b/images/library.png new file mode 100644 index 0000000..73435a4 Binary files /dev/null and b/images/library.png differ diff --git a/images/search.png b/images/search.png new file mode 100644 index 0000000..b32ed3a Binary files /dev/null and b/images/search.png differ diff --git a/images/shelf.png b/images/shelf.png deleted file mode 100644 index 7fb7acd..0000000 Binary files a/images/shelf.png and /dev/null differ diff --git a/images/shelf_dark.png b/images/shelf_dark.png deleted file mode 100644 index decd74f..0000000 Binary files a/images/shelf_dark.png and /dev/null differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b020fe..d642fc1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { google() mavenCentral() gradlePluginPortal() + maven("https://jitpack.io") } } dependencyResolutionManagement {