Partially migrate to Voyager navigation library
parent
210da5db8a
commit
47fffb5541
@ -1,6 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="selectedTabId" value="Firebase Crashlytics" />
|
||||
<option name="selectedTabId" value="Android Vitals" />
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Android Vitals">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="org.spray.qmanga" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="SEVEN_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Float, AnimationVector1D>,
|
||||
) = 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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -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<FileLogger>,
|
||||
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<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val fadeTween = tween<Float>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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<S>(initialState: S) : ScreenModel {
|
||||
|
||||
protected val mutableState: MutableStateFlow<S> = MutableStateFlow(initialState)
|
||||
public val state: StateFlow<S> = mutableState.asStateFlow()
|
||||
|
||||
@JvmField
|
||||
protected val loadingCounter = MutableStateFlow(0)
|
||||
|
||||
@JvmField
|
||||
protected val errorEvent = MutableEventFlow<Throwable>()
|
||||
|
||||
val onError: EventFlow<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
val isLoading: StateFlow<Boolean> = 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 <T> Flow<T>.withLoading() = onStart {
|
||||
loadingCounter.increment()
|
||||
}.onCompletion {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected suspend inline fun <T> withLoading(block: () -> T): T = try {
|
||||
loadingCounter.increment()
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Boolean>,
|
||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||
) {
|
||||
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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Manga>,
|
||||
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<Manga>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<PullToRefreshStateImpl, Boolean>(
|
||||
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
|
||||
}
|
||||
@ -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
|
||||
*
|
||||
* <a href="https://material.io/design/layout/understanding-layout.html" class="external" target="_blank">Material Design layout</a>.
|
||||
*
|
||||
* 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 }
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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<SortOption>,
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<SortOption>,
|
||||
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)
|
||||
}
|
||||
@ -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<Unit>(
|
||||
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<Density.(Constraints) -> List<Int>>(
|
||||
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..<size) {
|
||||
this[i] += this[i - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
fun VerticalGridFastScroller(
|
||||
state: LazyGridState,
|
||||
columns: GridCells,
|
||||
arrangement: Arrangement.Horizontal,
|
||||
contentPadding: PaddingValues,
|
||||
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,
|
||||
) {
|
||||
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<Unit>(
|
||||
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<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
delayMillis = 2000,
|
||||
)
|
||||
private val ImmediateFadeOutAnimationSpec = tween<Float>(
|
||||
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
|
||||
)
|
||||
|
||||
private val LazyListItemInfo.top: Int
|
||||
get() = offset
|
||||
|
||||
private val LazyListItemInfo.bottom: Int
|
||||
get() = offset + size
|
||||
@ -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
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package org.xtimms.shirizu.core.onboarding
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
internal interface OnboardingStep {
|
||||
|
||||
val isComplete: Boolean
|
||||
|
||||
@Composable
|
||||
fun Content()
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ScrobblerManga>
|
||||
|
||||
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?)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<ScrobblingEntity?>
|
||||
|
||||
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler")
|
||||
abstract fun observe(scrobbler: Int): Flow<List<ScrobblingEntity>>
|
||||
|
||||
@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)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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<ScrobblerMangaInfo>()
|
||||
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
|
||||
|
||||
val user: Flow<ScrobblerUser> = 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<ScrobblerManga> {
|
||||
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<ScrobblingInfo?> {
|
||||
return db.getScrobblingDao().observe(scrobblerService.id, mangaId)
|
||||
.map { it?.toScrobblingInfo() }
|
||||
}
|
||||
|
||||
fun observeAllScrobblingInfo(): Flow<List<ScrobblingInfo>> {
|
||||
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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.xtimms.shirizu.core.scrobbling.domain.model
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
annotation class ScrobblerType(
|
||||
val service: ScrobblerService
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<KitsuRepository>,
|
||||
) : 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()
|
||||
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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<ScrobblerManga> {
|
||||
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", "<br>"),
|
||||
)
|
||||
}
|
||||
|
||||
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<String> {
|
||||
val result = ArrayList<String>(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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<ShikimoriRepository>,
|
||||
) : 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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<ScrobblerManga> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<TabContent>,
|
||||
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,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue