I'm always forget to commit

master
Zakhar Timoshenko 2 years ago
parent dea934155d
commit 47321c7ae6
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-639820289">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_API_34" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Firebase Crashlytics" />
</component>
</project>

@ -2,21 +2,11 @@
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="AppBackupAgentTest">
<State />
</entry>
<entry key="app">
<State>
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\xtimms\.android\avd\Pixel_API_34.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-01-29T14:26:34.807344800Z" />
</State>
<State />
</entry>
</value>
</component>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ScreenshotViewer">
<option name="frameScreenshot" value="true" />
</component>
</project>

@ -11,6 +11,7 @@ plugins {
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.parcelize")
id("com.google.devtools.ksp")
id("com.mikepenz.aboutlibraries.plugin")
id("dagger.hilt.android.plugin")
}
@ -36,7 +37,7 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "org.xtimms.tokusho.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
}
@ -85,20 +86,22 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation(platform("dev.chrisbanes.compose:compose-bom:2024.02.00-alpha02"))
implementation("androidx.compose.animation:animation-graphics")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation("androidx.compose.material3:material3-android:1.2.0")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.compose.material:material-icons-extended:1.6.3")
implementation("androidx.compose.material3:material3-android:1.2.1")
implementation("androidx.compose.material3:material3-window-size-class:1.2.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
@ -112,12 +115,15 @@ dependencies {
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:a8f9423307")
implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler:1.2.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9") {
exclude(group = "org.json", module = "json")
}
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
implementation("com.squareup.okio:okio:3.7.0")
implementation("com.squareup.okio:okio:3.8.0")
implementation("com.tencent:mmkv:1.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
@ -128,6 +134,9 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

@ -0,0 +1,9 @@
{
"id": 4,
"title": "Read later",
"sortKey": 1,
"order": "NEWEST",
"createdAt": 1335906000000,
"isTrackingEnabled": true,
"isVisibleInLibrary": true
}

@ -0,0 +1,163 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 1552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 1552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,36 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [],
"source": "READMANGA_RU"
}

@ -0,0 +1,136 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,163 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541665,
"name": "2 - 1",
"number": 6,
"url": "/stranstviia_emanon/vol2/1",
"scanlator": "Sup!",
"uploadDate": 1415570400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -0,0 +1,35 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": null,
"source": "READMANGA_RU"
}

@ -0,0 +1,154 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [
{
"id": 3552943969433540704,
"name": "1 - 1",
"number": 1,
"url": "/stranstviia_emanon/vol1/1",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540705,
"name": "1 - 2",
"number": 2,
"url": "/stranstviia_emanon/vol1/2",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540706,
"name": "1 - 3",
"number": 3,
"url": "/stranstviia_emanon/vol1/3",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540707,
"name": "1 - 4",
"number": 4,
"url": "/stranstviia_emanon/vol1/4",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433540708,
"name": "1 - 5",
"number": 5,
"url": "/stranstviia_emanon/vol1/5",
"scanlator": "Sad-Robot",
"uploadDate": 1342731600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541666,
"name": "2 - 2",
"number": 7,
"url": "/stranstviia_emanon/vol2/2",
"scanlator": "Sup!",
"uploadDate": 1419976800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541667,
"name": "2 - 3",
"number": 8,
"url": "/stranstviia_emanon/vol2/3",
"scanlator": "Sup!",
"uploadDate": 1427922000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541668,
"name": "2 - 4",
"number": 9,
"url": "/stranstviia_emanon/vol2/4",
"scanlator": "Sup!",
"uploadDate": 1436907600000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541669,
"name": "2 - 5",
"number": 10,
"url": "/stranstviia_emanon/vol2/5",
"scanlator": "Sup!",
"uploadDate": 1446674400000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433541670,
"name": "2 - 6",
"number": 11,
"url": "/stranstviia_emanon/vol2/6",
"scanlator": "Sup!",
"uploadDate": 1451512800000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542626,
"name": "3 - 1",
"number": 12,
"url": "/stranstviia_emanon/vol3/1",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542627,
"name": "3 - 2",
"number": 13,
"url": "/stranstviia_emanon/vol3/2",
"scanlator": "Sup!",
"uploadDate": 1461618000000,
"source": "READMANGA_RU"
},
{
"id": 3552943969433542628,
"name": "3 - 3",
"number": 14,
"url": "/stranstviia_emanon/vol3/3",
"scanlator": "",
"uploadDate": 1465851600000,
"source": "READMANGA_RU"
}
],
"source": "READMANGA_RU"
}

@ -1,24 +0,0 @@
package org.xtimms.tokusho
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.xtimms.tokusho", appContext.packageName)
}
}

@ -0,0 +1,13 @@
package org.xtimms.tokusho
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}

@ -0,0 +1,9 @@
package org.xtimms.tokusho
import android.app.Instrumentation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine<Unit> { cont ->
waitForIdle { cont.resume(Unit) }
}

@ -0,0 +1,59 @@
package org.xtimms.tokusho
import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okio.buffer
import okio.source
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.model.FavouriteCategory
import java.util.Date
import kotlin.reflect.KClass
object SampleData {
private val moshi = Moshi.Builder()
.add(DateAdapter())
.add(KotlinJsonAdapterFactory())
.build()
val manga: Manga = loadAsset("manga/header.json", Manga::class)
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
val tag = mangaDetails.tags.elementAt(2)
val chapter = checkNotNull(mangaDetails.chapters)[2]
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
fun <T : Any> loadAsset(name: String, cls: KClass<T>): T {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
return assets.open(name).use {
moshi.adapter(cls.java).fromJson(it.source().buffer())
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
}
private class DateAdapter : JsonAdapter<Date>() {
@FromJson
override fun fromJson(reader: JsonReader): Date? {
val ms = reader.nextLong()
return if (ms == 0L) {
null
} else {
Date(ms)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: Date?) {
writer.value(value?.time ?: 0L)
}
}
}

@ -0,0 +1,109 @@
package org.xtimms.tokusho.sections.settings.backup
import android.content.res.AssetManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.xtimms.tokusho.SampleData
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.toMangaTags
import org.xtimms.tokusho.data.repository.FavouritesRepository
import org.xtimms.tokusho.data.repository.HistoryRepository
import org.xtimms.tokusho.data.repository.backup.BackupRepository
import java.io.File
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AppBackupAgentTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var historyRepository: HistoryRepository
@Inject
lateinit var favouritesRepository: FavouritesRepository
@Inject
lateinit var backupRepository: BackupRepository
@Inject
lateinit var database: TokushoDatabase
@Before
fun setUp() {
hiltRule.inject()
database.clearAllTables()
}
@Test
fun backupAndRestore() = runTest {
val category = favouritesRepository.createCategory(
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
)
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate(
manga = SampleData.mangaDetails,
chapterId = SampleData.mangaDetails.chapters!![2].id,
page = 3,
scroll = 40,
percent = 0.2f,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
val agent = AppBackupAgent()
val backup = agent.createBackupFile(
context = InstrumentationRegistry.getInstrumentation().targetContext,
repository = backupRepository,
)
database.clearAllTables()
assertTrue(favouritesRepository.getAllManga().isEmpty())
assertNull(historyRepository.getLastOrNull())
backup.inputStream().use {
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
}
assertEquals(category, favouritesRepository.getCategory(category.id))
assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
assertTrue(SampleData.tag in allTags)
}
@Test
fun restoreOldBackup() {
val agent = AppBackupAgent()
val backup = File.createTempFile("backup_", ".tmp")
InstrumentationRegistry.getInstrumentation().context.assets
.open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
.use { input ->
backup.outputStream().use { output ->
input.copyTo(output)
}
}
backup.inputStream().use {
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
}
runTest {
assertEquals(6, historyRepository.observeAll().first().size)
assertEquals(2, favouritesRepository.observeCategories().first().size)
assertEquals(15, favouritesRepository.getAllManga().size)
}
}
}

@ -23,6 +23,7 @@
<application
android:name=".App"
android:allowBackup="false"
android:backupAgent=".sections.settings.backup.AppBackupAgent"
android:enableOnBackInvokedCallback="true"
android:hardwareAccelerated="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -31,7 +32,7 @@
android:label="@string/app_name"
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.Tokusho"
android:theme="@style/Theme.Tokusho.Starting"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="tiramisu">

@ -6,6 +6,8 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.StrictMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.ProcessLifecycleOwner
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
@ -19,6 +21,7 @@ import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.KotatsuAppSettings
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import javax.inject.Inject
@ -30,6 +33,9 @@ class App : Application() {
@Inject
lateinit var database: Provider<TokushoDatabase>
@Inject
lateinit var settings: KotatsuAppSettings
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)

@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.xtimms.shiki.ui.theme.SEED
import org.xtimms.tokusho.ui.theme.SEED
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference
import org.xtimms.tokusho.core.prefs.paletteStyles

@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -39,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
@ -58,6 +60,9 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val isReady: MutableState<Boolean> = mutableStateOf(false)
private val isDone: MutableState<Boolean> = mutableStateOf(false)
@Inject
lateinit var coil: ImageLoader
@ -65,6 +70,7 @@ class MainActivity : ComponentActivity() {
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !isDone.value }
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@ -77,6 +83,10 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
LaunchedEffect(Unit) {
isReady.value = true
}
if (isReady.value) {
SettingsProvider {
TokushoTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
@ -89,6 +99,10 @@ class MainActivity : ComponentActivity() {
isCompactScreen = isCompactScreen,
navController = navController
)
LaunchedEffect(Unit) {
isDone.value = true
}
}
}
}
}

@ -14,6 +14,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.cache.CacheDir
@ -21,11 +24,13 @@ import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.MemoryContentCache
import org.xtimms.tokusho.core.cache.StubContentCache
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.network.MangaHttpClient
import org.xtimms.tokusho.core.os.NetworkState
import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher
import org.xtimms.tokusho.core.parser.local.LocalStorageChanges
import org.xtimms.tokusho.utils.CoilImageGetter
import org.xtimms.tokusho.utils.system.connectivityManager
import org.xtimms.tokusho.utils.system.isLowRamDevice
@ -98,6 +103,16 @@ interface TokushoModule {
}
}
@Provides
@Singleton
@LocalStorageChanges
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
@Provides
@LocalStorageChanges
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
}
}

@ -4,15 +4,18 @@ import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import org.xtimms.tokusho.R
import org.xtimms.tokusho.utils.composable.rememberResourceBitmapPainter
@Composable
fun AsyncImageImpl(
@ -38,14 +41,12 @@ fun AsyncImageImpl(
)
else AsyncImage(
imageLoader = coil,
model = model?.takeUnless { it == "" },
contentDescription = contentDescription,
model = model,
placeholder = ColorPainter(Color(0x1F888888)),
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
filterQuality = filterQuality
contentDescription = contentDescription
)
}

@ -4,6 +4,8 @@ import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
@ -21,8 +23,13 @@ import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
import org.xtimms.tokusho.sections.details.DetailsView
import org.xtimms.tokusho.sections.details.FULL_POSTER_DESTINATION
import org.xtimms.tokusho.sections.details.FullImageView
import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT
import org.xtimms.tokusho.sections.details.PICTURES_ARGUMENT
import org.xtimms.tokusho.sections.explore.ExploreView
import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
import org.xtimms.tokusho.sections.feed.FeedView
import org.xtimms.tokusho.sections.history.HistoryView
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
import org.xtimms.tokusho.sections.list.MangaListView
@ -33,6 +40,13 @@ import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.SettingsView
import org.xtimms.tokusho.sections.settings.about.ABOUT_DESTINATION
import org.xtimms.tokusho.sections.settings.about.AboutView
import org.xtimms.tokusho.sections.settings.about.LICENSES_DESTINATION
import org.xtimms.tokusho.sections.settings.about.LICENSE_CONTENT_ARGUMENT
import org.xtimms.tokusho.sections.settings.about.LICENSE_DESTINATION
import org.xtimms.tokusho.sections.settings.about.LICENSE_NAME_ARGUMENT
import org.xtimms.tokusho.sections.settings.about.LICENSE_WEBSITE_ARGUMENT
import org.xtimms.tokusho.sections.settings.about.LicenseView
import org.xtimms.tokusho.sections.settings.about.OpenSourceLicensesView
import org.xtimms.tokusho.sections.settings.about.UPDATES_DESTINATION
import org.xtimms.tokusho.sections.settings.about.UpdateView
import org.xtimms.tokusho.sections.settings.advanced.ADVANCED_DESTINATION
@ -43,16 +57,27 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.settings.backup.BACKUP_RESTORE_DESTINATION
import org.xtimms.tokusho.sections.settings.backup.BackupRestoreView
import org.xtimms.tokusho.sections.settings.backup.RESTORE_ARGUMENT
import org.xtimms.tokusho.sections.settings.backup.RESTORE_DESTINATION
import org.xtimms.tokusho.sections.settings.backup.RestoreItemsView
import org.xtimms.tokusho.sections.settings.network.NETWORK_DESTINATION
import org.xtimms.tokusho.sections.settings.network.NetworkView
import org.xtimms.tokusho.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.ShelfSettingsView
import org.xtimms.tokusho.sections.settings.shelf.categories.CATEGORIES_DESTINATION
import org.xtimms.tokusho.sections.settings.shelf.categories.CategoriesView
import org.xtimms.tokusho.sections.settings.sources.SOURCES_DESTINATION
import org.xtimms.tokusho.sections.settings.sources.SourcesView
import org.xtimms.tokusho.sections.settings.sources.catalog.CATALOG_DESTINATION
import org.xtimms.tokusho.sections.settings.sources.catalog.SourcesCatalogView
import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.tokusho.sections.settings.storage.StorageView
import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView
import org.xtimms.tokusho.sections.stats.STATS_DESTINATION
import org.xtimms.tokusho.sections.stats.StatsView
import org.xtimms.tokusho.utils.StringArrayNavType
import org.xtimms.tokusho.utils.lang.removeFirstAndLast
const val DURATION_ENTER = 400
@ -77,6 +102,15 @@ fun Navigation(
val navigateBack: () -> Unit = { navController.popBackStack() }
val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content ->
navController.navigate(
LICENSE_DESTINATION
.replace(LICENSE_NAME_ARGUMENT, name)
.replace(LICENSE_WEBSITE_ARGUMENT, website.orEmpty())
.replace(LICENSE_CONTENT_ARGUMENT, content ?: "No license text")
)
}
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
@ -102,14 +136,18 @@ fun Navigation(
) {
composable(BottomNavDestination.Shelf.route) {
val library: ShelfMap = emptyMap()
ShelfView(
currentPage = { 0 },
coil = coil,
currentPage = { 2 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
padding = padding,
topBarHeightPx = topBarHeightPx,
navigateToDetails = {
navController.navigate(
DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
)
},
onRefresh = { true }
)
}
@ -142,12 +180,26 @@ fun Navigation(
)
}
composable(FEED_DESTINATION) {
FeedView(
navigateBack = navigateBack,
navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
)
}
composable(SETTINGS_DESTINATION) {
SettingsView(
navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
navigateToBackupRestoreSettings = {
navController.navigate(
BACKUP_RESTORE_DESTINATION
)
},
navigateToMangaSources = { navController.navigate(SOURCES_DESTINATION) },
navigateToNetwork = { navController.navigate(NETWORK_DESTINATION) },
navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
)
@ -155,7 +207,6 @@ fun Navigation(
composable(APPEARANCE_DESTINATION) {
AppearanceView(
coil = coil,
navigateBack = navigateBack,
navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
@ -174,6 +225,43 @@ fun Navigation(
)
}
composable(SOURCES_DESTINATION) {
SourcesView(
navigateBack = navigateBack,
navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) },
navigateToSourcesManagement = { /*TODO*/ }
)
}
composable(CATALOG_DESTINATION) {
SourcesCatalogView(
navigateBack = navigateBack,
)
}
composable(BACKUP_RESTORE_DESTINATION) {
BackupRestoreView(
navigateBack = navigateBack,
navigateToRestoreScreen = {
navController.navigate(RESTORE_DESTINATION.replace(RESTORE_ARGUMENT, it))
}
)
}
composable(
route = RESTORE_DESTINATION,
arguments = listOf(
navArgument(RESTORE_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
RestoreItemsView(
uri = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?: "",
navigateBack = navigateBack
)
}
composable(SHELF_SETTINGS_DESTINATION) {
ShelfSettingsView(
navigateBack = navigateBack,
@ -187,6 +275,12 @@ fun Navigation(
)
}
composable(NETWORK_DESTINATION) {
NetworkView(
navigateBack = navigateBack,
)
}
composable(STORAGE_DESTINATION) {
StorageView(
navigateBack = navigateBack,
@ -233,10 +327,43 @@ fun Navigation(
composable(ABOUT_DESTINATION) {
AboutView(
navigateBack = navigateBack,
navigateToLicensesPage = { navController.navigate(LICENSES_DESTINATION) },
navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) }
)
}
composable(LICENSES_DESTINATION) {
OpenSourceLicensesView(
navigateBack = navigateBack,
navigateToLicensePage = navigateToLicense
)
}
composable(
route = LICENSE_DESTINATION,
arguments = listOf(
navArgument(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
},
navArgument(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
},
navArgument(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
LicenseView(
name = navEntry.arguments?.getString(LICENSE_NAME_ARGUMENT.removeFirstAndLast())
.orEmpty(),
website = navEntry.arguments?.getString(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast())
.orEmpty(),
license = navEntry.arguments?.getString(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast())
?: "No license text",
navigateBack = navigateBack
)
}
composable(UPDATES_DESTINATION) {
UpdateView(
navigateBack = navigateBack,
@ -249,13 +376,42 @@ fun Navigation(
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
type = NavType.LongType
}
)
),
) { navEntry ->
DetailsView(
coil = coil,
mangaId = navEntry.arguments?.getLong(MANGA_ID_ARGUMENT.removeFirstAndLast()) ?: 0L,
navigateBack = navigateBack,
navigateToFullImage = { pictures ->
navController.navigate(
FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures)
)
},
navigateToDetails = {
navController.navigate(
DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
)
},
navigateToSource = {
navController.navigate(
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
)
}
)
}
composable(
FULL_POSTER_DESTINATION,
arguments = listOf(
navArgument(PICTURES_ARGUMENT.removeFirstAndLast()) { type = StringArrayNavType }
),
) { navEntry ->
FullImageView(
coil = coil,
pictures = navEntry.arguments?.getStringArray(PICTURES_ARGUMENT.removeFirstAndLast())
?: emptyArray(),
navigateBack = navigateBack
)
}
}
}

@ -0,0 +1,79 @@
package org.xtimms.tokusho.core.base.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.tokusho.utils.lang.EventFlow
import org.xtimms.tokusho.utils.lang.MutableEventFlow
import org.xtimms.tokusho.utils.lang.call
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
abstract class KotatsuBaseViewModel : ViewModel() {
@JvmField
protected val loadingCounter = MutableStateFlow(0)
@JvmField
protected val errorEvent = MutableEventFlow<Throwable>()
val onError: EventFlow<Throwable>
get() = errorEvent
val isLoading: StateFlow<Boolean> = loadingCounter.map { it > 0 }
.stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
loadingCounter.increment()
try {
block()
} finally {
loadingCounter.decrement()
}
}
protected fun <T> Flow<T>.withLoading() = onStart {
loadingCounter.increment()
}.onCompletion {
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,114 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import org.xtimms.tokusho.ui.theme.TokushoTheme
import java.lang.Integer.MAX_VALUE
import kotlin.math.min
enum class ButtonType { PRIMARY, SECONDARY, TERTIARY, DELETE }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimatedButton(
modifier: Modifier = Modifier,
type: ButtonType,
icon: ImageVector? = null,
onClick: (() -> Unit) = {},
onLongClick: (() -> Unit) = {},
) {
val localDensity = LocalDensity.current
var minSize by remember { mutableStateOf(MAX_VALUE.dp) }
var minSizeFloat by remember { mutableStateOf(MAX_VALUE.toFloat()) }
val interactionSource = remember { MutableInteractionSource() }
val isPressed = interactionSource.collectIsPressedAsState()
val radius = animateDpAsState(targetValue = if (isPressed.value) 12.dp else minSize / 2)
val color = when (type) {
ButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer
ButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer
ButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer
ButtonType.DELETE -> MaterialTheme.colorScheme.errorContainer
}
val contentColor = when (type) {
ButtonType.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer
ButtonType.SECONDARY -> MaterialTheme.colorScheme.onSecondaryContainer
ButtonType.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer
ButtonType.DELETE -> MaterialTheme.colorScheme.onErrorContainer
}
Surface(
tonalElevation = 10.dp,
modifier = modifier
.fillMaxSize()
.onGloballyPositioned {
minSize = with(localDensity) { min(it.size.height, it.size.width).toDp() }
minSizeFloat = min(it.size.height, it.size.width).toFloat()
}
.clip(RoundedCornerShape(radius.value))
) {
Box(
modifier = Modifier
.background(color = color)
.fillMaxSize()
.clip(RoundedCornerShape(radius.value))
.combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = { onClick.invoke() },
onLongClick = { onLongClick.invoke() },
),
contentAlignment = Alignment.Center
) {
if (icon !== null) {
Icon(
imageVector = icon,
tint = contentColor,
modifier = Modifier.size(min(minSize * 0.5f, 154.dp)),
contentDescription = null,
)
}
}
}
}
@Preview(name = "Icon")
@Composable
private fun PreviewWithIcon() {
TokushoTheme {
AnimatedButton(
type = ButtonType.PRIMARY,
icon = Icons.Outlined.Edit
)
}
}

@ -0,0 +1,81 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FloatTweenSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.components.shape.WavyShape
import org.xtimms.tokusho.utils.lang.clamp
import org.xtimms.tokusho.utils.material.HarmonizedColorPalette
import kotlin.math.ceil
@Composable
fun BackgroundProgress(
color: Color,
) {
val percentWithNewSpent = 0.3f
val percentWithNewSpentAnimated = animateFloatAsState(
label = "percentWithNewSpentAnimated",
targetValue = percentWithNewSpent,
animationSpec = TweenSpec(300),
).value
val shift = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
fun anim() {
coroutineScope.launch {
shift.animateTo(
1f,
animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
)
shift.snapTo(0f)
anim()
}
}
anim()
}
Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.background(
color.copy(alpha = 0.33f),
shape = WavyShape(
period = 30.dp,
amplitude = percentWithNewSpentAnimated.clamp(0.96f, 1f) * 2.dp,
shift = shift.value,
),
)
.fillMaxHeight()
.fillMaxWidth(percentWithNewSpentAnimated),
)
}
}

@ -1,7 +1,25 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -9,7 +27,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -18,14 +41,29 @@ import androidx.compose.ui.unit.dp
fun DetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
onBackClicked: () -> Unit,
navigateBack: () -> Unit,
navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
var expanded by remember { mutableStateOf(false) }
val padding by animateDpAsState(
targetValue = if (backgroundAlphaProvider() == 1f) 0.dp else 16.dp,
label = "padding",
)
Column(
modifier = modifier,
modifier = modifier
) {
TopAppBar(
navigationIcon = {
CircleBackIconButton(
modifier = Modifier.padding(start = padding),
onClick = navigateBack
)
},
title = {
Text(
text = title,
@ -34,10 +72,44 @@ fun DetailsToolbar(
color = LocalContentColor.current.copy(alpha = titleAlphaProvider()),
)
},
navigationIcon = {
BackIconButton(
onClick = onBackClicked
actions = {
FilledTonalIconButton(
modifier = Modifier.padding(end = padding),
onClick = { expanded = true }, colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.outline,
disabledContentColor = MaterialTheme.colorScheme.outlineVariant
)
) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Share") },
onClick = { /*TODO*/ },
leadingIcon = {
Icon(imageVector = Icons.Default.Share, contentDescription = null)
}
)
DropdownMenuItem(
text = { Text("Download") },
onClick = { /*TODO*/ },
leadingIcon = {
Icon(imageVector = Icons.Outlined.Download, contentDescription = null)
}
)
DropdownMenuItem(
text = { Text("Open in web browser") },
onClick = {
navigateToWebBrowser()
expanded = false
},
leadingIcon = {
Icon(imageVector = Icons.Outlined.Language, contentDescription = null)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme

@ -0,0 +1,25 @@
package org.xtimms.tokusho.core.components
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun DotSeparatorText(
modifier: Modifier = Modifier,
) {
Text(
text = "",
modifier = modifier,
)
}
@Composable
fun DotSeparatorNoSpaceText(
modifier: Modifier = Modifier,
) {
Text(
text = "",
modifier = modifier,
)
}

@ -0,0 +1,125 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
/**
* ExtendedFloatingActionButton with custom transition between collapsed/expanded state.
*
* @see androidx.compose.material3.ExtendedFloatingActionButton
*/
@Composable
fun ExtendedFloatingActionButton(
text: @Composable () -> Unit,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = MaterialTheme.shapes.large,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
) {
val minWidth by animateDpAsState(
targetValue = if (expanded) ExtendedFabMinimumWidth else FabContainerWidth,
label = "minWidth",
)
FloatingActionButton(
modifier = modifier.sizeIn(minWidth = minWidth),
onClick = onClick,
interactionSource = interactionSource,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
elevation = elevation,
) {
val startPadding by animateDpAsState(
targetValue = if (expanded) ExtendedFabIconSize / 2 else 0.dp,
label = "startPadding",
)
val endPadding by animateDpAsState(
targetValue = if (expanded) ExtendedFabTextPadding else 0.dp,
label = "endPadding",
)
Row(
modifier = Modifier.padding(start = startPadding, end = endPadding),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
AnimatedVisibility(
visible = expanded,
enter = ExtendedFabExpandAnimation,
exit = ExtendedFabCollapseAnimation,
) {
Row {
Spacer(Modifier.width(ExtendedFabIconPadding))
text()
}
}
}
}
}
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
private val ExtendedFabMinimumWidth = 80.dp
private val ExtendedFabIconSize = 24.0.dp
private val ExtendedFabIconPadding = 12.dp
private val ExtendedFabTextPadding = 20.dp
private val ExtendedFabCollapseAnimation = fadeOut(
animationSpec = tween(
durationMillis = 100,
easing = EasingLinearCubicBezier,
),
) + shrinkHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
shrinkTowards = Alignment.Start,
)
private val ExtendedFabExpandAnimation = fadeIn(
animationSpec = tween(
durationMillis = 200,
delayMillis = 100,
easing = EasingLinearCubicBezier,
),
) + expandHorizontally(
animationSpec = tween(
durationMillis = 500,
easing = EasingEmphasizedCubicBezier,
),
expandFrom = Alignment.Start,
)
private val FabContainerWidth = 56.0.dp

@ -1,12 +1,19 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.OpenInBrowser
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
@Composable
@ -21,6 +28,28 @@ fun BackIconButton(
}
}
@Composable
fun CircleBackIconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
FilledTonalIconButton(
modifier = modifier,
onClick = onClick,
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.outline,
disabledContentColor = MaterialTheme.colorScheme.outlineVariant
)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "arrow_back"
)
}
}
@Composable
fun ViewInBrowserButton(
onClick: () -> Unit

@ -18,7 +18,7 @@ import org.xtimms.tokusho.core.AsyncImageImpl
enum class MangaCover(val ratio: Float) {
Square(1f / 1f),
Book(10f / 16f),
Book(2f / 3f),
;
@Composable

@ -6,14 +6,17 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -33,25 +36,19 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
private const val GridSelectedCoverAlpha = 0.76f
/**
* Layout of grid list item with title overlaying the cover.
* Accepts null [title] for a cover-only view.
*/
@Composable
fun MangaCompactGridItem(
fun MangaGridItem(
coil: ImageLoader,
imageUrl: String,
manga: Manga,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
title: String? = null,
onClickContinueReading: (() -> Unit)? = null,
coverAlpha: Float = 1f,
) {
GridItemSelectable(
isSelected = isSelected,
@ -71,12 +68,53 @@ fun MangaCompactGridItem(
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F),
coil = coil,
model = imageUrl,
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
}
Text(
text = manga.title,
modifier = Modifier.padding(4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
@Composable
fun MangaHorizontalItem(
coil: ImageLoader,
manga: Manga,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier.width(IntrinsicSize.Min)
) {
Column(
horizontalAlignment = Alignment.Start
) {
Box {
AsyncImageImpl(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F)
.height(156.dp),
coil = coil,
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
}
Text(
text = title!!,
text = manga.title,
modifier = Modifier.padding(4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
@ -207,17 +245,3 @@ private fun Modifier.selectedOutline(
isSelected: Boolean,
color: Color,
) = this then drawBehind { if (isSelected) drawRect(color = color) }
@PreviewLightDark
@Composable
fun MangaGridItemPreview() {
TokushoTheme {
MangaCompactGridItem(
coil = ImageLoader(LocalContext.current),
imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
title = "Stub",
onClick = { },
onLongClick = { }
)
}
}

@ -65,14 +65,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.xtimms.shiki.ui.theme.FixedAccentColors
import org.xtimms.tokusho.ui.theme.FixedAccentColors
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.preferenceTitle
import org.xtimms.tokusho.utils.FileSize
private const val horizontal = 8
@ -170,7 +169,6 @@ internal fun PreferenceItemTitle(
modifier: Modifier = Modifier,
text: String,
maxLines: Int = 2,
style: TextStyle = preferenceTitle,
enabled: Boolean,
color: Color = MaterialTheme.colorScheme.onBackground,
overflow: TextOverflow = TextOverflow.Ellipsis
@ -179,7 +177,6 @@ internal fun PreferenceItemTitle(
modifier = modifier,
text = text,
maxLines = maxLines,
style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
@ -349,7 +346,6 @@ fun PreferenceSwitchWithContainer(
Text(
text = title,
maxLines = 2,
style = preferenceTitle,
color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface
)
}

@ -0,0 +1,290 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
/**
* @param refreshing Whether the layout is currently refreshing
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
* @param enabled Whether the the layout should react to swipe gestures or not.
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param content The content containing a vertically scrollable composable.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullRefresh(
refreshing: Boolean,
enabled: () -> Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
indicatorPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable () -> Unit,
) {
val state = rememberPullToRefreshState(
isRefreshing = refreshing,
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
enabled = enabled,
onRefresh = onRefresh,
)
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
content()
val contentPadding = remember(indicatorPadding) {
object : PaddingValues {
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateLeftPadding(layoutDirection)
override fun calculateTopPadding(): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateRightPadding(layoutDirection)
override fun calculateBottomPadding(): Dp =
indicatorPadding.calculateBottomPadding()
}
}
PullToRefreshContainer(
state = state,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(contentPadding),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun rememberPullToRefreshState(
isRefreshing: Boolean,
extraVerticalOffset: Dp,
positionalThreshold: Dp = 64.dp,
enabled: () -> Boolean = { true },
onRefresh: () -> Unit,
): PullToRefreshStateImpl {
val density = LocalDensity.current
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
return rememberSaveable(
extraVerticalOffset,
positionalThresholdPx,
enabled,
onRefresh,
saver = PullToRefreshStateImpl.Saver(
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
),
) {
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
)
}.also {
LaunchedEffect(isRefreshing) {
if (isRefreshing && !it.isRefreshing) {
it.startRefreshAnimated()
} else if (!isRefreshing && it.isRefreshing) {
it.endRefreshAnimated()
}
}
}
}
/**
* Creates a [PullToRefreshState].
*
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
* @param enabled a callback used to determine whether scroll events are to be handled by this
* @param onRefresh a callback to run when pull-to-refresh action is triggered by user
* [PullToRefreshState]
*/
@OptIn(ExperimentalMaterial3Api::class)
private class PullToRefreshStateImpl(
initialRefreshing: Boolean,
private val extraVerticalOffset: Float,
override val positionalThreshold: Float,
enabled: () -> Boolean,
private val onRefresh: () -> Unit,
) : PullToRefreshState {
override val progress get() = adjustedDistancePulled / positionalThreshold
override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
override var isRefreshing by mutableStateOf(initialRefreshing)
private val refreshingVerticalOffset: Float
get() = positionalThreshold + extraVerticalOffset
override fun startRefresh() {
isRefreshing = true
verticalOffset = refreshingVerticalOffset
}
suspend fun startRefreshAnimated() {
isRefreshing = true
animateTo(refreshingVerticalOffset)
}
override fun endRefresh() {
verticalOffset = 0f
isRefreshing = false
}
suspend fun endRefreshAnimated() {
animateTo(0f)
isRefreshing = false
}
override var nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping up
source == NestedScrollSource.Drag && available.y < 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping down
source == NestedScrollSource.Drag && available.y > 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
}
/** Helper method for nested scroll connection */
fun consumeAvailableOffset(available: Offset): Offset {
val y = if (isRefreshing) {
0f
} else {
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
dragConsumed
}
return Offset(0f, y)
}
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
suspend fun onRelease(velocity: Float): Float {
if (isRefreshing) return 0f // Already refreshing, do nothing
// Trigger refresh
if (adjustedDistancePulled > positionalThreshold) {
onRefresh()
startRefreshAnimated()
} else {
animateTo(0f)
}
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
suspend fun animateTo(offset: Float) {
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
verticalOffset = value
}
}
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
fun calculateVerticalOffset(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = positionalThreshold * tensionPercent
positionalThreshold + extraOffset
}
}
companion object {
/** The default [Saver] for [PullToRefreshStateImpl]. */
fun Saver(
extraVerticalOffset: Float,
positionalThreshold: Float,
enabled: () -> Boolean,
onRefresh: () -> Unit,
) = Saver<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,187 @@
package org.xtimms.tokusho.core.components
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FloatTweenSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.material.combineColors
import org.xtimms.tokusho.utils.material.harmonize
import org.xtimms.tokusho.utils.material.toPalette
@Composable
fun RowScope.ReadButton() {
val shift = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
fun anim() {
coroutineScope.launch {
shift.animateTo(
1f,
animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
)
shift.snapTo(0f)
anim()
}
}
anim()
}
val percentWithNewSpentAnimated = animateFloatAsState(
label = "percentWithNewSpentAnimated",
targetValue = 0.3f,
animationSpec = TweenSpec(300),
).value
Card(
modifier = Modifier
.weight(1F)
.height(54.dp),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
onClick = {
// appViewModel.openSheet(PathState(WALLET_SHEET))
}
) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.CenterEnd,
) {
BackgroundProgress(MaterialTheme.colorScheme.primary)
Row(
modifier = Modifier
.fillMaxSize()
.drawWithLayer {
drawContent()
val leftOffset = size.width - 20.dp.toPx()
drawRect(
topLeft = Offset(leftOffset, 0f),
size = Size(
20.dp.toPx(),
size.height,
),
blendMode = BlendMode.SrcIn,
brush = Brush.horizontalGradient(
colors = listOf(
Color.Black,
Color.Black.copy(alpha = 0f),
),
startX = leftOffset,
endX = leftOffset + 14.dp.toPx()
)
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Spacer(modifier = Modifier.weight(1f))
Text(text = "Continue reading", color = MaterialTheme.colorScheme.onPrimaryContainer)
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
Modifier.drawWithContent {
drawWithLayer {
block()
}
}
)
@Preview(name = "The budget is almost completely spent")
@Composable
private fun Preview() {
TokushoTheme {
Row {
ReadButton()
}
}
}
@Preview(name = "Budget half spent")
@Composable
private fun PreviewHalf() {
TokushoTheme {
Row {
ReadButton()
}
}
}
@Preview(name = "Almost no budget")
@Composable
private fun PreviewFull() {
TokushoTheme {
Row {
ReadButton()
}
}
}
@Preview(name = "Overspending budget")
@Composable
private fun PreviewOverspending() {
TokushoTheme {
Row {
ReadButton()
}
}
}
@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun PreviewNightMode() {
TokushoTheme {
Row {
ReadButton()
}
}
}

@ -1,6 +1,7 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
@ -21,6 +22,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
fun ScaffoldWithTopAppBar(
title: String,
navigateBack: () -> Unit,
snackbarHost: @Composable (() -> Unit) = {},
floatingActionButton: @Composable (() -> Unit) = {},
content: @Composable (PaddingValues) -> Unit
) {
@ -40,6 +42,7 @@ fun ScaffoldWithTopAppBar(
navigateBack = navigateBack
)
},
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
content = content
@ -84,6 +87,7 @@ fun ScaffoldWithClassicTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
actions: @Composable (RowScope.() -> Unit) = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
@ -99,6 +103,7 @@ fun ScaffoldWithClassicTopAppBar(
ClassicTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
actions = actions,
navigateBack = navigateBack
)
},

@ -0,0 +1,54 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.lang.toStringPositiveValueOrUnknown
@Composable
fun SmallScoreIndicator(
score: Float?,
modifier: Modifier = Modifier,
fontSize: TextUnit = 16.sp,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.StarOutline,
contentDescription = stringResource(R.string.mean_score),
tint = MaterialTheme.colorScheme.outline
)
if (score != null) {
Text(
text = (score.times(5.0F)).toStringPositiveValueOrUnknown(),
modifier = Modifier.padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outline,
fontSize = fontSize
)
}
}
}
@Preview(showBackground = true)
@Composable
fun SmallScoreIndicatorPreview() {
TokushoTheme {
SmallScoreIndicator(score = 1f)
}
}

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -14,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
@ -28,6 +30,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
@ -42,11 +46,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.collections.immutable.persistentListOf
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.initialOffset
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
@ -124,7 +130,7 @@ fun TopAppBar(
modifier = modifier.padding(end = 16.dp),
) {
IconButton(
onClick = { },
onClick = { navController.navigate(FEED_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
@ -218,6 +224,7 @@ fun SmallTopAppBar(
fun ClassicTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior? = null,
actions: @Composable (RowScope.() -> Unit),
navigateBack: () -> Unit,
) {
androidx.compose.material3.TopAppBar(
@ -225,6 +232,7 @@ fun ClassicTopAppBar(
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
actions = actions,
scrollBehavior = scrollBehavior
)
}
@ -248,7 +256,16 @@ fun DefaultTopAppBarWithChipsPreview() {
TokushoTheme {
SmallTopAppBarWithChips(
title = "Tokusho",
chips = listOf("Chip 1", "Chip 2", "Chip 3", "Chip 4", "Chip 1", "Chip 2", "Chip 3", "Chip 4"),
chips = listOf(
"Chip 1",
"Chip 2",
"Chip 3",
"Chip 4",
"Chip 1",
"Chip 2",
"Chip 3",
"Chip 4"
),
navigateBack = {}
)
}
@ -273,7 +290,15 @@ fun ClassicTopAppBarPreview() {
TokushoTheme {
ClassicTopAppBar(
title = "Tokusho",
navigateBack = {}
navigateBack = {},
actions = {
IconButton(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Localized description"
)
}
}
)
}
}

@ -0,0 +1,60 @@
package org.xtimms.tokusho.core.components.effects
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import kotlin.random.Random
data class Snowflake(
var x: Float,
var y: Float,
var radius: Float,
var speed: Float
)
@Composable
fun SnowfallEffect() {
val snowflakes = remember { List(100) { generateRandomSnowflake() } }
val infiniteTransition = rememberInfiniteTransition(label = "")
val offsetY by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 100f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 10000, easing = LinearEasing)
), label = ""
)
Canvas(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
snowflakes.forEach { snowflake ->
drawSnowflake(snowflake, offsetY % size.height)
}
}
}
fun generateRandomSnowflake(): Snowflake {
return Snowflake(
x = Random.nextFloat(),
y = Random.nextFloat() * 1000f,
radius = Random.nextFloat() * 2f + 2f, // Snowflake size
speed = Random.nextFloat() * 0.5f + 1f // Falling speed
)
}
fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
}

@ -0,0 +1,53 @@
package org.xtimms.tokusho.core.components.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
public val Icons.Outlined.ArrowDecisionOutline: ImageVector
get() {
if (_arrow_decision_outline != null) {
return _arrow_decision_outline!!
}
_arrow_decision_outline = materialIcon(name = "Outlined.ArrowDecisionOutline") {
materialPath {
moveTo(9.64f, 13.4f)
curveTo(8.63f, 12.5f, 7.34f, 12.03f, 6.0f, 12.0f)
verticalLineTo(15.0f)
lineTo(2.0f, 11.0f)
lineTo(6.0f, 7.0f)
verticalLineTo(10.0f)
curveTo(7.67f, 10.0f, 9.3f, 10.57f, 10.63f, 11.59f)
curveTo(10.22f, 12.15f, 9.89f, 12.76f, 9.64f, 13.4f)
moveTo(18.0f, 15.0f)
verticalLineTo(12.0f)
curveTo(17.5f, 12.0f, 13.5f, 12.16f, 13.05f, 16.2f)
curveTo(14.61f, 16.75f, 15.43f, 18.47f, 14.88f, 20.03f)
curveTo(14.33f, 21.59f, 12.61f, 22.41f, 11.05f, 21.86f)
curveTo(9.5f, 21.3f, 8.67f, 19.59f, 9.22f, 18.03f)
curveTo(9.5f, 17.17f, 10.2f, 16.5f, 11.05f, 16.2f)
curveTo(11.34f, 12.61f, 14.4f, 9.88f, 18.0f, 10.0f)
verticalLineTo(7.0f)
lineTo(22.0f, 11.0f)
lineTo(18.0f, 15.0f)
moveTo(13.0f, 19.0f)
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 18.0f)
arcTo(1.0f, 1.0f, 0.0f, false, false, 11.0f, 19.0f)
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 20.0f)
arcTo(1.0f, 1.0f, 0.0f, false, false, 13.0f, 19.0f)
moveTo(11.0f, 11.12f)
curveTo(11.58f, 10.46f, 12.25f, 9.89f, 13.0f, 9.43f)
verticalLineTo(5.0f)
horizontalLineTo(16.0f)
lineTo(12.0f, 1.0f)
lineTo(8.0f, 5.0f)
horizontalLineTo(11.0f)
verticalLineTo(11.12f)
close()
}
}
return _arrow_decision_outline!!
}
private var _arrow_decision_outline: ImageVector? = null

@ -0,0 +1,293 @@
package org.xtimms.tokusho.core.components.icons
import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
public val Icons.Filled.Kotatsu: ImageVector
get() {
if (_kotatsu != null) {
return _kotatsu!!
}
_kotatsu = Builder(name = "Kotatsu", defaultWidth = 1406.2.dp, defaultHeight = 1406.2.dp,
viewportWidth = 1406.2f, viewportHeight = 1406.2f).apply {
path(fill = SolidColor(Color(0xFF0058C9)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(391.7f, 270.7f)
curveToRelative(-51.6f, 18.6f, -96.2f, 88.4f, -117.9f, 183.6f)
curveToRelative(-7.8f, 34.8f, -15.1f, 93.5f, -15.1f, 121.7f)
verticalLineToRelative(19.7f)
lineToRelative(-23.3f, 36.6f)
curveToRelative(-65.0f, 101.3f, -124.6f, 206.8f, -180.5f, 319.2f)
curveTo(5.1f, 1051.0f, 0.0f, 1063.2f, 0.0f, 1080.9f)
curveToRelative(0.0f, 7.8f, 2.0f, 18.0f, 4.0f, 22.2f)
curveToRelative(6.6f, 12.0f, 22.2f, 24.4f, 39.7f, 31.0f)
lineToRelative(16.0f, 6.2f)
lineToRelative(651.1f, -0.2f)
curveToRelative(633.1f, 0.0f, 651.1f, -0.4f, 661.2f, -5.3f)
curveToRelative(32.6f, -16.9f, 43.0f, -51.7f, 26.2f, -88.0f)
curveToRelative(-63.8f, -139.0f, -150.5f, -296.8f, -229.6f, -418.5f)
lineToRelative(-19.1f, -29.5f)
lineToRelative(-2.2f, -33.7f)
curveToRelative(-8.7f, -129.4f, -36.1f, -208.6f, -92.0f, -266.5f)
curveToRelative(-24.2f, -24.8f, -33.5f, -30.4f, -50.6f, -30.6f)
curveToRelative(-23.9f, 0.0f, -39.9f, 10.9f, -75.6f, 52.1f)
curveToRelative(-35.2f, 40.4f, -42.4f, 50.1f, -66.9f, 86.4f)
curveToRelative(-12.0f, 17.7f, -27.0f, 38.3f, -33.2f, 45.5f)
lineToRelative(-11.3f, 13.5f)
horizontalLineToRelative(-117.0f)
lineToRelative(-117.0f, -0.2f)
lineTo(560.2f, 429.0f)
curveTo(515.0f, 359.2f, 440.7f, 274.5f, 419.6f, 268.7f)
curveTo(406.8f, 264.9f, 409.0f, 264.5f, 391.7f, 270.7f)
close()
moveTo(466.2f, 666.4f)
curveToRelative(8.9f, 6.2f, 11.3f, 11.8f, 14.4f, 37.7f)
curveToRelative(4.0f, 30.6f, 7.7f, 34.8f, 27.5f, 32.4f)
curveToRelative(18.0f, -2.2f, 32.6f, 3.6f, 40.8f, 16.6f)
curveToRelative(16.0f, 25.9f, -11.5f, 80.2f, -50.6f, 99.3f)
curveToRelative(-14.0f, 7.1f, -19.1f, 7.8f, -42.8f, 7.8f)
curveToRelative(-22.8f, 0.0f, -28.8f, -1.1f, -39.4f, -6.7f)
curveToRelative(-31.2f, -16.4f, -50.3f, -40.3f, -58.3f, -71.8f)
curveToRelative(-4.0f, -16.6f, -4.2f, -21.7f, -1.1f, -36.3f)
curveToRelative(4.2f, -21.1f, 11.5f, -35.2f, 24.8f, -50.1f)
curveTo(404.8f, 669.5f, 449.1f, 654.4f, 466.2f, 666.4f)
close()
moveTo(964.0f, 669.0f)
curveToRelative(8.7f, 7.3f, 9.3f, 9.7f, 13.5f, 43.4f)
curveToRelative(2.6f, 20.6f, 8.7f, 26.8f, 25.3f, 24.2f)
curveToRelative(16.0f, -2.2f, 29.9f, 2.2f, 39.2f, 12.9f)
curveToRelative(15.1f, 18.2f, 6.2f, 53.4f, -20.8f, 82.2f)
curveToRelative(-21.7f, 23.1f, -35.2f, 28.4f, -69.2f, 28.8f)
curveToRelative(-25.9f, 0.0f, -29.1f, -0.5f, -42.8f, -8.2f)
curveToRelative(-20.2f, -11.3f, -38.4f, -29.9f, -47.7f, -49.0f)
curveToRelative(-6.2f, -12.8f, -8.2f, -20.8f, -8.9f, -39.2f)
curveToRelative(-0.9f, -21.1f, 0.0f, -25.0f, 7.7f, -41.0f)
curveToRelative(14.0f, -30.4f, 35.5f, -49.2f, 65.0f, -57.0f)
curveTo(945.2f, 660.2f, 954.7f, 661.1f, 964.0f, 669.0f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(1806.5f, 939.2f)
verticalLineTo(464.0f)
horizontalLineToRelative(88.3f)
verticalLineToRelative(475.3f)
lineTo(1806.5f, 939.2f)
lineTo(1806.5f, 939.2f)
close()
moveTo(1885.3f, 827.2f)
lineToRelative(-4.8f, -104.5f)
lineTo(2129.7f, 464.0f)
horizontalLineToRelative(99.1f)
lineToRelative(-207.1f, 220.0f)
lineToRelative(-48.9f, 53.6f)
lineTo(1885.3f, 827.2f)
close()
moveTo(2137.8f, 939.2f)
lineToRelative(-181.9f, -216.6f)
lineToRelative(58.4f, -64.5f)
lineTo(2241.0f, 939.2f)
horizontalLineTo(2137.8f)
lineTo(2137.8f, 939.2f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(2440.6f, 944.0f)
curveToRelative(-37.1f, 0.0f, -70.2f, -8.0f, -99.1f, -24.1f)
curveToRelative(-29.0f, -16.1f, -51.8f, -38.1f, -68.6f, -66.2f)
curveToRelative(-16.8f, -28.1f, -25.1f, -60.0f, -25.1f, -95.7f)
curveToRelative(0.0f, -36.2f, 8.4f, -68.2f, 25.1f, -96.1f)
curveToRelative(16.7f, -27.8f, 39.6f, -49.7f, 68.6f, -65.5f)
reflectiveCurveToRelative(62.0f, -23.8f, 99.1f, -23.8f)
curveToRelative(37.6f, 0.0f, 70.9f, 7.9f, 100.1f, 23.8f)
curveToRelative(29.2f, 15.9f, 52.1f, 37.6f, 68.6f, 65.2f)
curveToRelative(16.5f, 27.6f, 24.8f, 59.8f, 24.8f, 96.4f)
curveToRelative(0.0f, 35.8f, -8.3f, 67.7f, -24.8f, 95.7f)
curveToRelative(-16.5f, 28.1f, -39.4f, 50.1f, -68.6f, 66.2f)
curveTo(2511.6f, 936.0f, 2478.2f, 944.0f, 2440.6f, 944.0f)
close()
moveTo(2440.6f, 871.3f)
curveToRelative(20.8f, 0.0f, 39.4f, -4.5f, 55.7f, -13.6f)
curveToRelative(16.3f, -9.0f, 29.1f, -22.2f, 38.4f, -39.4f)
curveToRelative(9.3f, -17.2f, 13.9f, -37.3f, 13.9f, -60.4f)
curveToRelative(0.0f, -23.5f, -4.6f, -43.8f, -13.9f, -60.8f)
curveToRelative(-9.3f, -17.0f, -22.1f, -30.0f, -38.4f, -39.0f)
curveToRelative(-16.3f, -9.0f, -34.6f, -13.6f, -55.0f, -13.6f)
curveToRelative(-20.8f, 0.0f, -39.3f, 4.5f, -55.3f, 13.6f)
curveToRelative(-16.1f, 9.0f, -28.8f, 22.1f, -38.3f, 39.0f)
curveToRelative(-9.5f, 17.0f, -14.3f, 37.2f, -14.3f, 60.8f)
curveToRelative(0.0f, 23.1f, 4.8f, 43.2f, 14.3f, 60.4f)
reflectiveCurveToRelative(22.3f, 30.3f, 38.3f, 39.4f)
curveTo(2402.0f, 866.8f, 2420.3f, 871.3f, 2440.6f, 871.3f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(2667.4f, 647.3f)
verticalLineToRelative(-67.9f)
horizontalLineToRelative(241.7f)
verticalLineToRelative(67.9f)
horizontalLineTo(2667.4f)
close()
moveTo(2852.1f, 944.0f)
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
verticalLineTo(496.6f)
horizontalLineToRelative(84.9f)
verticalLineToRelative(323.2f)
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
lineToRelative(23.8f, 60.4f)
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
curveTo(2882.4f, 941.9f, 2867.4f, 944.0f, 2852.1f, 944.0f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(3112.8f, 944.0f)
curveToRelative(-27.2f, 0.0f, -50.9f, -4.6f, -71.3f, -13.9f)
curveToRelative(-20.4f, -9.3f, -36.1f, -22.2f, -47.2f, -38.7f)
curveToRelative(-11.1f, -16.5f, -16.6f, -35.2f, -16.6f, -56.0f)
curveToRelative(0.0f, -20.4f, 4.9f, -38.7f, 14.6f, -55.0f)
curveToRelative(9.7f, -16.3f, 25.7f, -29.2f, 47.9f, -38.7f)
reflectiveCurveToRelative(51.6f, -14.3f, 88.3f, -14.3f)
horizontalLineToRelative(105.2f)
verticalLineToRelative(56.3f)
horizontalLineToRelative(-99.1f)
curveToRelative(-29.0f, 0.0f, -48.4f, 4.7f, -58.4f, 13.9f)
curveToRelative(-10.0f, 9.3f, -14.9f, 20.7f, -14.9f, 34.3f)
curveToRelative(0.0f, 15.4f, 6.1f, 27.6f, 18.3f, 36.7f)
curveToRelative(12.2f, 9.1f, 29.2f, 13.6f, 50.9f, 13.6f)
curveToRelative(20.8f, 0.0f, 39.5f, -4.8f, 56.0f, -14.3f)
reflectiveCurveToRelative(28.4f, -23.5f, 35.6f, -42.1f)
lineToRelative(14.3f, 50.9f)
curveToRelative(-8.1f, 21.3f, -22.7f, 37.8f, -43.8f, 49.6f)
reflectiveCurveTo(3144.9f, 944.0f, 3112.8f, 944.0f)
close()
moveTo(3226.8f, 939.2f)
verticalLineToRelative(-73.3f)
lineToRelative(-4.8f, -15.6f)
verticalLineTo(722.0f)
curveToRelative(0.0f, -24.9f, -7.5f, -44.2f, -22.4f, -58.0f)
reflectiveCurveToRelative(-37.6f, -20.7f, -67.9f, -20.7f)
curveToRelative(-20.4f, 0.0f, -40.4f, 3.2f, -60.1f, 9.5f)
curveToRelative(-19.7f, 6.3f, -36.3f, 15.2f, -49.9f, 26.5f)
lineToRelative(-33.3f, -61.8f)
curveToRelative(19.4f, -14.9f, 42.6f, -26.1f, 69.6f, -33.6f)
curveToRelative(26.9f, -7.5f, 54.9f, -11.2f, 83.8f, -11.2f)
curveToRelative(52.5f, 0.0f, 93.1f, 12.6f, 121.9f, 37.7f)
curveToRelative(28.7f, 25.1f, 43.1f, 63.9f, 43.1f, 116.4f)
verticalLineToRelative(212.5f)
lineTo(3226.8f, 939.2f)
lineTo(3226.8f, 939.2f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(3367.4f, 647.3f)
verticalLineToRelative(-67.9f)
horizontalLineToRelative(241.7f)
verticalLineToRelative(67.9f)
horizontalLineTo(3367.4f)
close()
moveTo(3552.0f, 944.0f)
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
verticalLineTo(496.6f)
horizontalLineToRelative(84.9f)
verticalLineToRelative(323.2f)
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
lineToRelative(23.8f, 60.4f)
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
curveTo(3582.3f, 941.9f, 3567.4f, 944.0f, 3552.0f, 944.0f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(3815.5f, 944.0f)
curveToRelative(-30.3f, 0.0f, -59.4f, -4.0f, -87.2f, -11.9f)
reflectiveCurveToRelative(-49.9f, -17.5f, -66.2f, -28.9f)
lineToRelative(32.6f, -64.5f)
curveToRelative(16.3f, 10.4f, 35.8f, 19.0f, 58.4f, 25.8f)
curveToRelative(22.6f, 6.8f, 45.3f, 10.2f, 67.9f, 10.2f)
curveToRelative(26.7f, 0.0f, 46.0f, -3.6f, 58.0f, -10.9f)
curveToRelative(12.0f, -7.2f, 18.0f, -17.0f, 18.0f, -29.2f)
curveToRelative(0.0f, -10.0f, -4.1f, -17.5f, -12.2f, -22.8f)
curveToRelative(-8.1f, -5.2f, -18.8f, -9.2f, -31.9f, -11.9f)
curveToRelative(-13.1f, -2.7f, -27.7f, -5.2f, -43.8f, -7.5f)
reflectiveCurveToRelative(-32.1f, -5.3f, -48.2f, -9.2f)
curveToRelative(-16.1f, -3.8f, -30.7f, -9.5f, -43.8f, -17.0f)
reflectiveCurveToRelative(-23.8f, -17.5f, -31.9f, -30.2f)
curveToRelative(-8.1f, -12.7f, -12.2f, -29.4f, -12.2f, -50.2f)
curveToRelative(0.0f, -23.1f, 6.6f, -43.1f, 19.7f, -60.1f)
curveToRelative(13.1f, -17.0f, 31.6f, -30.1f, 55.3f, -39.4f)
curveToRelative(23.8f, -9.3f, 51.9f, -13.9f, 84.5f, -13.9f)
curveToRelative(24.4f, 0.0f, 49.1f, 2.7f, 74.0f, 8.1f)
curveToRelative(24.9f, 5.4f, 45.5f, 13.1f, 61.8f, 23.1f)
lineToRelative(-32.6f, 64.5f)
curveToRelative(-17.2f, -10.4f, -34.5f, -17.5f, -51.9f, -21.4f)
curveToRelative(-17.4f, -3.8f, -34.7f, -5.8f, -51.9f, -5.8f)
curveToRelative(-25.8f, 0.0f, -44.9f, 3.9f, -57.4f, 11.5f)
curveToRelative(-12.4f, 7.7f, -18.7f, 17.4f, -18.7f, 29.2f)
curveToRelative(0.0f, 10.9f, 4.1f, 19.0f, 12.2f, 24.4f)
curveToRelative(8.1f, 5.4f, 18.8f, 9.7f, 31.9f, 12.9f)
curveToRelative(13.1f, 3.2f, 27.7f, 5.8f, 43.8f, 7.8f)
reflectiveCurveToRelative(32.0f, 5.1f, 47.9f, 9.2f)
curveToRelative(15.8f, 4.1f, 30.4f, 9.6f, 43.8f, 16.6f)
curveToRelative(13.3f, 7.0f, 24.1f, 16.9f, 32.3f, 29.5f)
curveToRelative(8.1f, 12.7f, 12.2f, 29.2f, 12.2f, 49.6f)
curveToRelative(0.0f, 22.6f, -6.7f, 42.3f, -20.0f, 59.1f)
curveToRelative(-13.4f, 16.8f, -32.3f, 29.8f, -56.7f, 39.0f)
curveTo(3878.6f, 939.3f, 3849.4f, 944.0f, 3815.5f, 944.0f)
close()
}
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(4206.5f, 944.0f)
curveToRelative(-30.8f, 0.0f, -57.9f, -5.8f, -81.5f, -17.3f)
curveToRelative(-23.5f, -11.5f, -41.9f, -29.2f, -55.0f, -53.0f)
reflectiveCurveToRelative(-19.7f, -53.7f, -19.7f, -90.0f)
verticalLineToRelative(-207.0f)
horizontalLineToRelative(84.9f)
verticalLineToRelative(195.5f)
curveToRelative(0.0f, 32.6f, 7.4f, 56.9f, 22.1f, 73.0f)
reflectiveCurveToRelative(35.6f, 24.1f, 62.8f, 24.1f)
curveToRelative(19.9f, 0.0f, 37.2f, -4.1f, 51.9f, -12.2f)
curveToRelative(14.7f, -8.1f, 26.2f, -20.4f, 34.6f, -36.7f)
reflectiveCurveToRelative(12.6f, -36.4f, 12.6f, -60.4f)
verticalLineTo(576.7f)
horizontalLineToRelative(84.9f)
verticalLineToRelative(362.5f)
horizontalLineToRelative(-80.8f)
verticalLineToRelative(-97.8f)
lineToRelative(14.3f, 29.9f)
curveToRelative(-12.2f, 23.5f, -30.1f, 41.5f, -53.6f, 54.0f)
curveTo(4260.4f, 937.8f, 4234.6f, 944.0f, 4206.5f, 944.0f)
close()
}
}
.build()
return _kotatsu!!
}
private var _kotatsu: ImageVector? = null

@ -0,0 +1,46 @@
package org.xtimms.tokusho.core.components.shape
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.ceil
class WavyShape(
private val period: Dp,
private val amplitude: Dp,
private val shift: Float,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
) = Outline.Generic(Path().apply {
val halfPeriod = with(density) { period.toPx() } / 2
val amplitude = with(density) { amplitude.toPx() }
val wavyPath = Path().apply {
moveTo(x = 0f, y = 0f)
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
relativeQuadraticBezierTo(
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dy1 = halfPeriod / 2,
dx2 = 0f,
dy2 = halfPeriod,
)
}
lineTo(0f, size.height)
}
val boundsPath = Path().apply {
addRect(Rect(offset = Offset.Zero, size = size))
}
op(wavyPath, boundsPath, PathOperation.Intersect)
})
}

@ -21,5 +21,84 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
0L,
)
)
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.reading),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.completed),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.dropped),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
db.execSQL(
"INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)",
arrayOf(
"MANGADEX",
1,
1,
)
)
db.execSQL(
"INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)",
arrayOf(
"DESUME",
1,
1,
)
)
db.execSQL(
"INSERT INTO manga (manga_id, title, alt_title, url, public_url, rating, nsfw, cover_url, large_cover_url, state, author, source) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
4427365311541330000,
"Seitokai ni mo Ana wa Aru!",
"",
"822c9883-385c-4fd0-9523-16e7789cbeae",
"https://mangadex.org/title/822c9883-385c-4fd0-9523-16e7789cbeae",
-1.0,
0,
"https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg.256.jpg",
"https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg",
"FINISHED",
"Muchi Maro",
"MANGADEX",
)
)
db.execSQL(
"INSERT INTO favourites (manga_id, category_id, sort_key, created_at, deleted_at) VALUES (?,?,?,?,?)",
arrayOf(
4427365311541330000,
1,
0,
1705944302882,
0,
)
)
}
}

@ -10,11 +10,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.database.dao.BookmarksDao
import org.xtimms.tokusho.core.database.dao.FavouriteCategoriesDao
import org.xtimms.tokusho.core.database.dao.FavouritesDao
import org.xtimms.tokusho.core.database.dao.HistoryDao
import org.xtimms.tokusho.core.database.dao.MangaDao
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import org.xtimms.tokusho.core.database.dao.TagsDao
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.HistoryEntity
@ -34,12 +37,15 @@ const val DATABASE_VERSION = 1
MangaSourceEntity::class,
HistoryEntity::class,
FavouriteEntity::class,
FavouriteCategoryEntity::class
FavouriteCategoryEntity::class,
BookmarkEntity::class
],
version = DATABASE_VERSION
)
abstract class TokushoDatabase : RoomDatabase() {
abstract fun getTagsDao(): TagsDao
abstract fun getHistoryDao(): HistoryDao
abstract fun getMangaDao(): MangaDao
@ -50,10 +56,12 @@ abstract class TokushoDatabase : RoomDatabase() {
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
abstract fun getBookmarksDao(): BookmarksDao
}
fun TokushoDatabase(context: Context): TokushoDatabase = Room
.databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db.db")
.databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db")
.addCallback(DatabasePrePopulateCallback(context.resources))
.build()

@ -0,0 +1,57 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun find(pageId: Long): BookmarkEntity?
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
@Transaction
@Query(
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
)
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
@Delete
abstract suspend fun delete(entity: BookmarkEntity)
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
abstract suspend fun delete(pageId: Long): Int
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
@Upsert
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
}

@ -86,6 +86,9 @@ abstract class FavouritesDao {
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0 AND category_id = :categoryId")
abstract fun observeMangaCountInCategory(categoryId: Long): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>

@ -2,8 +2,15 @@ package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
@Dao
abstract class MangaSourcesDao {
@ -26,4 +33,49 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return observeImpl(query)
}
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
return findAllImpl(query)
}
@Transaction
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
if (updateIsEnabled(source, isEnabled) == 0) {
val entity = MangaSourceEntity(
source = source,
isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1,
)
upsert(entity)
}
}
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
@RawQuery(observedEntities = [MangaSourceEntity::class])
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
@RawQuery
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
SourcesSortOrder.ALPHABETIC -> "source ASC"
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
SourcesSortOrder.MANUAL -> "sort_key ASC"
}
}

@ -0,0 +1,88 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import org.xtimms.tokusho.core.database.entity.TagEntity
@Dao
abstract class TagsDao {
@Query("SELECT * FROM tags WHERE source = :source")
abstract suspend fun findTags(source: String): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) ASC
LIMIT :limit""",
)
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
LIMIT :limit""",
)
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
@Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>)
}

@ -0,0 +1,28 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "bookmarks",
primaryKeys = ["manga_id", "page_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
),
]
)
data class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "percent") val percent: Float,
)

@ -6,6 +6,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.tokusho.core.model.Bookmark
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.core.model.MangaHistory
@ -58,6 +59,24 @@ fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = Instant.ofEpochMilli(createdAt),
percent = percent,
)
fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
it.toBookmark(manga)
}
@JvmName("bookmarksIds")
fun Collection<Bookmark>.ids() = map { it.pageId }
// Model to entity
fun Manga.toEntity() = MangaEntity(
@ -84,6 +103,17 @@ fun MangaTag.toEntity() = TagEntity(
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
fun Bookmark.toEntity() = BookmarkEntity(
mangaId = manga.id,
pageId = pageId,
chapterId = chapterId,
page = page,
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.toEpochMilli(),
percent = percent,
)
// Other
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {

@ -0,0 +1,11 @@
package org.xtimms.tokusho.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
class CloudflareProtectedException(
val url: String,
val source: MangaSource?,
@Transient val headers: Headers,
) : IOException("Protected by Cloudflare")

@ -0,0 +1,42 @@
package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.xtimms.tokusho.utils.hasImageExtension
import java.time.Instant
data class Bookmark(
val manga: Manga,
val pageId: Long,
val chapterId: Long,
val page: Int,
val scroll: Int,
val imageUrl: String,
val createdAt: Instant,
val percent: Float,
) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Bookmark &&
manga.id == other.manga.id &&
chapterId == other.chapterId &&
page == other.page
}
fun toMangaPage() = MangaPage(
id = pageId,
url = imageUrl,
preview = null,
source = manga.source,
)
private fun isImageUrlDirect(): Boolean {
return hasImageExtension(imageUrl)
}
}

@ -0,0 +1,42 @@
package org.xtimms.tokusho.core.model
import androidx.core.net.toFile
import androidx.core.net.toUri
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.xtimms.tokusho.utils.system.creationTime
import java.io.File
data class LocalManga(
val manga: Manga,
val file: File = manga.url.toUri().toFile(),
) {
var createdAt: Long = -1L
private set
get() {
if (field == -1L) {
field = file.creationTime
}
return field
}
fun isMatchesQuery(query: String): Boolean {
return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true
}
fun containsTags(tags: Set<MangaTag>): Boolean {
return manga.tags.containsAll(tags)
}
fun containsAnyTag(tags: Set<MangaTag>): Boolean {
return tags.any { tag ->
manga.tags.contains(tag)
}
}
override fun toString(): String {
return "LocalManga(${file.path}: ${manga.title})"
}
}

@ -1,8 +1,64 @@
package org.xtimms.tokusho.core.model
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.utils.system.iterator
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
return null
}
if (history != null) {
val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) {
return currentChapter.branch
}
}
val groups = ch.groupBy { it.branch }
if (groups.size == 1) {
return groups.keys.first()
}
for (locale in LocaleListCompat.getAdjustedDefault()) {
val displayLanguage = locale.getDisplayLanguage(locale)
val displayName = locale.getDisplayName(locale)
val candidates = HashMap<String?, List<MangaChapter>>(3)
for (branch in groups.keys) {
if (branch != null && (
branch.contains(displayLanguage, ignoreCase = true) ||
branch.contains(displayName, ignoreCase = true)
)
) {
candidates[branch] = groups[branch] ?: continue
}
}
if (candidates.isNotEmpty()) {
return candidates.maxBy { it.value.size }.key
}
}
return groups.maxByOrNull { it.value.size }?.key
}
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
it.decimalSeparator = '.'
}
}
fun MangaChapter.formatNumber(): String? {
if (number <= 0f) {
return null
}
return chaptersNumberFormat.format(number.toDouble())
}
val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL

@ -1,5 +1,6 @@
package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
fun MangaSource(name: String): MangaSource {
@ -8,3 +9,5 @@ fun MangaSource(name: String): MangaSource {
}
return MangaSource.DUMMY
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

@ -15,9 +15,11 @@ import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor
import org.xtimms.tokusho.core.network.interceptors.CloudflareInterceptor
import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor
import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor
import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@ -58,8 +60,12 @@ interface NetworkModule {
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
if (AppSettings.isSSLBypassEnabled()) {
bypassSSLErrors()
}
cache(cache)
addInterceptor(GZipInterceptor())
addInterceptor(CloudflareInterceptor())
addInterceptor(RateLimitInterceptor())
}.build()

@ -0,0 +1,29 @@
package org.xtimms.tokusho.core.network
import android.annotation.SuppressLint
import okhttp3.OkHttpClient
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@SuppressLint("CustomX509TrustManager")
fun OkHttpClient.Builder.bypassSSLErrors() = also { builder ->
runCatching {
val trustAllCerts = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCerts), SecureRandom())
val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory
builder.sslSocketFactory(sslSocketFactory, trustAllCerts)
builder.hostnameVerifier { _, _ -> true }
}.onFailure {
it.printStackTrace()
}
}

@ -0,0 +1,32 @@
package org.xtimms.tokusho.core.network.interceptors
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.exceptions.CloudflareProtectedException
import java.net.HttpURLConnection.HTTP_FORBIDDEN
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
class CloudflareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
if (content.getElementById("challenge-error-title") != null) {
val request = response.request
response.closeQuietly()
throw CloudflareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
)
}
}
return response
}
}

@ -13,6 +13,7 @@ import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.network.CommonHeaders
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
import java.net.IDN
import javax.inject.Inject
import javax.inject.Singleton
@ -39,6 +40,10 @@ class CommonHeadersInterceptor @Inject constructor(
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain)
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
}
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
}
@ -46,7 +51,7 @@ class CommonHeadersInterceptor @Inject constructor(
private fun Headers.Builder.trySet(name: String, value: String) = try {
set(name, value)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
private class ProxyChain(

@ -1,8 +1,11 @@
package org.xtimms.tokusho.core.parser
import androidx.room.withTransaction
import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.toEntities
import org.xtimms.tokusho.core.database.entity.toEntity
import org.xtimms.tokusho.core.database.entity.toManga
import javax.inject.Inject
import javax.inject.Provider
@ -28,4 +31,12 @@ class MangaDataRepository @Inject constructor(
else -> null
}
suspend fun storeManga(manga: Manga) {
db.withTransaction {
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
}
}

@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.parser.local.LocalMangaRepository
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
@ -50,6 +51,7 @@ interface MangaRepository {
@Singleton
class Factory @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
) {
@ -58,6 +60,9 @@ interface MangaRepository {
@AnyThread
fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) {
return localMangaRepository
}
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }

@ -24,10 +24,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.SafeDeferred
import org.xtimms.tokusho.core.prefs.SourceSettings
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import java.util.Locale
@ -58,6 +60,12 @@ class RemoteMangaRepository(
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String
get() = parser.domain
set(value) {
getConfig()[parser.configKeyDomain] = value
}
val domains: Array<out String>
get() = parser.configKeyDomain.presetValues
@ -119,6 +127,8 @@ class RemoteMangaRepository(
return details.await()
}
private fun getConfig() = parser.config as SourceSettings
@OptIn(ExperimentalStdlibApi::class)
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]

@ -0,0 +1,22 @@
package org.xtimms.tokusho.core.parser.local
import android.net.Uri
import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP
import java.io.File
private fun isCbzExtension(ext: String?): Boolean {
return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
}
fun hasCbzExtension(string: String): Boolean {
val ext = string.substringAfterLast('.', "")
return isCbzExtension(ext)
}
fun File.hasCbzExtension() = isCbzExtension(extension)
fun Uri.isZipUri() = scheme.let {
it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
}
fun Uri.isFileUri() = scheme == "file"

@ -0,0 +1,8 @@
package org.xtimms.tokusho.core.parser.local
enum class DownloadFormat {
AUTOMATIC,
SINGLE_CBZ,
MULTIPLE_CBZ,
}

@ -0,0 +1,220 @@
package org.xtimms.tokusho.core.parser.local
import android.net.Uri
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.model.isLocal
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
import org.xtimms.tokusho.core.parser.local.output.LocalMangaUtil
import org.xtimms.tokusho.data.LocalStorageManager
import org.xtimms.tokusho.utils.AlphanumComparator
import org.xtimms.tokusho.utils.CompositeMutex2
import org.xtimms.tokusho.utils.system.children
import org.xtimms.tokusho.utils.system.deleteAwait
import org.xtimms.tokusho.utils.system.filterWith
import java.io.File
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_PARALLELISM = 4
@Singleton
class LocalMangaRepository @Inject constructor(
private val storageManager: LocalStorageManager,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
) : MangaRepository {
override val source = MangaSource.LOCAL
private val locks = CompositeMutex2<Long>()
override val isMultipleTagsSupported: Boolean = true
override val isTagsExclusionSupported: Boolean = true
override val isSearchSupported: Boolean = true
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>()
override val contentRatings = emptySet<ContentRating>()
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
when (filter) {
is MangaListFilter.Search -> {
list.retainAll { x -> x.isMatchesQuery(filter.query) }
}
is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) {
list.retainAll { x -> x.containsTags(filter.tags) }
}
if (filter.tagsExclude.isNotEmpty()) {
list.removeAll { x -> x.containsAnyTag(filter.tags) }
}
when (filter.sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
else -> Unit
}
}
null -> Unit
}
return list.unwrap()
}
override suspend fun getDetails(manga: Manga): Manga = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved"
}
else -> LocalMangaInput.of(manga).getManga().manga
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return LocalMangaInput.of(chapter).getPages(chapter)
}
suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile()
val result = file.deleteAwait()
if (result) {
localStorageChanges.emit(null)
}
return result
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
lockManga(manga.id)
try {
val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) {
"Manga is not stored on local storage"
}.manga
LocalMangaUtil(subject).deleteChapters(ids)
localStorageChanges.emit(LocalManga(subject))
} finally {
unlockManga(manga.id)
}
}
suspend fun getRemoteManga(localManga: Manga): Manga? {
return runCatchingCancellable {
LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal }
}.onFailure {
it.printStackTrace()
}.getOrNull()
}
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga()
}
// slow path
val files = getAllFiles()
return channelFlow {
for (file in files) {
launch {
val mangaInput = LocalMangaInput.of(file)
runCatchingCancellable {
val mangaInfo = mangaInput.getMangaInfo()
if (mangaInfo != null && mangaInfo.id == remoteManga.id) {
send(mangaInput)
}
}.onFailure {
it.printStackTrace()
}
}
}
}.firstOrNull()?.getManga()
}.onFailure {
it.printStackTrace()
}.getOrNull()
override suspend fun getPageUrl(page: MangaPage) = page.url
override suspend fun getTags() = emptySet<MangaTag>()
override suspend fun getLocales() = emptySet<Locale>()
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? {
val defaultDir = storageManager.getDefaultWriteableDir()
if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) {
return defaultDir
}
return storageManager.getWriteableDirs()
.firstOrNull {
LocalMangaOutput.get(it, manga) != null
} ?: defaultDir
}
suspend fun cleanup(): Boolean {
if (locks.isNotEmpty()) {
return false
}
val dirs = storageManager.getWriteableDirs()
runInterruptible(Dispatchers.IO) {
dirs.flatMap { dir ->
dir.children().filterWith(TempFileFilter())
}.forEach { file ->
file.deleteRecursively()
}
}
return true
}
suspend fun lockManga(id: Long) {
locks.lock(id)
}
fun unlockManga(id: Long) {
locks.unlock(id)
}
private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles().toList() // TODO remove toList()
return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
async(dispatcher) {
runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull()
}
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
dir.children()
}
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
}

@ -0,0 +1,200 @@
package org.xtimms.tokusho.core.parser.local
import androidx.annotation.WorkerThread
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.model.isLocal
import org.xtimms.tokusho.utils.AlphanumComparator
import java.io.File
class MangaIndex(source: String?) {
private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject()
fun setMangaInfo(manga: Manga) {
require(!manga.isLocal) { "Local manga information cannot be stored" }
json.put("id", manga.id)
json.put("title", manga.title)
json.put("title_alt", manga.altTitle)
json.put("url", manga.url)
json.put("public_url", manga.publicUrl)
json.put("author", manga.author)
json.put("cover", manga.coverUrl)
json.put("description", manga.description)
json.put("rating", manga.rating)
json.put("nsfw", manga.isNsfw)
json.put("state", manga.state?.name)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.put(
"tags",
JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
},
)
if (!json.has("chapters")) {
json.put("chapters", JSONObject())
}
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
}
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
val source = MangaSource.valueOf(json.getString("source"))
Manga(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("title_alt"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
author = json.getStringOrNull("author"),
largeCoverUrl = json.getStringOrNull("cover_large"),
source = source,
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover"),
state = json.getStringOrNull("state")?.let { stateString ->
MangaState.entries.find(stateString)
},
description = json.getStringOrNull("description"),
tags = json.getJSONArray("tags").mapJSONToSet { x ->
MangaTag(
title = x.getString("title").toTitleCase(),
key = x.getString("key"),
source = source,
)
},
chapters = getChapters(json.getJSONObject("chapters"), source),
)
}.getOrNull()
fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
fun addChapter(chapter: IndexedValue<MangaChapter>, filename: String?) {
val chapters = json.getJSONObject("chapters")
if (!chapters.has(chapter.value.id.toString())) {
val jo = JSONObject()
jo.put("number", chapter.value.number)
jo.put("volume", chapter.value.volume)
jo.put("url", chapter.value.url)
jo.put("name", chapter.value.name)
jo.put("uploadDate", chapter.value.uploadDate)
jo.put("scanlator", chapter.value.scanlator)
jo.put("branch", chapter.value.branch)
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.value.branch.hashCode(), chapter.index + 1))
jo.put("file", filename)
chapters.put(chapter.value.id.toString(), jo)
}
}
fun removeChapter(id: Long): Boolean {
return json.getJSONObject("chapters").remove(id.toString()) != null
}
fun getChapterFileName(chapterId: Long): String? {
return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
}
fun setCoverEntry(name: String) {
json.put("cover_entry", name)
}
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
json.getJSONObject("chapters")
.getJSONObject(chapter.id.toString())
.getString("entries"),
)
fun sortChaptersByName() {
val jo = json.getJSONObject("chapters")
val list = ArrayList<JSONObject>(jo.length())
jo.keys().forEach { id ->
val item = jo.getJSONObject(id)
item.put("id", id)
list.add(item)
}
val comparator = AlphanumComparator()
list.sortWith(compareBy(comparator) { it.getString("name") })
val newJo = JSONObject()
list.forEachIndexed { i, obj ->
obj.put("number", i + 1)
val id = obj.remove("id") as String
newJo.put(id, obj)
}
json.put("chapters", newJo)
}
fun clear() {
val keys = json.keys()
while (keys.hasNext()) {
json.remove(keys.next())
}
}
fun setFrom(other: MangaIndex) {
clear()
other.json.keys().forEach { key ->
json.putOpt(key, other.json.opt(key))
}
}
private fun getChapters(json: JSONObject, source: MangaSource): List<MangaChapter> {
val chapters = ArrayList<MangaChapter>(json.length())
for (k in json.keys()) {
val v = json.getJSONObject(k)
chapters.add(
MangaChapter(
id = k.toLong(),
name = v.getString("name"),
url = v.getString("url"),
number = v.getFloatOrDefault("number", 0f),
volume = v.getIntOrDefault("volume", 0),
uploadDate = v.getLongOrDefault("uploadDate", 0L),
scanlator = v.getStringOrNull("scanlator"),
branch = v.getStringOrNull("branch"),
source = source,
),
)
}
return chapters.sortedBy { it.number }
}
override fun toString(): String = if (BuildConfig.DEBUG) {
json.toString(4)
} else {
json.toString()
}
companion object {
@WorkerThread
fun read(file: File): MangaIndex? {
if (file.exists() && file.canRead()) {
val text = file.readText()
if (text.length > 2) {
return MangaIndex(text)
}
}
return null
}
}
}

@ -0,0 +1,7 @@
package org.xtimms.tokusho.core.parser.local
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalStorageChanges

@ -0,0 +1,11 @@
package org.xtimms.tokusho.core.parser.local
import java.io.File
import java.io.FileFilter
class TempFileFilter : FileFilter {
override fun accept(file: File): Boolean {
return file.name.endsWith(".tmp", ignoreCase = true)
}
}

@ -0,0 +1,158 @@
package org.xtimms.tokusho.core.parser.local.input
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.parser.local.MangaIndex
import org.xtimms.tokusho.core.parser.local.hasCbzExtension
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
import org.xtimms.tokusho.utils.AlphanumComparator
import org.xtimms.tokusho.utils.hasImageExtension
import org.xtimms.tokusho.utils.lang.longHashCode
import org.xtimms.tokusho.utils.lang.toListSorted
import org.xtimms.tokusho.utils.system.children
import org.xtimms.tokusho.utils.system.creationTime
import org.xtimms.tokusho.utils.system.walkCompat
import java.io.File
import java.util.TreeMap
import java.util.zip.ZipFile
/**
* Manga {Folder}
* |--- index.json (optional)
* |--- Chapter 1.cbz
* |--- Page 1.png
* :
* L--- Page x.png
* |--- Chapter 2.cbz
* :
* L--- Chapter x.cbz
*/
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
val mangaUri = root.toUri().toString()
val chapterFiles = getChaptersFiles()
val info = index?.getMangaInfo()
val cover = fileUri(
root,
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
)
val manga = info?.copy2(
source = MangaSource.LOCAL,
url = mangaUri,
coverUrl = cover,
largeCoverUrl = cover,
chapters = info.chapters?.mapIndexedNotNull { i, c ->
val fileName = index.getChapterFileName(c.id)
val file = if (fileName != null) {
chapterFiles[fileName]
} else {
// old downloads
chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
},
) ?: Manga(
id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(),
url = mangaUri,
publicUrl = mangaUri,
source = MangaSource.LOCAL,
coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter(
id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(),
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
uploadDate = f.creationTime,
url = f.toUri().toString(),
scanlator = null,
branch = null,
)
},
altTitle = null,
rating = -1f,
isNsfw = false,
tags = setOf(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
)
LocalManga(manga, root)
}
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
index?.getMangaInfo()
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.children()
.filter { it.isFile && hasImageExtension(it) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
}
} else {
ZipFile(file).use { zip ->
zip.entries()
.asSequence()
.filter { x -> !x.isDirectory }
.map { it.name }
.toListSorted(AlphanumComparator())
.map {
val pageUri = zipUri(file, it)
MangaPage(
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
)
}
}
}
}
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? {
return root.walkCompat(includeDirectories = false)
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
?: run {
val cbz = root.walkCompat(includeDirectories = false)
.firstOrNull { it.hasCbzExtension() } ?: return null
ZipFile(cbz).use { zip ->
zip.entries().asSequence()
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
?.let { zipUri(cbz, it.name) }
}
}
}
private fun fileUri(base: File, name: String): String {
return File(base, name).toUri().toString()
}
private fun File.isChapterDirectory(): Boolean {
return isDirectory && children().any { hasImageExtension(it) }
}
}

@ -0,0 +1,111 @@
package org.xtimms.tokusho.core.parser.local.input
import android.net.Uri
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.parser.local.hasCbzExtension
import java.io.File
sealed class LocalMangaInput(
protected val root: File,
) {
abstract suspend fun getManga(): LocalManga
abstract suspend fun getMangaInfo(): Manga?
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
companion object {
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
fun of(file: File): LocalMangaInput = when {
file.isDirectory -> LocalMangaDirInput(file)
else -> LocalMangaZipInput(file)
}
fun ofOrNull(file: File): LocalMangaInput? = when {
file.isDirectory -> LocalMangaDirInput(file)
hasCbzExtension(file.name) -> LocalMangaZipInput(file)
else -> null
}
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaInput? = channelFlow {
val fileName = manga.title.toFileNameSafe()
for (root in roots) {
launch {
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
val input = when {
dir.isDirectory -> LocalMangaDirInput(dir)
zip.isFile -> LocalMangaZipInput(zip)
else -> null
}
val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
if (info?.id == manga.id) {
send(input)
}
}
}
}.flowOn(Dispatchers.Default).firstOrNull()
@JvmStatic
protected fun zipUri(file: File, entryName: String): String =
Uri.fromParts("cbz", file.path, entryName).toString()
@JvmStatic
protected fun Manga.copy2(
url: String,
coverUrl: String,
largeCoverUrl: String,
chapters: List<MangaChapter>?,
source: MangaSource,
) = Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = chapters,
source = source,
)
@JvmStatic
protected fun MangaChapter.copy(
url: String,
source: MangaSource,
) = MangaChapter(
id = id,
name = name,
number = number,
volume = volume,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,
branch = branch,
source = source,
)
}
}

@ -0,0 +1,154 @@
package org.xtimms.tokusho.core.parser.local.input
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.xtimms.tokusho.core.model.LocalManga
import org.xtimms.tokusho.core.parser.local.MangaIndex
import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput
import org.xtimms.tokusho.utils.AlphanumComparator
import org.xtimms.tokusho.utils.lang.longHashCode
import org.xtimms.tokusho.utils.lang.toListSorted
import org.xtimms.tokusho.utils.system.readText
import java.io.File
import java.util.Enumeration
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Manga archive {.cbz or .zip file}
* |--- index.json (optional)
* |--- Page 1.png
* |--- Page 2.png
* :
* L--- Page x.png
*/
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
override suspend fun getManga(): LocalManga {
val manga = runInterruptible(Dispatchers.IO) {
ZipFile(root).use { zip ->
val fileUri = root.toUri().toString()
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo()
if (info != null) {
val cover = zipUri(
root,
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
)
return@use info.copy2(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = cover,
largeCoverUrl = cover,
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
},
)
}
// fallback
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>()
for (x in zip.entries()) {
if (!x.isDirectory) {
chapters += x.name.substringBeforeLast(File.separatorChar, "")
}
}
val uriBuilder = root.toUri().buildUpon()
Manga(
id = root.absolutePath.longHashCode(),
title = title,
url = fileUri,
publicUrl = fileUri,
source = MangaSource.LOCAL,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s ->
MangaChapter(
id = "$i$s".longHashCode(),
name = s.ifEmpty { title },
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(),
scanlator = null,
branch = null,
)
},
altTitle = null,
rating = -1f,
isNsfw = false,
tags = setOf(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
)
}
}
return LocalManga(manga, root)
}
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
ZipFile(root).use { zip ->
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo()
}
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
} else {
val parent = uri.fragment.orEmpty()
entries.filter { x ->
!x.isDirectory && x.name.substringBeforeLast(
File.separatorChar,
"",
) == parent
}
}
entries
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { x ->
val entryUri = zipUri(file, x.name)
MangaPage(
id = entryUri.longHashCode(),
url = entryUri,
preview = null,
source = MangaSource.LOCAL,
)
}
}
}
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
val list = entries.toList()
.filterNot { it.isDirectory }
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
val map = MimeTypeMap.getSingleton()
return list.firstOrNull {
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
?.startsWith("image/") == true
}
}
}

@ -0,0 +1,136 @@
package org.xtimms.tokusho.core.parser.local.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.xtimms.tokusho.core.model.isLocal
import org.xtimms.tokusho.core.parser.local.MangaIndex
import org.xtimms.tokusho.core.zip.ZipOutput
import org.xtimms.tokusho.utils.system.deleteAwait
import org.xtimms.tokusho.utils.system.takeIfReadable
import java.io.File
class LocalMangaDirOutput(
rootFile: File,
manga: Manga,
) : LocalMangaOutput(rootFile) {
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
private val mutex = Mutex()
init {
if (!manga.isLocal) {
index.setMangaInfo(manga)
}
}
override suspend fun mergeWithExisting() = Unit
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString {
append("cover")
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
file.copyTo(File(rootFile, name), overwrite = true)
}
index.setCoverEntry(name)
flushIndex()
}
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val output = chaptersOutput.getOrPut(chapter.value) {
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
}
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, chapterFileName(chapter))
}
override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
val output = chaptersOutput.remove(chapter) ?: return@withLock false
output.flushAndFinish()
flushIndex()
true
}
override suspend fun finish() = mutex.withLock {
flushIndex()
for (output in chaptersOutput.values) {
output.flushAndFinish()
}
chaptersOutput.clear()
}
override suspend fun cleanup() = mutex.withLock {
for (output in chaptersOutput.values) {
output.file.deleteAwait()
}
}
override fun close() {
for (output in chaptersOutput.values) {
output.close()
}
}
suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
val chapter = checkNotNull(index.getMangaInfo()?.chapters?.withIndex()) {
"No chapters found"
}.find { x -> x.value.id == chapterId } ?: error("Chapter not found")
val chapterDir = File(rootFile, chapterFileName(chapter))
chapterDir.deleteAwait()
index.removeChapter(chapterId)
}
fun setIndex(newIndex: MangaIndex) {
index.setFrom(newIndex)
}
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
finish()
close()
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
file.renameTo(resFile)
}
private fun chapterFileName(chapter: IndexedValue<MangaChapter>): String {
index.getChapterFileName(chapter.value.id)?.let {
return it
}
val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18)
var i = 0
while (true) {
val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz"
if (!File(rootFile, name).exists()) {
return name
}
i++
}
}
private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) {
File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
}
}

@ -0,0 +1,109 @@
package org.xtimms.tokusho.core.parser.local.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Closeable
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.xtimms.tokusho.core.parser.local.DownloadFormat
import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput
import java.io.File
sealed class LocalMangaOutput(
val rootFile: File,
) : Closeable {
abstract suspend fun mergeWithExisting()
abstract suspend fun addCover(file: File, ext: String)
abstract suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String)
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
abstract suspend fun finish()
abstract suspend fun cleanup()
companion object {
const val ENTRY_NAME_INDEX = "index.json"
const val SUFFIX_TMP = ".tmp"
private val mutex = Mutex()
suspend fun getOrCreate(
root: File,
manga: Manga,
format: DownloadFormat,
): LocalMangaOutput = withContext(Dispatchers.IO) {
val targetFormat = if (format == DownloadFormat.AUTOMATIC) {
if (manga.chapters.let { it != null && it.size <= 3 }) {
DownloadFormat.SINGLE_CBZ
} else {
DownloadFormat.MULTIPLE_CBZ
}
} else {
format
}
checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat))
}
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC)
}
private suspend fun getImpl(
root: File,
manga: Manga,
onlyIfExists: Boolean,
format: DownloadFormat,
): LocalMangaOutput? {
mutex.withLock {
var i = 0
val baseName = manga.title.toFileNameSafe()
while (true) {
val fileName = if (i == 0) baseName else baseName + "_$i"
val dir = File(root, fileName)
val zip = File(root, "$fileName.cbz")
i++
return when {
dir.isDirectory -> {
if (canWriteTo(dir, manga)) {
LocalMangaDirOutput(dir, manga)
} else {
continue
}
}
zip.isFile -> if (canWriteTo(zip, manga)) {
LocalMangaZipOutput(zip, manga)
} else {
continue
}
!onlyIfExists -> when (format) {
DownloadFormat.AUTOMATIC -> null
DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga)
DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga)
}
else -> null
}
}
}
}
private suspend fun canWriteTo(file: File, manga: Manga): Boolean {
val info = runCatchingCancellable {
LocalMangaInput.of(file).getMangaInfo()
}.onFailure {
it.printStackTrace()
}.getOrNull() ?: return false
return info.id == manga.id
}
}
}

@ -0,0 +1,45 @@
package org.xtimms.tokusho.core.parser.local.output
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
class LocalMangaUtil(
private val manga: Manga,
) {
init {
require(manga.source == MangaSource.LOCAL) {
"Expected LOCAL source but ${manga.source} found"
}
}
suspend fun deleteChapters(ids: Set<Long>) {
newOutput().use { output ->
when (output) {
is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) {
LocalMangaZipOutput.filterChapters(output, ids)
}
is LocalMangaDirOutput -> {
for (id in ids) {
output.deleteChapter(id)
}
output.finish()
}
}
}
}
private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) {
val file = manga.url.toUri().toFile()
if (file.isDirectory) {
LocalMangaDirOutput(file, manga)
} else {
LocalMangaZipOutput(file, manga)
}
}
}

@ -0,0 +1,156 @@
package org.xtimms.tokusho.core.parser.local.output
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.xtimms.tokusho.core.model.isLocal
import org.xtimms.tokusho.core.parser.local.MangaIndex
import org.xtimms.tokusho.core.zip.ZipOutput
import org.xtimms.tokusho.utils.system.deleteAwait
import org.xtimms.tokusho.utils.system.readText
import java.io.File
import java.util.zip.ZipFile
class LocalMangaZipOutput(
rootFile: File,
manga: Manga,
) : LocalMangaOutput(rootFile) {
private val output = ZipOutput(File(rootFile.path + ".tmp"))
private val index = MangaIndex(null)
private val mutex = Mutex()
init {
if (!manga.isLocal) {
index.setMangaInfo(manga)
}
}
override suspend fun mergeWithExisting() = mutex.withLock {
if (rootFile.exists()) {
runInterruptible(Dispatchers.IO) {
mergeWith(rootFile)
}
}
}
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.setCoverEntry(name)
}
override suspend fun addPage(chapter: IndexedValue<MangaChapter>, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter, null)
}
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() = mutex.withLock {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
}
rootFile.deleteAwait()
output.file.renameTo(rootFile)
Unit
}
override suspend fun cleanup() = mutex.withLock {
output.file.deleteAwait()
Unit
}
override fun close() {
output.close()
}
@WorkerThread
private fun mergeWith(other: File) {
var otherIndex: MangaIndex? = null
ZipFile(other).use { zip ->
for (entry in zip.entries()) {
if (entry.name == ENTRY_NAME_INDEX) {
otherIndex = MangaIndex(
zip.getInputStream(entry).use {
it.reader().readText()
},
)
} else {
output.copyEntryFrom(zip, entry)
}
}
}
otherIndex?.getMangaInfo()?.chapters?.withIndex()?.let { chapters ->
for (chapter in chapters) {
index.addChapter(chapter, null)
}
}
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
@WorkerThread
fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set<Long>) {
ZipFile(subject.rootFile).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
}
}
}
}
subject.output.finish()
subject.output.close()
subject.rootFile.delete()
subject.output.file.renameTo(subject.rootFile)
}
}
}
}

@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.shiki.ui.theme.SEED
import org.xtimms.tokusho.ui.theme.SEED
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.utils.lang.processLifecycleScope
@ -36,6 +36,10 @@ const val PRE_RELEASE = 1
const val ACRA = "acra"
const val LOGGING = "logging"
const val SSL_BYPASS = "ssl_bypass"
const val NSFW = "nsfw"
const val TABS_MANGA_COUNT = "tabs_manga_count"
val paletteStyles = listOf(
PaletteStyle.TonalSpot,
PaletteStyle.Spritz,
@ -91,12 +95,18 @@ object AppSettings {
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
fun isACRAEnabled() = ACRA.getBoolean(false)
fun isACRAEnabled() = ACRA.getBoolean(true)
fun isLoggingEnabled() = LOGGING.getBoolean(false)
fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true)
fun isNSFWEnabled() = NSFW.getBoolean(false)
fun isSSLBypassEnabled() = SSL_BYPASS.getBoolean(false)
fun isMangaCountInTabsEnabled() = TABS_MANGA_COUNT.getBoolean(false)
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
languageMap.getOrElse(languageNumber) { "" }

@ -0,0 +1,89 @@
package org.xtimms.tokusho.core.prefs
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
import org.xtimms.tokusho.utils.system.getEnumValue
import org.xtimms.tokusho.utils.system.putEnumValue
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KotatsuAppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
val isNewSourcesTipEnabled: Boolean
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
var sourcesSortOrder: SourcesSortOrder
get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL)
set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) }
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
fun observe() = prefs.observe()
companion object {
const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_SOURCES_ORDER = "sources_sort_order"
}
}
fun <T> KotatsuAppSettings.observeAsFlow(key: String, valueProducer: KotatsuAppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
emit(lastValue)
observe().collect {
if (it == key) {
val value = valueProducer()
if (value != lastValue) {
emit(value)
}
lastValue = value
}
}
}
fun <T> KotatsuAppSettings.observeAsStateFlow(
scope: CoroutineScope,
key: String,
valueProducer: KotatsuAppSettings.() -> T,
): StateFlow<T> = observe().transform {
if (it == key) {
emit(valueProducer())
}
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}

@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import kotlinx.collections.immutable.ImmutableList
import org.xtimms.tokusho.core.components.ActionButton
@ -36,12 +39,16 @@ data class EmptyScreenAction(
@Composable
fun EmptyScreen(
icon: ImageVector,
@StringRes title: Int,
@StringRes description: Int,
modifier: Modifier = Modifier,
actions: ImmutableList<EmptyScreenAction>? = null,
) {
EmptyScreen(
icon = icon,
message = stringResource(title),
summary = stringResource(description),
modifier = modifier,
actions = actions,
)
@ -49,11 +56,12 @@ fun EmptyScreen(
@Composable
fun EmptyScreen(
icon: ImageVector,
message: String,
summary: String,
modifier: Modifier = Modifier,
actions: ImmutableList<EmptyScreenAction>? = null,
) {
val face = remember { getRandomErrorFace() }
Column(
modifier = modifier
.fillMaxSize()
@ -62,20 +70,27 @@ fun EmptyScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
text = face,
modifier = Modifier.secondaryItemAlpha(),
style = MaterialTheme.typography.displayMedium,
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(96.dp).secondaryItemAlpha()
)
}
Text(
text = message,
modifier = Modifier
.paddingFromBaseline(top = 24.dp)
.secondaryItemAlpha(),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = summary,
modifier = Modifier
.paddingFromBaseline(top = 24.dp)
.secondaryItemAlpha(),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
)
@ -97,16 +112,3 @@ fun EmptyScreen(
}
}
}
private val ErrorFaces = listOf(
"(・o・;)",
"Σ(ಠ_ಠ)",
"ಥ_ಥ",
"(˘・_・˘)",
"(; ̄Д ̄)",
"(・Д・。",
)
private fun getRandomErrorFace(): String {
return ErrorFaces[Random.nextInt(ErrorFaces.size)]
}

@ -31,8 +31,8 @@ import java.util.regex.Pattern
object Updater {
private const val OWNER = "ztimms73"
private const val REPO = "Tokusho"
private const val OWNER = "KotatsuApp"
private const val REPO = "Kotatsu"
private const val TAG = "Updates"
private val client = OkHttpClient()

@ -0,0 +1,122 @@
package org.xtimms.tokusho.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import org.xtimms.tokusho.utils.system.children
import java.io.File
import java.io.FileInputStream
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
class ZipOutput(
val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable {
private val entryNames = ArraySet<String>()
private var isClosed = false
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@WorkerThread
fun put(name: String, file: File): Boolean {
return output.appendFile(file, name)
}
@WorkerThread
fun put(name: String, content: String): Boolean {
return output.appendText(content, name)
}
@WorkerThread
fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
return if (entryNames.add(entry.name)) {
output.putNextEntry(entry)
output.closeEntry()
true
} else {
false
}
}
@WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
try {
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
} finally {
output.closeEntry()
}
true
} else {
false
}
}
fun finish() {
output.finish()
output.flush()
}
override fun close() {
if (!isClosed) {
output.close()
isClosed = true
}
}
@WorkerThread
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
if (fileToZip.isDirectory) {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
if (!entryNames.add(entry.name)) {
return false
}
putNextEntry(entry)
closeEntry()
fileToZip.children().forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} else {
FileInputStream(fileToZip).use { fis ->
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
fis.copyTo(this)
closeEntry()
}
}
return true
}
@WorkerThread
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
content.byteInputStream().copyTo(this)
closeEntry()
return true
}
}

@ -59,6 +59,28 @@ class LocalStorageManager @Inject constructor(
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
}
suspend fun getReadableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs()
.filter { it.isReadable() }
}
suspend fun getWriteableDirs(): List<File> = runInterruptible(Dispatchers.IO) {
getConfiguredStorageDirs()
.filter { it.isWriteable() }
}
suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) {
val preferredDir = context.filesDir?.takeIf { it.isWriteable() }
preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() }
}
@WorkerThread
private fun getConfiguredStorageDirs(): MutableSet<File> {
val set = getAvailableStorageDirs()
set.addAll(setOf(context.filesDir))
return set
}
@WorkerThread
private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
@ -103,4 +125,11 @@ class LocalStorageManager @Inject constructor(
}
}
private fun File.isReadable() = runCatching {
canRead()
}.getOrDefault(false)
private fun File.isWriteable() = runCatching {
canWrite()
}.getOrDefault(false)
}

@ -0,0 +1,102 @@
package org.xtimms.tokusho.data.repository
import android.database.SQLException
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.toBookmark
import org.xtimms.tokusho.core.database.entity.toBookmarks
import org.xtimms.tokusho.core.database.entity.toEntities
import org.xtimms.tokusho.core.database.entity.toEntity
import org.xtimms.tokusho.core.database.entity.toManga
import org.xtimms.tokusho.core.model.Bookmark
import org.xtimms.tokusho.utils.ReversibleHandle
import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
@Reusable
class BookmarksRepository @Inject constructor(
private val db: TokushoDatabase,
) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
}
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
}
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
return db.getBookmarksDao().observe().map { map ->
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) {
val manga = k.toManga()
res[manga] = v.toBookmarks(manga)
}
res
}
}
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.getBookmarksDao().insert(bookmark.toEntity())
}
}
suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) {
val entity = bookmark.toEntity().copy(
imageUrl = imageUrl,
)
db.getBookmarksDao().upsert(listOf(entity))
}
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
"Bookmark not found"
}
}
suspend fun removeBookmark(bookmark: Bookmark) {
removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page)
}
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction {
val dao = db.getBookmarksDao()
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
entities.add(e)
}
dao.delete(pageId)
}
}
return BookmarksRestorer(entities)
}
private inner class BookmarksRestorer(
private val entities: Collection<BookmarkEntity>,
) : ReversibleHandle {
override suspend fun reverse() {
db.withTransaction {
for (e in entities) {
try {
db.getBookmarksDao().insert(e)
} catch (e: SQLException) {
e.printStackTrace()
}
}
}
}
}
}

@ -0,0 +1,40 @@
package org.xtimms.tokusho.data.repository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.utils.lang.asArrayList
import javax.inject.Inject
class ExploreRepository @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private suspend fun getList(
source: MangaSource,
tags: List<String>,
): List<Manga> = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(source)
val order = repository.sortOrders.random()
val availableTags = repository.getTags()
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
}
val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced.Builder(order)
.tags(setOfNotNull(tag))
.build(),
).asArrayList()
list.shuffle()
list
}.onFailure {
// TODO
}.getOrDefault(emptyList())
}

@ -2,6 +2,7 @@ package org.xtimms.tokusho.data.repository
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@ -9,8 +10,13 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.toEntities
import org.xtimms.tokusho.core.database.entity.toEntity
import org.xtimms.tokusho.core.database.entity.toFavouriteCategory
import org.xtimms.tokusho.core.database.entity.toManga
import org.xtimms.tokusho.core.database.entity.toMangaList
import org.xtimms.tokusho.core.model.FavouriteCategory
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.utils.ReversibleHandle
@ -22,11 +28,27 @@ class FavouritesRepository @Inject constructor(
private val db: TokushoDatabase,
) {
suspend fun getAllManga(): List<Manga> {
val entities = db.getFavouritesDao().findAll()
return entities.toMangaList()
}
suspend fun getLastManga(limit: Int): List<Manga> {
val entities = db.getFavouritesDao().findLast(limit)
return entities.toMangaList()
}
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(categoryId, order)
.mapItems { it.toManga() }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.getFavouritesDao().findAll(categoryId)
return entities.toMangaList()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun observeAll(categoryId: Long): Flow<List<Manga>> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
@ -37,6 +59,17 @@ class FavouritesRepository @Inject constructor(
.distinctUntilChanged()
}
fun observeMangaCountInCategory(categoryId: Long): Flow<Int> {
return db.getFavouritesDao().observeMangaCountInCategory(categoryId)
.distinctUntilChanged()
}
fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAll().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems {
it.toFavouriteCategory()
@ -47,10 +80,34 @@ class FavouritesRepository @Inject constructor(
return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() }
}
suspend fun getCategory(id: Long): FavouriteCategory {
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
}
suspend fun getCategoriesIds(mangaIds: Collection<Long>): Set<Long> {
return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet()
}
suspend fun createCategory(
title: String,
sortOrder: ListSortOrder,
isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.getFavouriteCategoriesDao().getNextSortKey(),
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
deletedAt = 0L,
isVisibleInLibrary = isVisibleOnShelf,
)
val id = db.getFavouriteCategoriesDao().insert(entity)
return entity.toFavouriteCategory(id)
}
suspend fun updateCategory(
id: Long,
title: String,
@ -91,6 +148,24 @@ class FavouritesRepository @Inject constructor(
}
}
suspend fun addToCategory(categoryId: Long, mangas: Collection<Manga>) {
db.withTransaction {
for (manga in mangas) {
val tags = manga.tags.toEntities()
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
val entity = FavouriteEntity(
mangaId = manga.id,
categoryId = categoryId,
createdAt = System.currentTimeMillis(),
sortKey = 0,
deletedAt = 0L,
)
db.getFavouritesDao().insert(entity)
}
}
}
suspend fun removeFromFavourites(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {

@ -1,14 +1,20 @@
package org.xtimms.tokusho.data.repository
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.toManga
import org.xtimms.tokusho.core.database.entity.toMangaHistory
import org.xtimms.tokusho.core.database.entity.toMangaTags
import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.core.model.findById
import org.xtimms.tokusho.core.model.isNsfw
import org.xtimms.tokusho.core.parser.MangaDataRepository
import org.xtimms.tokusho.utils.lang.mapItems
import javax.inject.Inject
const val PROGRESS_NONE = -1f
@ -16,8 +22,26 @@ const val PROGRESS_NONE = -1f
@Reusable
class HistoryRepository @Inject constructor(
private val db: TokushoDatabase,
private val mangaRepository: MangaDataRepository,
) {
suspend fun getLastOrNull(): Manga? {
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())
}
fun observeAll(): Flow<List<Manga>> {
return db.getHistoryDao().observeAll().mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAll(limit: Int): Flow<List<Manga>> {
return db.getHistoryDao().observeAll(limit).mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
}
@ -28,6 +52,31 @@ class HistoryRepository @Inject constructor(
}
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (shouldSkip(manga)) {
return
}
db.withTransaction {
mangaRepository.storeManga(manga)
db.getHistoryDao().upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
chapterId = chapterId,
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
deletedAt = 0L,
),
)
}
}
fun shouldSkip(manga: Manga): Boolean {
return ((manga.source.isNsfw() || manga.isNsfw))
}
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {

@ -1,17 +1,39 @@
package org.xtimms.tokusho.data.repository
import androidx.compose.runtime.Composable
import dagger.Reusable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.core.model.isNsfw
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.KotatsuAppSettings
import org.xtimms.tokusho.core.prefs.observeAsFlow
import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder
import org.xtimms.tokusho.utils.ReversibleHandle
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: TokushoDatabase,
private val settings: KotatsuAppSettings,
) {
private val dao: MangaSourcesDao
@ -27,4 +49,95 @@ class MangaSourcesRepository @Inject constructor(
val allMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled)
}
suspend fun getDisabledSources(): List<MangaSource> {
return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled)
}
fun observeEnabledSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources ->
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
}.distinctUntilChanged()
}
fun observeAvailableSourcesCount(): Flow<Int> {
return combine(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, enabledSources ->
val enabled = enabledSources.mapToSet { it.source }
allMangaSources.count { x ->
x.name !in enabled && (!skipNsfw || !x.isNsfw())
}
}.distinctUntilChanged()
}
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
observeIsNsfwDisabled(),
observeSortOrder(),
) { skipNsfw, order ->
dao.observeEnabled(order).map {
it.toSources(skipNsfw)
}
}.flatMapLatest { it }
suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle {
dao.setEnabled(source.name, isEnabled)
return ReversibleHandle {
dao.setEnabled(source.name, !isEnabled)
}
}
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
if (it) {
combine(
dao.observeAll(),
observeIsNsfwDisabled(),
) { entities, skipNsfw ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
if (skipNsfw) {
result.removeAll { x -> x.isNsfw() }
}
result
}.distinctUntilChanged()
} else {
flowOf(emptySet())
}
}
private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean,
): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
continue
}
if (source in remoteSources) {
result.add(source)
}
}
return result
}
private fun observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow()
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder
}
}

@ -0,0 +1,21 @@
package org.xtimms.tokusho.data.repository.backup
import org.json.JSONArray
class BackupEntry(
val name: Name,
val data: JSONArray
) {
enum class Name(
val key: String,
) {
INDEX("index"),
HISTORY("history"),
CATEGORIES("categories"),
FAVOURITES("favourites"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
}
}

@ -0,0 +1,200 @@
package org.xtimms.tokusho.data.repository.backup
import androidx.room.withTransaction
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.database.TokushoDatabase
import java.util.Date
import javax.inject.Inject
private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(
private val db: TokushoDatabase,
) {
suspend fun dumpHistory(): BackupEntry {
var offset = 0
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) {
break
}
offset += history.size
for (item in history) {
val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
val json = JsonSerializer(item.history).toJson()
json.put("manga", manga)
entry.data.put(json)
}
}
return entry
}
suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) {
entry.data.put(JsonSerializer(item).toJson())
}
return entry
}
suspend fun dumpFavourites(): BackupEntry {
var offset = 0
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) {
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
if (favourites.isEmpty()) {
break
}
offset += favourites.size
for (item in favourites) {
val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
val json = JsonSerializer(item.favourite).toJson()
json.put("manga", manga)
entry.data.put(json)
}
}
return entry
}
suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll()
for ((m, b) in all) {
val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson()
json.put("manga", manga)
val tags = JSONArray()
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
json.put("tags", tags)
val bookmarks = JSONArray()
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
json.put("bookmarks", bookmarks)
entry.data.put(json)
}
return entry
}
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE)
json.put("created_at", System.currentTimeMillis())
entry.data.put(json)
return entry
}
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getHistoryDao().upsert(history)
}
}
}
return result
}
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category)
}
}
return result
}
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getFavouritesDao().upsert(favourite)
}
}
}
return result
}
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = item.getJSONArray("tags").mapJSON {
JsonDeserializer(it).toTagEntity()
}
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
JsonDeserializer(it).toBookmarkEntity()
}
result += runCatchingCancellable {
db.withTransaction {
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getBookmarksDao().upsert(bookmarks)
}
}
}
return result
}
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
}

@ -0,0 +1,44 @@
package org.xtimms.tokusho.data.repository.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.json.JSONArray
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
BackupEntry(name, json)
}
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() {
zipFile.close()
}
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
}

@ -0,0 +1,46 @@
package org.xtimms.tokusho.data.repository.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.zip.ZipOutput
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name.key, entry.data.toString(2))
}
suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish()
}
override fun close() {
output.close()
}
}
const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy")))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
}

@ -0,0 +1,42 @@
package org.xtimms.tokusho.data.repository.backup
class CompositeResult {
private var successCount: Int = 0
private val errors = ArrayList<Throwable?>()
val size: Int
get() = successCount + errors.size
val failures: List<Throwable>
get() = errors.filterNotNull()
val isEmpty: Boolean
get() = errors.isEmpty() && successCount == 0
val isAllSuccess: Boolean
get() = errors.none { it != null }
val isAllFailed: Boolean
get() = successCount == 0 && errors.isNotEmpty()
operator fun plusAssign(result: Result<*>) {
when {
result.isSuccess -> successCount++
result.isFailure -> errors.add(result.exceptionOrNull())
}
}
operator fun plusAssign(other: CompositeResult) {
this.successCount += other.successCount
this.errors += other.errors
}
operator fun plus(other: CompositeResult): CompositeResult {
val result = CompositeResult()
result.successCount = this.successCount + other.successCount
result.errors.addAll(this.errors)
result.errors.addAll(other.errors)
return result
}
}

@ -0,0 +1,100 @@
package org.xtimms.tokusho.data.repository.backup
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
class JsonDeserializer(private val json: JSONObject) {
fun toFavouriteEntity() = FavouriteEntity(
mangaId = json.getLong("manga_id"),
categoryId = json.getLong("category_id"),
sortKey = json.getIntOrDefault("sort_key", 0),
createdAt = json.getLong("created_at"),
deletedAt = 0L,
)
fun toMangaEntity() = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("alt_title"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source"),
)
fun toTagEntity() = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source"),
)
fun toHistoryEntity() = HistoryEntity(
mangaId = json.getLong("manga_id"),
createdAt = json.getLong("created_at"),
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
deletedAt = 0L,
)
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"),
title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
deletedAt = 0L,
)
fun toBookmarkEntity() = BookmarkEntity(
mangaId = json.getLong("manga_id"),
pageId = json.getLong("page_id"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getInt("scroll"),
imageUrl = json.getString("image_url"),
createdAt = json.getLong("created_at"),
percent = json.getDouble("percent").toFloat(),
)
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = json.get(key)
map[key] = value
}
return map
}
}

@ -0,0 +1,99 @@
package org.xtimms.tokusho.data.repository.backup
import org.json.JSONObject
import org.xtimms.tokusho.core.database.entity.BookmarkEntity
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.HistoryEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
import org.xtimms.tokusho.core.database.entity.TagEntity
class JsonSerializer private constructor(private val json: JSONObject) {
constructor(e: FavouriteEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("category_id", e.categoryId)
put("sort_key", e.sortKey)
put("created_at", e.createdAt)
},
)
constructor(e: FavouriteCategoryEntity) : this(
JSONObject().apply {
put("category_id", e.categoryId)
put("created_at", e.createdAt)
put("sort_key", e.sortKey)
put("title", e.title)
put("order", e.order)
put("track", e.track)
put("show_in_lib", e.isVisibleInLibrary)
},
)
constructor(e: HistoryEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("created_at", e.createdAt)
put("updated_at", e.updatedAt)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
},
)
constructor(e: TagEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("key", e.key)
put("source", e.source)
},
)
constructor(e: MangaEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("alt_title", e.altTitle)
put("url", e.url)
put("public_url", e.publicUrl)
put("rating", e.rating)
put("nsfw", e.isNsfw)
put("cover_url", e.coverUrl)
put("large_cover_url", e.largeCoverUrl)
put("state", e.state)
put("author", e.author)
put("source", e.source)
},
)
constructor(e: BookmarkEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("page_id", e.pageId)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("image_url", e.imageUrl)
put("created_at", e.createdAt)
put("percent", e.percent)
},
)
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this(
JSONObject(m),
)
fun toJson(): JSONObject = json
}

@ -0,0 +1,127 @@
package org.xtimms.tokusho.sections.details
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DotSeparatorText
import org.xtimms.tokusho.utils.composable.selectedBackground
import org.xtimms.tokusho.utils.material.SecondaryItemAlpha
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChapterListItem(
title: String,
date: Long?,
scanlator: String?,
read: Boolean,
bookmark: Boolean,
selected: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val textAlpha = if (read) .38f else 1f
val textSubtitleAlpha = if (read) .38f else SecondaryItemAlpha
Box(
modifier = Modifier.clipToBounds()
) {
Row(
modifier = modifier
.selectedBackground(selected)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
var textHeight by remember { mutableIntStateOf(0) }
if (!read) {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = stringResource(R.string.unread),
modifier = Modifier
.height(8.dp)
.padding(end = 4.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
if (bookmark) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(R.string.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = textAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
)
}
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
if (date != null) {
Text(
text = date.toString(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (scanlator != null) DotSeparatorText()
}
if (scanlator != null) {
Text(
text = scanlator,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
}

@ -0,0 +1,63 @@
package org.xtimms.tokusho.sections.details
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.tokusho.core.model.Bookmark
import org.xtimms.tokusho.core.model.MangaHistory
import org.xtimms.tokusho.sections.details.data.MangaDetails
import org.xtimms.tokusho.sections.details.model.ChapterItem
import org.xtimms.tokusho.sections.details.model.toListItem
fun MangaDetails.mapChapters(
history: MangaHistory?,
newCount: Int,
branch: String?,
bookmarks: List<Bookmark>,
): List<ChapterItem> {
val remoteChapters = chapters[branch].orEmpty()
val localChapters = local?.manga?.getChapters(branch).orEmpty()
if (remoteChapters.isEmpty() && localChapters.isEmpty()) {
return emptyList()
}
val bookmarked = bookmarks.mapToSet { it.chapterId }
val currentId = history?.chapterId ?: 0L
val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount
val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) {
remoteChapters.mapTo(this) { it.id }
localChapters.mapTo(this) { it.id }
}
val result = ArrayList<ChapterItem>(ids.size)
val localMap = if (localChapters.isNotEmpty()) {
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
} else {
null
}
var isUnread = currentId !in ids
for (chapter in remoteChapters) {
val local = localMap?.remove(chapter.id)
if (chapter.id == currentId) {
isUnread = true
}
result += (local ?: chapter).toListItem(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = isUnread && result.size >= newFrom,
isDownloaded = local != null,
isBookmarked = chapter.id in bookmarked,
)
}
if (!localMap.isNullOrEmpty()) {
for (chapter in localMap.values) {
if (chapter.id == currentId) {
isUnread = true
}
result += chapter.toListItem(
isCurrent = chapter.id == currentId,
isUnread = isUnread,
isNew = false,
isDownloaded = !isLocal,
isBookmarked = chapter.id in bookmarked,
)
}
}
return result
}

@ -1,47 +1,78 @@
package org.xtimms.tokusho.sections.details
import android.net.Uri
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.ChipDefaults
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ChipColors
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
@ -54,29 +85,39 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.core.components.AnimatedButton
import org.xtimms.tokusho.core.components.ButtonType
import org.xtimms.tokusho.core.components.MangaCover
import org.xtimms.tokusho.core.components.MangaHorizontalItem
import org.xtimms.tokusho.core.components.ReadButton
import org.xtimms.tokusho.core.parser.favicon.faviconUri
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.disabledIconOpacity
import org.xtimms.tokusho.utils.composable.clickableNoIndication
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
import kotlin.math.roundToInt
@ -87,33 +128,42 @@ private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTIL
fun DetailsInfoBox(
coil: ImageLoader,
imageUrl: String,
favicon: Uri,
title: String,
author: String?,
altTitle: String,
score: Float,
author: String,
artist: String?,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
chapters: String?,
isTabletUi: Boolean,
appBarPadding: Dp,
modifier: Modifier = Modifier,
onCoverClick: () -> Unit,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
) {
Box(modifier = modifier) {
Column(modifier = modifier) {
val backdropGradientColors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background,
)
AsyncImage(
AsyncImageImpl(
coil = coil,
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.drawWithContent {
drawContent()
drawRect(
brush = Brush.verticalGradient(colors = backdropGradientColors),
.padding(start = 16.dp, end = 16.dp)
.aspectRatio(1f)
.clickable(
role = Role.Button,
onClick = onCoverClick
)
}
.blur(2.dp)
.alpha(0.2f)
.clip(MaterialTheme.shapes.large)
)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
@ -122,20 +172,37 @@ fun DetailsInfoBox(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
score = score,
author = author,
artist = artist,
state = state
isNsfw = isNsfw,
state = state,
source = source,
chapters = chapters,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked
)
} else {
MangaAndSourceTitlesLarge(
coil = coil,
appBarPadding = appBarPadding,
imageUrl = imageUrl,
favicon = favicon,
title = title,
altTitle = altTitle,
score = score,
author = author,
artist = artist,
state = state
isNsfw = isNsfw,
state = state,
source = source,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked
)
}
}
@ -147,10 +214,18 @@ private fun MangaAndSourceTitlesLarge(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
title: String,
author: String?,
altTitle: String,
score: Float,
author: String,
artist: String?,
state: MangaState?
isNsfw: Boolean,
source: MangaSource,
state: MangaState?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
) {
Column(
modifier = Modifier
@ -166,9 +241,18 @@ private fun MangaAndSourceTitlesLarge(
)
Spacer(modifier = Modifier.height(16.dp))
DetailsContentInfo(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
score = score,
author = author,
artist = artist,
isNsfw = isNsfw,
state = state,
source = source.title,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked
)
}
}
@ -178,103 +262,307 @@ private fun MangaAndSourceTitlesSmall(
coil: ImageLoader,
appBarPadding: Dp,
imageUrl: String,
favicon: Uri,
title: String,
author: String?,
altTitle: String,
score: Float,
author: String,
artist: String?,
isNsfw: Boolean,
state: MangaState?,
source: MangaSource,
chapters: String?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
MangaCover.Book(
coil = coil,
modifier = Modifier
.sizeIn(maxWidth = 100.dp)
.align(Alignment.Top),
data = imageUrl,
contentDescription = stringResource(R.string.manga_cover),
)
Column(
.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
/*AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(PaddingValues(bottom = 8.dp))
.clip(RoundedCornerShape(100))
.size(48.dp),
)*/
DetailsContentInfo(
coil = coil,
favicon = favicon,
title = title,
altTitle = altTitle,
score = score,
author = author,
artist = artist,
)
}
}
Row {
DetailsRow(
source = "MangaDex",
chapters = "22 chapters",
state = state
isNsfw = isNsfw,
state = state,
source = source.title,
isInShelf = isInShelf,
onAddToShelfClicked = onAddToShelfClicked,
onSourceClicked = onSourceClicked
)
}
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class,
ExperimentalMaterial3Api::class
)
@Composable
private fun ColumnScope.DetailsContentInfo(
private fun DetailsContentInfo(
coil: ImageLoader,
favicon: Uri,
title: String,
author: String?,
artist: String?,
altTitle: String,
score: Float,
author: String,
isNsfw: Boolean,
state: MangaState?,
source: String?,
isInShelf: Boolean,
onAddToShelfClicked: () -> Unit,
onSourceClicked: () -> Unit,
textAlign: TextAlign? = LocalTextStyle.current.textAlign,
) {
val context = LocalContext.current
Row {
Column(
modifier = Modifier
.padding(end = 16.dp, start = 16.dp)
) {
val sourceTitle = source?.takeIf { it.isNotBlank() }
?: stringResource(id = R.string.unknown)
Text(
text = title.ifBlank { stringResource(id = R.string.unknown_title) },
style = MaterialTheme.typography.headlineLarge,
textAlign = textAlign,
lineHeight = 36.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 3
)
if (altTitle.isNotBlank()) {
Text(
text = altTitle,
style = MaterialTheme.typography.headlineSmall,
textAlign = textAlign
textAlign = textAlign,
overflow = TextOverflow.Ellipsis,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(4.dp))
if (author.isNotEmpty()) {
Row(
modifier = Modifier.secondaryItemAlpha(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
modifier = Modifier.size(MaterialTheme.typography.titleLarge.fontSize.value.dp),
imageVector = Icons.Outlined.Person,
contentDescription = null,
modifier = Modifier.size(16.dp)
contentDescription = null
)
Text(
text = author?.takeIf { it.isNotBlank() }
?: stringResource(id = R.string.unknown_author),
style = MaterialTheme.typography.titleSmall,
textAlign = textAlign
text = author,
style = MaterialTheme.typography.titleLarge,
textAlign = textAlign,
overflow = TextOverflow.Ellipsis,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(4.dp))
}
if (!artist.isNullOrBlank() && author != artist) {
Row(
modifier = Modifier.secondaryItemAlpha(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
FlowRow(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
InputChip(
selected = false,
onClick = { onAddToShelfClicked() },
label = {
Text(
text = if (isInShelf)
stringResource(id = R.string.in_shelf)
else
stringResource(id = R.string.add_to_shelf),
color = if (isInShelf)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface,
)
},
leadingIcon = {
Icon(
modifier = Modifier.size(18.dp),
imageVector = Icons.Outlined.LocalLibrary,
contentDescription = null,
tint = if (isInShelf)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline
)
},
trailingIcon = {
Icon(
imageVector = Icons.Outlined.Brush,
imageVector = Icons.Outlined.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(16.dp)
tint = if (isInShelf)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline
)
},
border = BorderStroke(
1.dp,
if (isInShelf) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
)
AssistChip(
onClick = { onSourceClicked() },
leadingIcon = {
AsyncImageImpl(
coil = coil,
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(100)),
model = favicon,
contentScale = ContentScale.Crop,
contentDescription = null
)
},
label = { Text(text = sourceTitle) },
)
AssistChip(
onClick = { /*TODO*/ },
leadingIcon = {
Icon(
imageVector = when (state) {
MangaState.ONGOING -> Icons.Outlined.Schedule
MangaState.FINISHED -> Icons.Outlined.DoneAll
MangaState.ABANDONED -> Icons.Outlined.Close
MangaState.PAUSED -> Icons.Outlined.Pause
MangaState.UPCOMING -> Icons.Outlined.Upcoming
else -> Icons.Outlined.Block
},
contentDescription = null,
modifier = Modifier
.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
tint = MaterialTheme.colorScheme.outline
)
},
label = {
Text(
text = artist,
style = MaterialTheme.typography.titleSmall,
textAlign = textAlign,
text = when (state) {
MangaState.ONGOING -> stringResource(id = R.string.ongoing)
MangaState.FINISHED -> stringResource(id = R.string.finished)
MangaState.ABANDONED -> stringResource(id = R.string.abandoned)
MangaState.PAUSED -> stringResource(id = R.string.paused)
MangaState.UPCOMING -> stringResource(id = R.string.upcoming)
else -> stringResource(id = R.string.unknown)
},
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
)
if (isNsfw) {
AssistChip(
onClick = { /*TODO*/ },
leadingIcon = {
Icon(
modifier = Modifier.size(18.dp),
imageVector = Icons.Outlined.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
label = { Text(text = "18+", color = MaterialTheme.colorScheme.error) },
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
)
}
OutlinedIconButton(
modifier = Modifier
.height(32.dp)
.width(56.dp),
onClick = { /*TODO*/ },
shape = MaterialTheme.shapes.small,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
) {
val rotating by rememberInfiniteTransition("rotating").animateFloat(
label = "rotating",
initialValue = 360f,
targetValue = -360f,
animationSpec = infiniteRepeatable(tween(3000), RepeatMode.Restart)
)
Icon(
modifier = Modifier
.size(18.dp)
.rotate(rotating),
imageVector = Icons.Outlined.Sync,
contentDescription = "",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ReadButton()
/*FilledTonalButton(
modifier = Modifier
.height(54.dp)
.weight(1f),
onClick = { /*TODO*/ }
) {
Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(id = R.string.read))
}*/
AnimatedButton(
modifier = Modifier
.size(54.dp),
type = ButtonType.TERTIARY,
icon = Icons.Outlined.FileDownload
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
}
/*Row(modifier = Modifier
.weight(.5f)
.padding(start = 4.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AnimatedButton(
modifier = Modifier.size(54.dp),
type = KeyboardButtonType.PRIMARY,
icon = Icons.Outlined.FavoriteBorder
)
AnimatedButton(
modifier = Modifier
.height(54.dp)
.fillMaxWidth(),
type = KeyboardButtonType.TERTIARY,
icon = Icons.Outlined.PlayArrow
)
}*/
}
}
@Composable
private fun RowScope.DetailsRow(
source: String?,
@ -366,23 +654,37 @@ private fun RowScope.DetailsRow(
fun ExpandableMangaDescription(
defaultExpandState: Boolean,
description: String?,
tagsProvider: () -> List<MangaTag>?,
tagsProvider: () -> Set<MangaTag>?,
onTagSearch: (String) -> Unit,
onCopyTagToClipboard: (tag: String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(modifier = modifier.padding(start = 16.dp, end = 8.dp)) {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(defaultExpandState)
}
val desc =
description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder)
description.takeIf { !it.isNullOrBlank() }
?: stringResource(R.string.description_placeholder)
val trimmedDescription = remember(desc) {
desc
.replace(whitespaceLineRegex, "\n")
.trimEnd()
}
val tags = tagsProvider()
Text(
text = stringResource(id = R.string.description),
style = MaterialTheme.typography.titleLarge
)
MangaSummary(
expandedDescription = desc,
shrunkDescription = trimmedDescription,
expanded = expanded,
modifier = Modifier
.padding(top = 8.dp)
.clickableNoIndication { onExpanded(!expanded) },
)
if (!tags.isNullOrEmpty()) {
Box(
modifier = Modifier
@ -410,13 +712,13 @@ fun ExpandableMangaDescription(
)
}
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
tags.forEach {
TagsChip(
modifier = DefaultTagChipModifier,
text = it.title,
tag = it,
onClick = {
tagSelected = it.title
showMenu = true
@ -426,14 +728,7 @@ fun ExpandableMangaDescription(
}
}
}
MangaSummary(
expandedDescription = desc,
shrunkDescription = trimmedDescription,
expanded = expanded,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickableNoIndication { onExpanded(!expanded) },
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
}
}
@ -481,7 +776,8 @@ private fun MangaSummary(
modifier = Modifier.background(Brush.verticalGradient(colors = colors)),
contentAlignment = Alignment.Center,
) {
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
val image =
AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = stringResource(
@ -523,7 +819,7 @@ private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagsChip(
text: String,
tag: MangaTag,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
@ -531,7 +827,52 @@ private fun TagsChip(
SuggestionChip(
modifier = modifier,
onClick = onClick,
label = { Text(text = text) },
label = { Text(text = tag.title) },
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DetailsInfoBoxPreview() {
TokushoTheme {
LazyColumn() {
item {
DetailsInfoBox(
coil = ImageLoader(LocalContext.current),
appBarPadding = 0.dp,
imageUrl = "",
favicon = MangaSource.MANGADEX.faviconUri(),
title = "Yofukashi no Uta",
altTitle = "よふかしのうた",
score = 3f,
author = "Kotoyama",
artist = null,
isNsfw = true,
state = null,
source = MangaSource.MANGADEX,
chapters = "22",
isTabletUi = false,
onCoverClick = {},
isInShelf = true,
onAddToShelfClicked = {},
onSourceClicked = {}
)
}
item {
ExpandableMangaDescription(
defaultExpandState = true,
description = "Test ".repeat(5),
tagsProvider = {
setOf(
MangaTag("Test", "1", MangaSource.DUMMY),
MangaTag("Test", "2", MangaSource.DUMMY)
)
},
onTagSearch = { },
onCopyTagToClipboard = { }
)
}
}
}
}

@ -1,46 +1,101 @@
package org.xtimms.tokusho.sections.details
import android.net.Uri
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timelapse
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import kotlinx.coroutines.flow.collectLatest
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DetailsToolbar
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.components.MangaHorizontalItem
import org.xtimms.tokusho.core.parser.favicon.faviconUri
import org.xtimms.tokusho.utils.lang.toNavArgument
const val MANGA_ID_ARGUMENT = "{mangaId}"
const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailsView(
coil: ImageLoader,
mangaId: Long,
viewModel: DetailsViewModel = hiltViewModel(),
navigateBack: () -> Unit,
navigateToFullImage: (String) -> Unit,
navigateToDetails: (Long) -> Unit,
navigateToSource: (MangaSource) -> Unit
) {
val context = LocalContext.current
val viewModel: DetailsViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val chapterListState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
var openCategoriesBottomSheet by rememberSaveable { mutableStateOf(false) }
val uriHandler = LocalUriHandler.current
fun openUrl(url: String) {
uriHandler.openUri(url)
}
val isChaptersEmpty by viewModel.isChaptersEmpty.collectAsStateWithLifecycle(false)
val chapters by viewModel.chapters.collectAsStateWithLifecycle(emptyList())
val relatedManga by viewModel.relatedManga.collectAsStateWithLifecycle(emptyList())
val readingTime by viewModel.readingTime.collectAsStateWithLifecycle(null)
val favouriteCategories by viewModel.favouriteCategories.collectAsStateWithLifecycle()
LaunchedEffect(mangaId) {
if (viewModel.details.value == null) viewModel.doLoad(mangaId)
}
LaunchedEffect(Unit) {
viewModel.events.collectLatest { e ->
when (e) {
DetailsViewModel.Event.InternalError ->
snackbarHostState.showSnackbar(context.getString(R.string.error_occured))
}
}
}
Scaffold(
topBar = {
@ -59,22 +114,27 @@ fun DetailsView(
label = "Top Bar Background",
)
DetailsToolbar(
title = uiState.details?.toManga()?.title ?: "Unknown",
title = viewModel.details.value?.toManga()?.title.orEmpty(),
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
onBackClicked = { navigateBack() }
navigateBack = { navigateBack() },
navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) },
)
},
bottomBar = {
},
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState
)
}
) { contentPadding ->
val topPadding = contentPadding.calculateTopPadding()
val layoutDirection = LocalLayoutDirection.current
val relatedMangaListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = chapterListState,
contentPadding = PaddingValues(
top = contentPadding.calculateTopPadding() - 60.dp,
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding(),
@ -86,30 +146,49 @@ fun DetailsView(
) {
DetailsInfoBox(
coil = coil,
imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
title = uiState.details?.toManga()?.title ?: "",
author = uiState.details?.toManga()?.author ?: "",
imageUrl = viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty(),
favicon = viewModel.details.value?.toManga()?.source?.faviconUri() ?: Uri.EMPTY,
title = viewModel.details.value?.toManga()?.title.orEmpty(),
altTitle = viewModel.details.value?.toManga()?.altTitle.orEmpty(),
score = viewModel.details.value?.toManga()?.rating ?: RATING_UNKNOWN,
author = viewModel.details.value?.toManga()?.author.orEmpty(),
artist = "",
state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
isNsfw = viewModel.details.value?.toManga()?.isNsfw ?: true,
state = viewModel.details.value?.toManga()?.state ?: MangaState.FINISHED,
source = viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY,
chapters = chapters.size.toString(),
isTabletUi = false,
appBarPadding = topPadding,
onCoverClick = {
navigateToFullImage(
arrayOf(
viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty()
).toNavArgument()
)
},
isInShelf = favouriteCategories,
onAddToShelfClicked = {
openCategoriesBottomSheet = !openCategoriesBottomSheet
},
onSourceClicked = {
navigateToSource(
viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY
)
}
)
}
val time = viewModel.readingTime.value
if (AppSettings.isReadingTimeEstimationEnabled() || time == null) {
/*if (AppSettings.isReadingTimeEstimationEnabled() || readingTime != null) {
item {
if (time != null) {
PreferenceItem(
title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource(
title = if (readingTime?.isContinue == true) stringResource(id = R.string.approximate_remaining_time) else stringResource(
id = R.string.approximate_reading_time
),
description = time.format(context.resources),
description = readingTime?.format(context.resources),
icon = Icons.Outlined.Timelapse
)
}
}
}
}*/
item(
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
@ -117,13 +196,78 @@ fun DetailsView(
) {
ExpandableMangaDescription(
defaultExpandState = true,
description = uiState.details?.toManga()?.description ?: "",
tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
description = viewModel.details.value?.toManga()?.description,
tagsProvider = { viewModel.details.value?.toManga()?.tags },
onTagSearch = { },
onCopyTagToClipboard = { },
)
}
item {
Column {
Text(
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
text = stringResource(id = R.string.related_manga),
style = MaterialTheme.typography.titleLarge
)
LazyRow(
modifier = Modifier
.padding(top = 8.dp)
.sizeIn(minHeight = 100.dp),
state = relatedMangaListState,
contentPadding = PaddingValues(horizontal = 8.dp),
flingBehavior = rememberSnapFlingBehavior(lazyListState = relatedMangaListState)
) {
items(
items = relatedManga,
key = { it.id },
contentType = { it }
) {
MangaHorizontalItem(
coil = coil,
manga = it,
onClick = { navigateToDetails(it.id) },
onLongClick = { })
}
}
HorizontalDivider(modifier = Modifier.padding(16.dp))
}
}
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp),
text = stringResource(id = R.string.chapters),
style = MaterialTheme.typography.titleLarge
)
}
items(
items = chapters
) {
ChapterListItem(
title = it.chapter.name,
date = it.chapter.uploadDate,
scanlator = it.chapter.scanlator,
read = it.isUnread,
bookmark = false,
selected = false,
onLongClick = { /*TODO*/ },
onClick = { /*TODO*/ }
)
}
}
}
if (openCategoriesBottomSheet) {
val windowInsets = WindowInsets(0)
ModalBottomSheet(
onDismissRequest = { openCategoriesBottomSheet = false },
windowInsets = windowInsets
) {
Text(text = "Hello MBS")
Spacer(modifier = Modifier.height(1000.dp))
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save