Partially migrate to Voyager navigation library
parent
210da5db8a
commit
47fffb5541
@ -1,6 +1,41 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="AppInsightsSettings">
|
<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>
|
</component>
|
||||||
</project>
|
</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