├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── rerere │ │ └── zhiwang │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── me │ │ │ └── rerere │ │ │ └── zhiwang │ │ │ ├── AppContext.kt │ │ │ ├── MainActivity.kt │ │ │ ├── api │ │ │ ├── bilibili │ │ │ │ ├── BilibiliUser.kt │ │ │ │ ├── BilibiliUserData.kt │ │ │ │ ├── BilibiliUtil.kt │ │ │ │ ├── SubList.kt │ │ │ │ └── SubResult.kt │ │ │ ├── wiki │ │ │ │ ├── WikiList.kt │ │ │ │ └── WikiUtil.kt │ │ │ ├── zhiwang │ │ │ │ ├── Request.kt │ │ │ │ ├── Response.kt │ │ │ │ └── ZhiWangService.kt │ │ │ └── zuowen │ │ │ │ ├── ZuowenContent.kt │ │ │ │ ├── ZuowenPageSource.kt │ │ │ │ ├── ZuowenResponse.kt │ │ │ │ └── ZuowenService.kt │ │ │ ├── di │ │ │ └── NetworkModule.kt │ │ │ ├── repo │ │ │ ├── WikiRepo.kt │ │ │ └── ZuowenRepo.kt │ │ │ ├── ui │ │ │ ├── public │ │ │ │ └── XiaoZuoWen.kt │ │ │ ├── screen │ │ │ │ ├── index │ │ │ │ │ ├── IndexScreen.kt │ │ │ │ │ ├── IndexScreenVideoModel.kt │ │ │ │ │ └── page │ │ │ │ │ │ ├── About.kt │ │ │ │ │ │ ├── Chachong.kt │ │ │ │ │ │ ├── MemeWiki.kt │ │ │ │ │ │ └── Zuowen.kt │ │ │ │ └── zuowen │ │ │ │ │ ├── ZuowenScreen.kt │ │ │ │ │ └── ZuowenViewModel.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── util │ │ │ ├── ModifierEx.kt │ │ │ ├── ReplaceUtil.kt │ │ │ ├── UpdateChecker.kt │ │ │ ├── android │ │ │ └── ClipboardUtil.kt │ │ │ ├── charts │ │ │ ├── ChartShape.kt │ │ │ ├── bars │ │ │ │ ├── DropdownContent.kt │ │ │ │ ├── HorizontalBarsChart.kt │ │ │ │ ├── StackedHorizontalBar.kt │ │ │ │ ├── Utils.kt │ │ │ │ └── data │ │ │ │ │ ├── HorizontalBarsData.kt │ │ │ │ │ ├── StackedBarData.kt │ │ │ │ │ ├── StackedBarEntry.kt │ │ │ │ │ └── StackedBarItem.kt │ │ │ ├── internal │ │ │ │ ├── DefaultText.kt │ │ │ │ └── Utils.kt │ │ │ ├── legend │ │ │ │ ├── HorizontalLegend.kt │ │ │ │ ├── LegendEntry.kt │ │ │ │ └── VerticalLegend.kt │ │ │ ├── pie │ │ │ │ ├── LegendPosition.kt │ │ │ │ ├── PieChart.kt │ │ │ │ ├── PieChartData.kt │ │ │ │ ├── PieChartEntry.kt │ │ │ │ ├── PieChartRenderer.kt │ │ │ │ └── Utils.kt │ │ │ └── table │ │ │ │ ├── Table.kt │ │ │ │ ├── TableEntry.kt │ │ │ │ └── TableRow.kt │ │ │ ├── format │ │ │ ├── Format.kt │ │ │ └── HtmlFormatUtil.kt │ │ │ └── net │ │ │ ├── AutoRetry.kt │ │ │ ├── OkhttpUtil.kt │ │ │ └── UserAgentInterceptor.kt │ └── res │ │ ├── drawable-v24 │ │ ├── asoul.jpg │ │ └── chengfen.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── me │ └── rerere │ └── zhiwang │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── Versions.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── chengfen.png ├── query.png └── zuowen.png ├── settings.gradle.kts └── wiki.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | /buildSrc/build 12 | /app/release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASoulZhiWang 2 | 一个ASOUL评论区小作文APP,可以查询小作文出处,也可以浏览小作文库 3 | * 查重API来自: https://github.com/stream2000/ASoulCnki 4 | * 小作文库来自: https://asoul.icu 5 | 6 | ## A-SOUL简介 🥵 7 | A-SOUL是乐华娱乐于2020年11月23日公开的其旗下首个虚拟偶像团体,由5名成员组成。 8 | * A-SOUL主页链接:https://space.bilibili.com/703007996 9 | * 珈乐:https://space.bilibili.com/351609538 10 | * 乃琳:https://space.bilibili.com/672342685 11 | * 贝拉:https://space.bilibili.com/672353429 12 | * 向晚:https://space.bilibili.com/672346917 13 | * 嘉然:https://space.bilibili.com/672328094 14 | 15 | 在未来学院中,五位性格迥异的少女,为了成为偶像这一共同目标走到一起,并且为之努力奋斗。 16 | 17 | ## 下载 🐭 18 | 前往Release页面下载: https://github.com/jiangdashao/ASoulZhiWang/releases 19 | 20 | ## 技术栈和引用库 👍 21 | * JetPack Compose - 声明式UI框架 22 | * Hilt - 依赖注入 23 | * Navigation - 导航 24 | * Retrofit - 访问Restful API 25 | * Paging3 - 列表数据加载库 26 | 27 | ## 截图 🎞 28 | | 查重 | 小作文库 | 查成分 | 29 | | ----- | ------| ------| 30 | | | | | 31 | 32 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id ("com.android.application") 3 | id ("kotlin-android") 4 | id ("kotlin-kapt") 5 | id ("dagger.hilt.android.plugin") 6 | } 7 | 8 | android { 9 | compileSdk = 31 10 | buildToolsVersion = "30.0.3" 11 | 12 | defaultConfig { 13 | applicationId = "me.rerere.zhiwang" 14 | minSdk = 26 15 | targetSdk = 31 16 | versionCode = 12 17 | versionName = "2.0.0" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | vectorDrawables { 21 | useSupportLibrary = true 22 | } 23 | } 24 | 25 | buildTypes { 26 | release { 27 | isMinifyEnabled = false 28 | proguardFiles (getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 29 | } 30 | } 31 | compileOptions { 32 | sourceCompatibility (JavaVersion.VERSION_1_8) 33 | targetCompatibility (JavaVersion.VERSION_1_8) 34 | } 35 | kotlinOptions { 36 | jvmTarget = "1.8" 37 | } 38 | buildFeatures { 39 | compose = true 40 | } 41 | composeOptions { 42 | kotlinCompilerExtensionVersion = composeVersion 43 | } 44 | } 45 | 46 | dependencies { 47 | implementation ("androidx.core:core-ktx:1.7.0") 48 | implementation ("androidx.appcompat:appcompat:1.3.1") 49 | implementation ("com.google.android.material:material:1.4.0") 50 | 51 | // Compose Lib 52 | implementation ("androidx.compose.ui:ui:$composeVersion") 53 | implementation ("androidx.compose.material:material:$composeVersion") 54 | implementation("androidx.compose.material3:material3:1.0.0-alpha01") 55 | implementation ("androidx.compose.ui:ui-tooling:$composeVersion") 56 | implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0") 57 | implementation ("androidx.activity:activity-compose:1.4.0") 58 | implementation ("androidx.compose.runtime:runtime-livedata:$composeVersion") 59 | 60 | // splash 61 | implementation("androidx.core:core-splashscreen:1.0.0-alpha02") 62 | 63 | // Dialog 64 | implementation ("io.github.vanpra.compose-material-dialogs:core:0.6.1") 65 | 66 | // Coil 67 | implementation("io.coil-kt:coil-compose:1.3.2") 68 | 69 | // Hilt 70 | implementation ("com.google.dagger:hilt-android:$hiltVersion") 71 | kapt ("com.google.dagger:hilt-compiler:$hiltVersion") 72 | implementation ("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03") 73 | 74 | // Paging3 75 | implementation ("androidx.paging:paging-runtime-ktx:3.1.0-beta01") 76 | implementation ("androidx.paging:paging-compose:1.0.0-alpha14") 77 | 78 | // 图标扩展 79 | implementation ("androidx.compose.material:material-icons-extended:$composeVersion") 80 | 81 | // Navigation for JetpackCompose 82 | implementation ("androidx.navigation:navigation-compose:2.4.0-beta01") 83 | 84 | // accompanist 85 | // Pager 86 | implementation ("com.google.accompanist:accompanist-pager:$accVersion") 87 | // Swipe to refresh 88 | implementation ("com.google.accompanist:accompanist-swiperefresh:$accVersion") 89 | // 状态栏颜色 90 | implementation ("com.google.accompanist:accompanist-systemuicontroller:$accVersion") 91 | // Insets 92 | implementation ("com.google.accompanist:accompanist-insets:$accVersion") 93 | implementation ("com.google.accompanist:accompanist-insets-ui:$accVersion") 94 | // Flow 95 | implementation ("com.google.accompanist:accompanist-flowlayout:$accVersion") 96 | // Placeholder 97 | implementation ("com.google.accompanist:accompanist-placeholder-material:$accVersion") 98 | 99 | // Retrofit 100 | implementation ("com.squareup.okhttp3:okhttp:5.0.0-alpha.2") 101 | implementation ("com.squareup.retrofit2:converter-gson:2.9.0") 102 | implementation ("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2") 103 | 104 | // JSOUP 105 | implementation ("org.jsoup:jsoup:1.13.1") 106 | 107 | // 约束布局 108 | implementation ("androidx.constraintlayout:constraintlayout:2.1.1") 109 | implementation ("androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01") 110 | 111 | 112 | testImplementation ("junit:junit:4.+") 113 | androidTestImplementation ("androidx.test.ext:junit:1.1.3") 114 | androidTestImplementation ("androidx.test.espresso:espresso-core:3.4.0") 115 | androidTestImplementation ("androidx.compose.ui:ui-test-junit4:$composeVersion") 116 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/me/rerere/zhiwang/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("me.rerere.zhiwang", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/AppContext.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.hilt.android.HiltAndroidApp 6 | 7 | @HiltAndroidApp 8 | class AppContext : Application() { 9 | companion object { 10 | lateinit var appContext: Context 11 | } 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | appContext = this 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang 2 | 3 | import android.os.Build 4 | import android.os.Bundle 5 | import android.view.ViewGroup 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.runtime.SideEffect 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.platform.ComposeView 14 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 15 | import androidx.core.view.WindowCompat 16 | import androidx.navigation.NavType 17 | import androidx.navigation.compose.NavHost 18 | import androidx.navigation.compose.composable 19 | import androidx.navigation.compose.rememberNavController 20 | import androidx.navigation.navArgument 21 | import com.google.accompanist.insets.ProvideWindowInsets 22 | import com.google.accompanist.pager.ExperimentalPagerApi 23 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 24 | import dagger.hilt.android.AndroidEntryPoint 25 | import me.rerere.zhiwang.ui.screen.index.IndexScreen 26 | import me.rerere.zhiwang.ui.screen.zuowen.ZuowenScreen 27 | import me.rerere.zhiwang.ui.theme.ZhiWangTheme 28 | 29 | @AndroidEntryPoint 30 | class MainActivity : ComponentActivity() { 31 | @OptIn(ExperimentalPagerApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) 32 | override fun onCreate(savedInstanceState: Bundle?) { 33 | super.onCreate(savedInstanceState) 34 | 35 | // 全屏 36 | WindowCompat.setDecorFitsSystemWindows(window, false) 37 | 38 | installSplashScreen() 39 | 40 | setContent { 41 | ZhiWangTheme { 42 | ProvideWindowInsets { 43 | val navController = rememberNavController() 44 | 45 | val systemUiController = rememberSystemUiController() 46 | val darkIcons = MaterialTheme.colors.isLight 47 | 48 | // 设置状态栏和导航栏颜色 49 | SideEffect { 50 | systemUiController.setNavigationBarColor( 51 | Color.Transparent, 52 | darkIcons = darkIcons 53 | ) 54 | systemUiController.setStatusBarColor( 55 | Color.Transparent, 56 | darkIcons = darkIcons 57 | ) 58 | } 59 | 60 | // 导航部件 61 | NavHost( 62 | modifier = Modifier.fillMaxSize(), 63 | navController = navController, 64 | startDestination = "index" 65 | ) { 66 | composable("index") { 67 | IndexScreen(navController) 68 | } 69 | 70 | composable("zuowen?id={id}&title={title}&author={author}&tags={tags}", 71 | arguments = listOf( 72 | navArgument("id") { 73 | type = NavType.StringType 74 | }, 75 | navArgument("title") { 76 | type = NavType.StringType 77 | }, 78 | navArgument("author") { 79 | type = NavType.StringType 80 | }, 81 | navArgument("tags") { 82 | type = NavType.StringType 83 | } 84 | )) { 85 | ZuowenScreen( 86 | navController, 87 | it.arguments?.getString("id")!!, 88 | it.arguments?.getString("title")!!, 89 | it.arguments?.getString("author")!!, 90 | it.arguments?.getString("tags")!!.run { 91 | split(",") 92 | } 93 | ) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | // 禁止强制暗色模式,因为已经适配了夜间模式,所以不需要强制反色 101 | // 国产UI似乎必需这样做(isForceDarkAllowed = false)才能阻止反色,原生会自动识别 102 | val existingComposeView = window.decorView 103 | .findViewById(android.R.id.content) 104 | .getChildAt(0) as? ComposeView 105 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 106 | existingComposeView?.isForceDarkAllowed = false 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/bilibili/BilibiliUser.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.bilibili 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class BilibiliUser( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: Data, 11 | @SerializedName("message") 12 | val message: String, 13 | @SerializedName("ttl") 14 | val ttl: Int 15 | ) { 16 | data class Data( 17 | @SerializedName("birthday") 18 | val birthday: String, 19 | @SerializedName("coins") 20 | val coins: Int, 21 | @SerializedName("face") 22 | val face: String, 23 | @SerializedName("fans_badge") 24 | val fansBadge: Boolean, 25 | @SerializedName("is_followed") 26 | val isFollowed: Boolean, 27 | @SerializedName("jointime") 28 | val jointime: Int, 29 | @SerializedName("level") 30 | val level: Int, 31 | @SerializedName("live_room") 32 | val liveRoom: LiveRoom, 33 | @SerializedName("mid") 34 | val mid: Int, 35 | @SerializedName("moral") 36 | val moral: Int, 37 | @SerializedName("name") 38 | val name: String, 39 | @SerializedName("nameplate") 40 | val nameplate: Nameplate, 41 | @SerializedName("official") 42 | val official: Official, 43 | @SerializedName("pendant") 44 | val pendant: Pendant, 45 | @SerializedName("rank") 46 | val rank: Int, 47 | @SerializedName("sex") 48 | val sex: String, 49 | @SerializedName("sign") 50 | val sign: String, 51 | @SerializedName("silence") 52 | val silence: Int, 53 | @SerializedName("sys_notice") 54 | val sysNotice: SysNotice, 55 | @SerializedName("theme") 56 | val theme: Theme, 57 | @SerializedName("top_photo") 58 | val topPhoto: String, 59 | @SerializedName("user_honour_info") 60 | val userHonourInfo: UserHonourInfo, 61 | @SerializedName("vip") 62 | val vip: Vip 63 | ) { 64 | data class LiveRoom( 65 | @SerializedName("broadcast_type") 66 | val broadcastType: Int, 67 | @SerializedName("cover") 68 | val cover: String, 69 | @SerializedName("liveStatus") 70 | val liveStatus: Int, 71 | @SerializedName("online") 72 | val online: Int, 73 | @SerializedName("roomStatus") 74 | val roomStatus: Int, 75 | @SerializedName("roomid") 76 | val roomid: Int, 77 | @SerializedName("roundStatus") 78 | val roundStatus: Int, 79 | @SerializedName("title") 80 | val title: String, 81 | @SerializedName("url") 82 | val url: String 83 | ) 84 | 85 | data class Nameplate( 86 | @SerializedName("condition") 87 | val condition: String, 88 | @SerializedName("image") 89 | val image: String, 90 | @SerializedName("image_small") 91 | val imageSmall: String, 92 | @SerializedName("level") 93 | val level: String, 94 | @SerializedName("name") 95 | val name: String, 96 | @SerializedName("nid") 97 | val nid: Int 98 | ) 99 | 100 | data class Official( 101 | @SerializedName("desc") 102 | val desc: String, 103 | @SerializedName("role") 104 | val role: Int, 105 | @SerializedName("title") 106 | val title: String, 107 | @SerializedName("type") 108 | val type: Int 109 | ) 110 | 111 | data class Pendant( 112 | @SerializedName("expire") 113 | val expire: Int, 114 | @SerializedName("image") 115 | val image: String, 116 | @SerializedName("image_enhance") 117 | val imageEnhance: String, 118 | @SerializedName("image_enhance_frame") 119 | val imageEnhanceFrame: String, 120 | @SerializedName("name") 121 | val name: String, 122 | @SerializedName("pid") 123 | val pid: Int 124 | ) 125 | 126 | class SysNotice( 127 | ) 128 | 129 | class Theme( 130 | ) 131 | 132 | data class UserHonourInfo( 133 | @SerializedName("colour") 134 | val colour: Any, 135 | @SerializedName("mid") 136 | val mid: Int, 137 | @SerializedName("tags") 138 | val tags: Any 139 | ) 140 | 141 | data class Vip( 142 | @SerializedName("avatar_subscript") 143 | val avatarSubscript: Int, 144 | @SerializedName("avatar_subscript_url") 145 | val avatarSubscriptUrl: String, 146 | @SerializedName("due_date") 147 | val dueDate: Int, 148 | @SerializedName("label") 149 | val label: Label, 150 | @SerializedName("nickname_color") 151 | val nicknameColor: String, 152 | @SerializedName("role") 153 | val role: Int, 154 | @SerializedName("status") 155 | val status: Int, 156 | @SerializedName("theme_type") 157 | val themeType: Int, 158 | @SerializedName("type") 159 | val type: Int, 160 | @SerializedName("vip_pay_type") 161 | val vipPayType: Int 162 | ) { 163 | data class Label( 164 | @SerializedName("bg_color") 165 | val bgColor: String, 166 | @SerializedName("bg_style") 167 | val bgStyle: Int, 168 | @SerializedName("border_color") 169 | val borderColor: String, 170 | @SerializedName("label_theme") 171 | val labelTheme: String, 172 | @SerializedName("path") 173 | val path: String, 174 | @SerializedName("text") 175 | val text: String, 176 | @SerializedName("text_color") 177 | val textColor: String 178 | ) 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/bilibili/BilibiliUserData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.bilibili 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class BilibiliUserData( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: Data, 11 | @SerializedName("message") 12 | val message: String, 13 | @SerializedName("ttl") 14 | val ttl: Int 15 | ) { 16 | data class Data( 17 | @SerializedName("birthday") 18 | val birthday: String, 19 | @SerializedName("coins") 20 | val coins: Int, 21 | @SerializedName("face") 22 | val face: String, 23 | @SerializedName("fans_badge") 24 | val fansBadge: Boolean, 25 | @SerializedName("is_followed") 26 | val isFollowed: Boolean, 27 | @SerializedName("jointime") 28 | val jointime: Int, 29 | @SerializedName("level") 30 | val level: Int, 31 | @SerializedName("live_room") 32 | val liveRoom: LiveRoom, 33 | @SerializedName("mid") 34 | val mid: Int, 35 | @SerializedName("moral") 36 | val moral: Int, 37 | @SerializedName("name") 38 | val name: String, 39 | @SerializedName("nameplate") 40 | val nameplate: Nameplate, 41 | @SerializedName("official") 42 | val official: Official, 43 | @SerializedName("pendant") 44 | val pendant: Pendant, 45 | @SerializedName("rank") 46 | val rank: Int, 47 | @SerializedName("sex") 48 | val sex: String, 49 | @SerializedName("sign") 50 | val sign: String, 51 | @SerializedName("silence") 52 | val silence: Int, 53 | @SerializedName("sys_notice") 54 | val sysNotice: SysNotice, 55 | @SerializedName("theme") 56 | val theme: Theme, 57 | @SerializedName("top_photo") 58 | val topPhoto: String, 59 | @SerializedName("user_honour_info") 60 | val userHonourInfo: UserHonourInfo, 61 | @SerializedName("vip") 62 | val vip: Vip 63 | ) { 64 | data class LiveRoom( 65 | @SerializedName("broadcast_type") 66 | val broadcastType: Int, 67 | @SerializedName("cover") 68 | val cover: String, 69 | @SerializedName("liveStatus") 70 | val liveStatus: Int, 71 | @SerializedName("online") 72 | val online: Int, 73 | @SerializedName("roomStatus") 74 | val roomStatus: Int, 75 | @SerializedName("roomid") 76 | val roomid: Int, 77 | @SerializedName("roundStatus") 78 | val roundStatus: Int, 79 | @SerializedName("title") 80 | val title: String, 81 | @SerializedName("url") 82 | val url: String 83 | ) 84 | 85 | data class Nameplate( 86 | @SerializedName("condition") 87 | val condition: String, 88 | @SerializedName("image") 89 | val image: String, 90 | @SerializedName("image_small") 91 | val imageSmall: String, 92 | @SerializedName("level") 93 | val level: String, 94 | @SerializedName("name") 95 | val name: String, 96 | @SerializedName("nid") 97 | val nid: Int 98 | ) 99 | 100 | data class Official( 101 | @SerializedName("desc") 102 | val desc: String, 103 | @SerializedName("role") 104 | val role: Int, 105 | @SerializedName("title") 106 | val title: String, 107 | @SerializedName("type") 108 | val type: Int 109 | ) 110 | 111 | data class Pendant( 112 | @SerializedName("expire") 113 | val expire: Int, 114 | @SerializedName("image") 115 | val image: String, 116 | @SerializedName("image_enhance") 117 | val imageEnhance: String, 118 | @SerializedName("image_enhance_frame") 119 | val imageEnhanceFrame: String, 120 | @SerializedName("name") 121 | val name: String, 122 | @SerializedName("pid") 123 | val pid: Int 124 | ) 125 | 126 | class SysNotice( 127 | ) 128 | 129 | class Theme( 130 | ) 131 | 132 | data class UserHonourInfo( 133 | @SerializedName("colour") 134 | val colour: Any, 135 | @SerializedName("mid") 136 | val mid: Int, 137 | @SerializedName("tags") 138 | val tags: Any 139 | ) 140 | 141 | data class Vip( 142 | @SerializedName("avatar_subscript") 143 | val avatarSubscript: Int, 144 | @SerializedName("avatar_subscript_url") 145 | val avatarSubscriptUrl: String, 146 | @SerializedName("due_date") 147 | val dueDate: Long, 148 | @SerializedName("label") 149 | val label: Label, 150 | @SerializedName("nickname_color") 151 | val nicknameColor: String, 152 | @SerializedName("role") 153 | val role: Int, 154 | @SerializedName("status") 155 | val status: Int, 156 | @SerializedName("theme_type") 157 | val themeType: Int, 158 | @SerializedName("type") 159 | val type: Int, 160 | @SerializedName("vip_pay_type") 161 | val vipPayType: Int 162 | ) { 163 | data class Label( 164 | @SerializedName("bg_color") 165 | val bgColor: String, 166 | @SerializedName("bg_style") 167 | val bgStyle: Int, 168 | @SerializedName("border_color") 169 | val borderColor: String, 170 | @SerializedName("label_theme") 171 | val labelTheme: String, 172 | @SerializedName("path") 173 | val path: String, 174 | @SerializedName("text") 175 | val text: String, 176 | @SerializedName("text_color") 177 | val textColor: String 178 | ) 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/bilibili/BilibiliUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.bilibili 2 | 3 | import com.google.gson.Gson 4 | import kotlinx.coroutines.delay 5 | import me.rerere.zhiwang.util.net.await 6 | import okhttp3.OkHttpClient 7 | import okhttp3.Request 8 | import org.jsoup.Jsoup 9 | 10 | class BilibiliUtil( 11 | private val okHttpClient: OkHttpClient 12 | ) { 13 | private val gson = Gson() 14 | 15 | suspend fun getUserInfo(mId: Int): BilibiliUserData? = try { 16 | val request = Request.Builder() 17 | .url("https://api.bilibili.com/x/space/acc/info?mid=$mId") 18 | .get() 19 | .build() 20 | val response = okHttpClient.newCall(request).await() 21 | require(response.isSuccessful) 22 | val json = response.body?.string() 23 | // println(json) 24 | val user = gson.fromJson(json, BilibiliUserData::class.java) 25 | user 26 | } catch (e: Exception) { 27 | e.printStackTrace() 28 | null 29 | } 30 | 31 | suspend fun getAllSubLis(profileLink: String): SubResult? = try { 32 | val profileRequest = Request.Builder() 33 | .url(profileLink) 34 | .get() 35 | .build() 36 | 37 | val profileResponse = okHttpClient.newCall(profileRequest).await() 38 | // 用户ID 39 | val id = profileResponse.request.url.toString().let { 40 | it.substring( 41 | it.indexOf("com/") + 4, 42 | it.indexOf('?', it.indexOf("com/") + 4) 43 | ) 44 | } 45 | // 用户名 46 | val name = Jsoup.parse(profileResponse.body?.string()).title().let { 47 | it.substring(0 until it.indexOf("的个人空间")) 48 | } 49 | // 关注的用户 50 | val list = hashSetOf() 51 | repeat(5) { 52 | val subRequest = Request.Builder() 53 | .url("https://api.bilibili.com/x/relation/followings?vmid=$id&pn=${it + 1}&ps=50&order=desc") 54 | .get() 55 | .build() 56 | val subList = try { 57 | val subResponse = okHttpClient.newCall(subRequest).await() 58 | gson.fromJson(subResponse.body!!.string(), SubList::class.java) 59 | } catch (e: Exception) { 60 | e.printStackTrace() 61 | null 62 | } 63 | subList?.data?.list?.forEach { user -> 64 | list.add( 65 | SubUser(mid = user.mid, name = user.uname) 66 | ) 67 | } 68 | delay(50) 69 | } 70 | repeat(5) { 71 | val subRequest = Request.Builder() 72 | .url("https://api.bilibili.com/x/relation/followings?vmid=$id&pn=${it + 1}&ps=50&order=asc") 73 | .get() 74 | .build() 75 | val subList = try { 76 | val subResponse = okHttpClient.newCall(subRequest).await() 77 | gson.fromJson(subResponse.body!!.string(), SubList::class.java) 78 | } catch (e: Exception) { 79 | e.printStackTrace() 80 | null 81 | } 82 | subList?.data?.list?.forEach { user -> 83 | list.add( 84 | SubUser(mid = user.mid, name = user.uname) 85 | ) 86 | } 87 | delay(50) 88 | } 89 | list.forEach { 90 | println("${it.mid} - ${it.name}") 91 | } 92 | val result = SubResult( 93 | id = id, 94 | name = name, 95 | subList = list 96 | ) 97 | result 98 | } catch (e: Exception) { 99 | e.printStackTrace() 100 | null 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/bilibili/SubList.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.bilibili 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class SubList( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: Data, 11 | @SerializedName("message") 12 | val message: String, 13 | @SerializedName("ttl") 14 | val ttl: Int 15 | ) { 16 | data class Data( 17 | @SerializedName("list") 18 | val list: List, 19 | @SerializedName("re_version") 20 | val reVersion: Long, 21 | @SerializedName("total") 22 | val total: Int 23 | ) { 24 | data class User( 25 | @SerializedName("attribute") 26 | val attribute: Int, 27 | @SerializedName("face") 28 | val face: String, 29 | @SerializedName("mid") 30 | val mid: Int, 31 | @SerializedName("mtime") 32 | val mtime: Int, 33 | @SerializedName("sign") 34 | val sign: String, 35 | @SerializedName("special") 36 | val special: Int, 37 | @SerializedName("tag") 38 | val tag: Any, 39 | @SerializedName("uname") 40 | val uname: String, 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/bilibili/SubResult.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.bilibili 2 | 3 | data class SubResult( 4 | val id: String, 5 | val name: String, 6 | val subList: Set 7 | ) 8 | 9 | data class SubUser( 10 | val mid: Int, 11 | val name: String 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/wiki/WikiList.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.wiki 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class WikiList : ArrayList(){ 7 | data class WikiListItem( 8 | @SerializedName("description") 9 | val description: String, 10 | @SerializedName("title") 11 | val title: String 12 | ) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/wiki/WikiUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.wiki 2 | 3 | import com.google.gson.Gson 4 | import me.rerere.zhiwang.util.net.await 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import javax.inject.Inject 8 | 9 | class WikiUtil @Inject constructor( 10 | private val okHttpClient: OkHttpClient 11 | ){ 12 | private val gson = Gson() 13 | 14 | suspend fun loadWiki() : List? { 15 | return try { 16 | val request = Request.Builder() 17 | .url("https://cdn.jsdelivr.net/gh/jiangdashao/ASoulZhiWang/wiki.json") 18 | .get() 19 | .build() 20 | val response = okHttpClient.newCall(request).await() 21 | val body = response.body?.string()?.trim() 22 | val wikiList = gson.fromJson(body, WikiList::class.java) 23 | wikiList 24 | }catch (e: Exception){ 25 | e.printStackTrace() 26 | null 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zhiwang/Request.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zhiwang 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class Request( 6 | @SerializedName("text") 7 | val text: String 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zhiwang/Response.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zhiwang 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Response( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: Data, 11 | @SerializedName("message") 12 | val message: String 13 | ) { 14 | data class Data( 15 | @SerializedName("end_time") 16 | val endTime: Int, 17 | @SerializedName("rate") 18 | val rate: Double, 19 | @SerializedName("related") 20 | val related: List, 21 | @SerializedName("start_time") 22 | val startTime: Int 23 | ) { 24 | data class Related( 25 | @SerializedName("rate") 26 | val rate: Double, 27 | @SerializedName("reply") 28 | val reply: Reply, 29 | @SerializedName("reply_url") 30 | val replyUrl: String 31 | ) { 32 | data class Reply( 33 | @SerializedName("content") 34 | val content: String, 35 | @SerializedName("ctime") 36 | val ctime: Int, 37 | @SerializedName("dynamic_id") 38 | val dynamicId: String, 39 | @SerializedName("like_num") 40 | val likeNum: Int, 41 | @SerializedName("m_name") 42 | val mName: String, 43 | @SerializedName("mid") 44 | val mid: Int, 45 | @SerializedName("oid") 46 | val oid: String, 47 | @SerializedName("origin_rpid") 48 | val originRpid: String, 49 | @SerializedName("rpid") 50 | val rpid: String, 51 | @SerializedName("similar_count") 52 | val similarCount: Int, 53 | @SerializedName("similar_like_sum") 54 | val similarLikeSum: Int, 55 | @SerializedName("type_id") 56 | val typeId: Int, 57 | 58 | var avatar: String = "" 59 | ) 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zhiwang/ZhiWangService.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zhiwang 2 | 3 | import retrofit2.http.Body 4 | import retrofit2.http.Headers 5 | import retrofit2.http.POST 6 | 7 | interface ZhiWangService { 8 | @POST("/v1/api/check") 9 | @Headers("Content-Type: application/json") 10 | suspend fun query(@Body content: Request) : Response 11 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zuowen/ZuowenContent.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zuowen 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class ZuowenContent( 7 | @SerializedName("htmlContent") 8 | val htmlContent: String 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zuowen/ZuowenPageSource.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zuowen 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import me.rerere.zhiwang.repo.ZuowenRepo 7 | 8 | private const val TAG = "ZuowenPageSource" 9 | 10 | class ZuowenPageSource( 11 | private val zuowenRepo: ZuowenRepo 12 | ) : PagingSource() { 13 | override fun getRefreshKey(state: PagingState): Int { 14 | return 0 15 | } 16 | 17 | override suspend fun load(params: LoadParams): LoadResult { 18 | val page = params.key ?: 0 19 | Log.i(TAG, "loading page: $page, ${params.loadSize}") 20 | val data = zuowenRepo.getZuowen(pageNum = page, pageSize = params.loadSize) 21 | return if (data != null) { 22 | LoadResult.Page( 23 | prevKey = if (page <= 0) null else page - 1, 24 | nextKey = if(data.articles.size < params.loadSize) null else page + 1, 25 | data = data.articles 26 | ) 27 | } else { 28 | LoadResult.Error( 29 | Exception("Failed to load zuowen list") 30 | ) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zuowen/ZuowenResponse.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zuowen 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class ZuowenResponse( 7 | @SerializedName("articles") 8 | val articles: List
, 9 | @SerializedName("count") 10 | val count: Int, 11 | @SerializedName("info") 12 | val info: String 13 | ) { 14 | data class Article( 15 | @SerializedName("author") 16 | val author: String, 17 | @SerializedName("_id") 18 | val id: String, 19 | @SerializedName("plainContent") 20 | val plainContent: String, 21 | @SerializedName("submissionTime") 22 | val submissionTime: Int, 23 | @SerializedName("tags") 24 | val tags: List, 25 | @SerializedName("title") 26 | val title: String 27 | ) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/api/zuowen/ZuowenService.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.api.zuowen 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Path 5 | import retrofit2.http.Query 6 | 7 | interface ZuowenService { 8 | @GET("/v/articles") 9 | suspend fun getZuowenList(@Query("pageNum") page: Int, @Query("pageSize") pageSize: Int) : ZuowenResponse 10 | 11 | @GET("/v/articles/{id}/html") 12 | suspend fun getZuowenContent(@Path("id") id: String) : ZuowenContent 13 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import me.rerere.zhiwang.api.bilibili.BilibiliUtil 8 | import me.rerere.zhiwang.api.zhiwang.ZhiWangService 9 | import me.rerere.zhiwang.api.zuowen.ZuowenService 10 | import me.rerere.zhiwang.repo.ZuowenRepo 11 | import me.rerere.zhiwang.util.net.UserAgentInterceptor 12 | import okhttp3.OkHttpClient 13 | import retrofit2.Retrofit 14 | import retrofit2.converter.gson.GsonConverterFactory 15 | import java.util.concurrent.TimeUnit 16 | import javax.inject.Qualifier 17 | import javax.inject.Singleton 18 | 19 | // User Agent 20 | private const val USER_AGENT = 21 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36" 22 | 23 | @Module 24 | @InstallIn(SingletonComponent::class) 25 | object NetworkModule { 26 | private const val TIMEOUT = 3000L 27 | 28 | @Provides 29 | @Singleton 30 | fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder() 31 | .connectTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 32 | .readTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 33 | .callTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 34 | .addInterceptor(UserAgentInterceptor(USER_AGENT)) 35 | .build() 36 | 37 | @Provides 38 | @Singleton 39 | fun provideBilibiliUtil(okHttpClient: OkHttpClient) = BilibiliUtil(okHttpClient) 40 | 41 | @ZhiwangRetrofit 42 | @Provides 43 | @Singleton 44 | fun provideZhiwangRetrofitClient(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder() 45 | .client(okHttpClient) 46 | .baseUrl("https://asoulcnki.asia") 47 | .addConverterFactory(GsonConverterFactory.create()) 48 | .build() 49 | 50 | @Provides 51 | @Singleton 52 | fun provideZhiwangService(@ZhiwangRetrofit retrofit: Retrofit): ZhiWangService = retrofit 53 | .create(ZhiWangService::class.java) 54 | 55 | @ZuowenRetrofit 56 | @Provides 57 | @Singleton 58 | fun provideZuowenRetrofit(okHttpClient: OkHttpClient) = Retrofit.Builder() 59 | .client(okHttpClient) 60 | .baseUrl("https://asoul.icu") 61 | .addConverterFactory(GsonConverterFactory.create()) 62 | .build() 63 | 64 | @Provides 65 | @Singleton 66 | fun provideZuowenService(@ZuowenRetrofit retrofit: Retrofit) : ZuowenService = retrofit 67 | .create(ZuowenService::class.java) 68 | 69 | @Provides 70 | @Singleton 71 | fun provideZhiwangRepo(zhiWangService: ZhiWangService, zuowenService: ZuowenService, bilibiliUtil: BilibiliUtil) = ZuowenRepo(zhiWangService, zuowenService, bilibiliUtil) 72 | } 73 | 74 | @Qualifier 75 | annotation class ZhiwangRetrofit 76 | 77 | @Qualifier 78 | annotation class ZuowenRetrofit -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/repo/WikiRepo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.repo 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import me.rerere.zhiwang.api.wiki.WikiUtil 6 | import javax.inject.Inject 7 | 8 | class WikiRepo @Inject constructor( 9 | private val wikiUtil: WikiUtil 10 | ) { 11 | suspend fun loadWiki() = withContext(Dispatchers.IO) { wikiUtil.loadWiki() } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/repo/ZuowenRepo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.repo 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import me.rerere.zhiwang.api.bilibili.BilibiliUtil 6 | import me.rerere.zhiwang.api.bilibili.SubResult 7 | import me.rerere.zhiwang.api.zhiwang.Request 8 | import me.rerere.zhiwang.api.zhiwang.ZhiWangService 9 | import me.rerere.zhiwang.api.zuowen.ZuowenService 10 | import me.rerere.zhiwang.util.net.autoRetry 11 | 12 | class ZuowenRepo( 13 | private val zhiWangService: ZhiWangService, 14 | private val zuowenService: ZuowenService, 15 | private val bilibiliUtil: BilibiliUtil 16 | ) { 17 | suspend fun query(content: Request) = withContext(Dispatchers.IO) { 18 | autoRetry { 19 | zhiWangService.query(content) 20 | }?.apply { 21 | println(this) 22 | 23 | // 获取用户信息 24 | this.data.related.forEach { 25 | try { 26 | val mid = it.reply.mid 27 | val bilibiliUser = bilibiliUtil.getUserInfo(mid) 28 | bilibiliUser?.let { user -> 29 | it.reply.avatar = user.data.face.replace("http:", "https:") 30 | println("头像: ${user.data.face}") 31 | } 32 | }catch (e: Exception){ 33 | e.printStackTrace() 34 | } 35 | } 36 | } 37 | } 38 | 39 | suspend fun getZuowen(pageNum: Int, pageSize: Int) = withContext(Dispatchers.IO) { 40 | try { 41 | zuowenService.getZuowenList(pageNum, pageSize) 42 | } catch (e: Exception){ 43 | e.printStackTrace() 44 | null 45 | } 46 | } 47 | 48 | suspend fun getZuowenContent(id: String) = withContext(Dispatchers.IO){ 49 | try { 50 | zuowenService.getZuowenContent(id) 51 | } catch (e: Exception){ 52 | e.printStackTrace() 53 | null 54 | } 55 | } 56 | 57 | suspend fun getAllSubLis(link: String) : SubResult? = withContext(Dispatchers.IO){ 58 | bilibiliUtil.getAllSubLis(link) 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/public/XiaoZuoWen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.public 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.widget.Toast 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.gestures.detectTapGestures 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.* 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.ThumbUp 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.input.pointer.pointerInput 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.unit.dp 23 | import androidx.constraintlayout.compose.ConstraintLayout 24 | import coil.compose.ImagePainter 25 | import coil.compose.rememberImagePainter 26 | import com.google.accompanist.placeholder.material.placeholder 27 | import me.rerere.zhiwang.api.zhiwang.Response 28 | import me.rerere.zhiwang.ui.theme.PINK 29 | import me.rerere.zhiwang.util.android.setClipboardText 30 | import java.text.SimpleDateFormat 31 | import java.util.* 32 | 33 | @Composable 34 | fun XiaoZuoWen(data: Response.Data.Related) { 35 | val context = LocalContext.current 36 | Card( 37 | modifier = Modifier 38 | .fillMaxWidth() 39 | .padding(8.dp) 40 | .pointerInput(Unit) { 41 | detectTapGestures( 42 | onLongPress = { 43 | context.setClipboardText(data.reply.content) 44 | Toast 45 | .makeText(context, "已复制该作文到剪贴板", Toast.LENGTH_SHORT) 46 | .show() 47 | }, 48 | onTap = { 49 | val intent = Intent( 50 | Intent.ACTION_VIEW, 51 | Uri.parse(data.replyUrl.trim()) 52 | ) 53 | context.startActivity(intent) 54 | } 55 | ) 56 | }, 57 | shape = RoundedCornerShape(4.dp), 58 | elevation = 4.dp 59 | ) { 60 | Column(Modifier.padding(12.dp)) { 61 | // 作者信息 62 | ConstraintLayout( 63 | modifier = Modifier 64 | .fillMaxWidth() 65 | .padding(vertical = 4.dp) 66 | ) { 67 | val (avatar, name, likes) = createRefs() 68 | val painter = rememberImagePainter(data.reply.avatar as? String) 69 | Box(modifier = Modifier 70 | .constrainAs(avatar) { 71 | start.linkTo(parent.start, 8.dp) 72 | top.linkTo(parent.top) 73 | } 74 | .size(40.dp) 75 | .clip(CircleShape) 76 | .placeholder(painter.state is ImagePainter.State.Loading) 77 | ) { 78 | Image( 79 | painter = painter, 80 | contentDescription = null, 81 | modifier = Modifier.fillMaxSize() 82 | ) 83 | } 84 | // 名字 85 | Text( 86 | modifier = Modifier.constrainAs(name) { 87 | start.linkTo(avatar.end, 8.dp) 88 | centerVerticallyTo(avatar) 89 | }, 90 | text = data.reply.mName, 91 | fontWeight = FontWeight.Bold, 92 | color = PINK 93 | ) 94 | // 点赞 95 | Row(modifier = Modifier.constrainAs(likes) { 96 | end.linkTo(parent.end, 8.dp) 97 | centerVerticallyTo(avatar) 98 | }, verticalAlignment = Alignment.CenterVertically) { 99 | Icon( 100 | modifier = Modifier.size(15.dp), 101 | imageVector = Icons.Default.ThumbUp, 102 | contentDescription = null 103 | ) 104 | Spacer(modifier = Modifier.width(3.dp)) 105 | Text(text = (data.reply.likeNum.toString())) 106 | } 107 | } 108 | // 作文内容 109 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) { 110 | Text(text = data.reply.content) 111 | } 112 | // 作文信息 113 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 114 | Row( 115 | modifier = Modifier.fillMaxWidth(), 116 | verticalAlignment = Alignment.CenterVertically 117 | ) { 118 | Text(text = "重复度: ${(data.rate * 100f).toInt().coerceAtMost(100)}%") 119 | Spacer(modifier = Modifier.width(8.dp)) 120 | Text(text = "日期: ${getDateTime((data.reply.ctime.toLong() * 1000L))}") 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | private val timeFormat = SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.CHINA) 128 | fun getDateTime(time: Long): String? { 129 | return try { 130 | return timeFormat.format(Date(time)) 131 | } catch (e: Exception) { 132 | e.toString() 133 | } 134 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/IndexScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.compose.animation.ExperimentalAnimationApi 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Explore 12 | import androidx.compose.material.icons.filled.FindInPage 13 | import androidx.compose.material.icons.filled.Info 14 | import androidx.compose.material.icons.filled.Public 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.rememberCoroutineScope 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.platform.LocalContext 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import androidx.navigation.NavController 22 | import com.google.accompanist.insets.LocalWindowInsets 23 | import com.google.accompanist.insets.navigationBarsPadding 24 | import com.google.accompanist.insets.rememberInsetsPaddingValues 25 | import com.google.accompanist.pager.ExperimentalPagerApi 26 | import com.google.accompanist.pager.HorizontalPager 27 | import com.google.accompanist.pager.rememberPagerState 28 | import kotlinx.coroutines.launch 29 | import me.rerere.zhiwang.ui.screen.index.page.AboutPage 30 | import me.rerere.zhiwang.ui.screen.index.page.Content 31 | import me.rerere.zhiwang.ui.screen.index.page.WikiPage 32 | import me.rerere.zhiwang.ui.screen.index.page.Zuowen 33 | import me.rerere.zhiwang.ui.theme.uiBackGroundColor 34 | import me.rerere.zhiwang.util.noRippleClickable 35 | 36 | val pages = mapOf( 37 | 0 to "小作文查重", 38 | 1 to "小作文库", 39 | 2 to "百科", 40 | 3 to "关于" 41 | ) 42 | 43 | @ExperimentalPagerApi 44 | @ExperimentalAnimationApi 45 | @Composable 46 | fun IndexScreen( 47 | navController: NavController, 48 | indexScreenVideoModel: IndexScreenVideoModel = hiltViewModel() 49 | ) { 50 | val scaffoldState = rememberScaffoldState() 51 | val pager = rememberPagerState() 52 | val coroutineScope = rememberCoroutineScope() 53 | Scaffold( 54 | scaffoldState = scaffoldState, 55 | topBar = { 56 | TopBar(indexScreenVideoModel, pages[pager.currentPage] ?: "") 57 | }, 58 | bottomBar = { 59 | BottomNavigation(modifier = Modifier.navigationBarsPadding(), backgroundColor = MaterialTheme.colors.uiBackGroundColor) { 60 | BottomNavigationItem( 61 | selected = pager.currentPage == 0, 62 | onClick = { coroutineScope.launch { pager.animateScrollToPage(0) } }, 63 | icon = { 64 | Icon(Icons.Default.FindInPage, null) 65 | }, label = { 66 | Text(text = pages[0] ?: "") 67 | }) 68 | BottomNavigationItem( 69 | selected = pager.currentPage == 1, 70 | onClick = { coroutineScope.launch { pager.animateScrollToPage(1) } }, 71 | icon = { 72 | Icon(Icons.Default.Public, null) 73 | }, label = { 74 | Text(text = pages[1] ?: "") 75 | }) 76 | BottomNavigationItem( 77 | selected = pager.currentPage == 2, 78 | onClick = { coroutineScope.launch { pager.animateScrollToPage(2) } }, 79 | icon = { 80 | Icon(Icons.Default.Explore, null) 81 | }, label = { 82 | Text(text = pages[2] ?: "") 83 | }) 84 | BottomNavigationItem( 85 | selected = pager.currentPage == 3, 86 | onClick = { coroutineScope.launch { pager.animateScrollToPage(3) } }, 87 | icon = { 88 | Icon(Icons.Default.Info, null) 89 | }, label = { 90 | Text(text = pages[3] ?: "") 91 | }) 92 | } 93 | } 94 | ) { 95 | Box(modifier = Modifier.padding(it)) { 96 | HorizontalPager(modifier = Modifier.fillMaxSize(), state = pager, count = 4) { 97 | when (it) { 98 | 0 -> { 99 | Box(modifier = Modifier.fillMaxSize()) { 100 | Content( 101 | indexScreenVideoModel = indexScreenVideoModel, 102 | scaffoldState = scaffoldState 103 | ) 104 | } 105 | } 106 | 1 -> { 107 | Box(modifier = Modifier.fillMaxSize()) { 108 | Zuowen( 109 | indexScreenVideoModel = indexScreenVideoModel, 110 | navController = navController 111 | ) 112 | } 113 | } 114 | 2 -> { 115 | Box(modifier = Modifier.fillMaxSize()) { 116 | WikiPage( 117 | navController = navController, 118 | indexViewModel = indexScreenVideoModel 119 | ) 120 | } 121 | } 122 | 3 -> { 123 | Box(modifier = Modifier.fillMaxSize()) { 124 | AboutPage() 125 | } 126 | } 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | 134 | @Composable 135 | private fun TopBar(indexScreenVideoModel: IndexScreenVideoModel, title: String) { 136 | val context = LocalContext.current 137 | com.google.accompanist.insets.ui.TopAppBar( 138 | title = { 139 | Text(text = title) 140 | }, 141 | actions = { 142 | if (indexScreenVideoModel.foundUpdate) { 143 | Text(text = "APP有更新", modifier = Modifier 144 | .noRippleClickable { 145 | val intent = Intent( 146 | Intent.ACTION_VIEW, 147 | Uri.parse("https://github.com/jiangdashao/ASoulZhiWang/releases/latest") 148 | ) 149 | context.startActivity(intent) 150 | } 151 | .padding(horizontal = 16.dp)) 152 | } 153 | }, 154 | contentPadding = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.statusBars, applyBottom = false) 155 | ) 156 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/IndexScreenVideoModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.MutableLiveData 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import androidx.paging.Pager 11 | import androidx.paging.PagingConfig 12 | import androidx.paging.cachedIn 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import kotlinx.coroutines.launch 15 | import me.rerere.zhiwang.AppContext 16 | import me.rerere.zhiwang.api.bilibili.SubUser 17 | import me.rerere.zhiwang.api.wiki.WikiList 18 | import me.rerere.zhiwang.api.zhiwang.Request 19 | import me.rerere.zhiwang.api.zhiwang.Response 20 | import me.rerere.zhiwang.api.zuowen.ZuowenPageSource 21 | import me.rerere.zhiwang.repo.WikiRepo 22 | import me.rerere.zhiwang.repo.ZuowenRepo 23 | import me.rerere.zhiwang.util.checkUpdate 24 | import javax.inject.Inject 25 | 26 | @HiltViewModel 27 | class IndexScreenVideoModel @Inject constructor( 28 | private val zhiwangRepo: ZuowenRepo, 29 | private val wikiRepo: WikiRepo 30 | ) : ViewModel() { 31 | // 查重 32 | var loading by mutableStateOf(false) 33 | var content by mutableStateOf("") 34 | val queryResult = MutableLiveData() 35 | var error by mutableStateOf(false) 36 | var lastQuery by mutableStateOf(0L) 37 | 38 | 39 | // 找到更新 40 | var foundUpdate by mutableStateOf(false) 41 | 42 | init { 43 | // 检查更新 44 | viewModelScope.launch { 45 | foundUpdate = checkUpdate(AppContext.appContext) 46 | } 47 | } 48 | 49 | // 小作文 50 | val pager = Pager( 51 | config = PagingConfig( 52 | pageSize = 32, 53 | prefetchDistance = 1, 54 | initialLoadSize = 32 55 | ) 56 | ) { 57 | ZuowenPageSource(zhiwangRepo) 58 | }.flow.cachedIn(viewModelScope) 59 | 60 | // 查成分 61 | var profileLink by mutableStateOf("") 62 | var cfLoading by mutableStateOf(false) 63 | var cfError by mutableStateOf(false) 64 | var name by mutableStateOf("") 65 | var sublist by mutableStateOf(emptySet()) 66 | 67 | // WIKI 68 | var wikiList by mutableStateOf(emptyList()) 69 | var wikiLoading by mutableStateOf(false) 70 | var wikiError by mutableStateOf(false) 71 | 72 | init { 73 | loadWiki() 74 | } 75 | 76 | fun loadWiki() { 77 | viewModelScope.launch { 78 | wikiLoading = true 79 | wikiError = false 80 | 81 | val result = wikiRepo.loadWiki() 82 | result?.let { 83 | wikiList = it 84 | println("Loaded ${wikiList.size} wiki items") 85 | } ?: kotlin.run { 86 | wikiError = true 87 | } 88 | 89 | wikiLoading = false 90 | } 91 | } 92 | 93 | fun chaChengFen(){ 94 | viewModelScope.launch { 95 | cfLoading = true 96 | cfError = false 97 | 98 | val result = zhiwangRepo.getAllSubLis(profileLink) 99 | result?.let { 100 | name = it.name 101 | sublist = it.subList 102 | } ?: kotlin.run { 103 | cfError = true 104 | } 105 | 106 | cfLoading = false 107 | } 108 | } 109 | 110 | fun query() { 111 | lastQuery = System.currentTimeMillis() 112 | viewModelScope.launch { 113 | loading = true 114 | error = false 115 | 116 | // 查重 117 | val result = zhiwangRepo.query(Request(content)) 118 | queryResult.value = result 119 | 120 | loading = false 121 | if (queryResult.value == null) error = true 122 | } 123 | } 124 | 125 | fun resetResult() { 126 | queryResult.value = null 127 | } 128 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/page/About.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index.page 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.rememberScrollState 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.Card 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.layout.ContentScale 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.unit.sp 18 | import me.rerere.zhiwang.R 19 | 20 | @Composable 21 | fun AboutPage() { 22 | Column( 23 | modifier = Modifier 24 | .padding(16.dp) 25 | .verticalScroll(state = rememberScrollState()) 26 | ) { 27 | Card { 28 | Column(Modifier.padding(16.dp)) { 29 | Image( 30 | modifier = Modifier 31 | .fillMaxWidth() 32 | .height(250.dp) 33 | .clip(RoundedCornerShape(4.dp)), 34 | painter = painterResource(R.drawable.asoul), 35 | contentDescription = null, 36 | contentScale = ContentScale.FillWidth 37 | ) 38 | Text(text = "ASoul简介", fontWeight = FontWeight.Bold, fontSize = 20.sp) 39 | Text(text = "A-SOUL是乐华娱乐年度最新企划中打造的虚拟偶像女团,成员由向晚(Ava)、贝拉(Bella)、珈乐(Carol)、嘉然(Diana)、乃琳(Eileen)五人组成,于2020年11月以“乐华娱乐首个虚拟偶像团体”名义出道。") 40 | } 41 | } 42 | Spacer(modifier = Modifier.height(30.dp)) 43 | Card { 44 | Column(Modifier.padding(16.dp)) { 45 | Text(text = "本APP简介", fontWeight = FontWeight.Bold, fontSize = 20.sp) 46 | Text(text = "项目地址: https://github.com/jiangdashao/asoulzhiwang") 47 | Text(text = "非常感谢:", fontWeight = FontWeight.Bold, fontSize = 20.sp) 48 | Text(text = "查重API: https://asoulcnki.asia/") 49 | Text(text = "小作文库: https://asoul.icu/") 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/page/Chachong.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index.page 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | import android.widget.Toast 7 | import androidx.activity.compose.BackHandler 8 | import androidx.compose.animation.ExperimentalAnimationApi 9 | import androidx.compose.animation.animateContentSize 10 | import androidx.compose.foundation.background 11 | import androidx.compose.foundation.layout.* 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.foundation.verticalScroll 15 | import androidx.compose.material.* 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.filled.Clear 18 | import androidx.compose.material.icons.filled.ContentPaste 19 | import androidx.compose.runtime.* 20 | import androidx.compose.runtime.livedata.observeAsState 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.platform.LocalContext 25 | import androidx.compose.ui.platform.LocalFocusManager 26 | import androidx.compose.ui.text.font.FontWeight 27 | import androidx.compose.ui.text.style.TextAlign 28 | import androidx.compose.ui.unit.dp 29 | import androidx.compose.ui.unit.sp 30 | import com.google.accompanist.placeholder.PlaceholderHighlight 31 | import com.google.accompanist.placeholder.material.placeholder 32 | import com.google.accompanist.placeholder.material.shimmer 33 | import me.rerere.zhiwang.ui.public.XiaoZuoWen 34 | import me.rerere.zhiwang.ui.public.getDateTime 35 | import me.rerere.zhiwang.ui.screen.index.IndexScreenVideoModel 36 | import me.rerere.zhiwang.util.android.getClipboardContent 37 | import me.rerere.zhiwang.util.format.format 38 | import me.rerere.zhiwang.util.noRippleClickable 39 | 40 | @ExperimentalAnimationApi 41 | @Composable 42 | fun Content(indexScreenVideoModel: IndexScreenVideoModel, scaffoldState: ScaffoldState) { 43 | // val coroutineScope = rememberCoroutineScope() 44 | val response by indexScreenVideoModel.queryResult.observeAsState() 45 | var error by remember { 46 | mutableStateOf(false) 47 | } 48 | val context = LocalContext.current 49 | 50 | // 返回处理 51 | // 显示了查重结果的时候点击返回键会清空查重结果,方便重新查重 52 | BackHandler(response != null) { 53 | indexScreenVideoModel.resetResult() 54 | } 55 | 56 | // 查重页面 57 | Column( 58 | modifier = Modifier 59 | .fillMaxWidth() 60 | .verticalScroll(rememberScrollState()) 61 | ) { 62 | // 输入框 63 | Box(contentAlignment = Alignment.BottomEnd) { 64 | TextField( 65 | modifier = Modifier 66 | .fillMaxWidth() 67 | .animateContentSize() 68 | .let { 69 | if (response == null) { 70 | it.height(200.dp) 71 | } else { 72 | it.wrapContentHeight() 73 | } 74 | } 75 | .padding(16.dp), 76 | value = indexScreenVideoModel.content, 77 | onValueChange = { 78 | if (it.length >= 10) { 79 | error = false 80 | } 81 | indexScreenVideoModel.content = it 82 | }, 83 | label = { 84 | Text(text = "输入要查重的小作文, 至少10个字哦") 85 | }, 86 | colors = TextFieldDefaults.textFieldColors( 87 | focusedIndicatorColor = Color.Transparent, 88 | unfocusedIndicatorColor = Color.Transparent 89 | ), 90 | shape = RoundedCornerShape(5.dp), 91 | isError = error, 92 | maxLines = if (response == null) 8 else 1 93 | ) 94 | 95 | // 输入框上的按钮 96 | Row( 97 | modifier = Modifier 98 | .padding(20.dp), 99 | verticalAlignment = Alignment.CenterVertically 100 | ) { 101 | // 从剪贴板粘贴 102 | androidx.compose.animation.AnimatedVisibility(visible = indexScreenVideoModel.queryResult.value == null) { 103 | Icon(modifier = Modifier.noRippleClickable { 104 | val text = context.getClipboardContent() 105 | text?.let { 106 | indexScreenVideoModel.content = it 107 | } ?: kotlin.run { 108 | Toast.makeText(context, "剪贴板没有内容", Toast.LENGTH_SHORT).show() 109 | } 110 | }, imageVector = Icons.Default.ContentPaste, contentDescription = null) 111 | } 112 | 113 | Spacer(modifier = Modifier.width(4.dp)) 114 | 115 | // 清空 116 | androidx.compose.animation.AnimatedVisibility(visible = indexScreenVideoModel.content.isNotEmpty()) { 117 | Icon(modifier = Modifier.noRippleClickable { 118 | indexScreenVideoModel.content = "" 119 | indexScreenVideoModel.queryResult.value = null 120 | }, imageVector = Icons.Default.Clear, contentDescription = null) 121 | } 122 | } 123 | } 124 | val focusManager = LocalFocusManager.current 125 | 126 | // 查重按钮 127 | Button( 128 | modifier = Modifier 129 | .fillMaxWidth() 130 | .padding(horizontal = 16.dp, vertical = 4.dp), 131 | onClick = { 132 | focusManager.clearFocus() 133 | 134 | if (indexScreenVideoModel.content.length < 10) { 135 | // 小作文长度不够 136 | error = true 137 | Toast.makeText(context, "小作文至少需要10个字哦", Toast.LENGTH_SHORT).show() 138 | } else if (System.currentTimeMillis() - indexScreenVideoModel.lastQuery <= 5000L) { 139 | Toast.makeText(context, "请等待 5 秒再查重哦!", Toast.LENGTH_SHORT).show() 140 | } else { 141 | // 开始查询 142 | indexScreenVideoModel.resetResult() 143 | indexScreenVideoModel.query() 144 | } 145 | }) { 146 | Text(text = "立即查重捏 🤤") 147 | } 148 | 149 | // 加载动画 150 | if (indexScreenVideoModel.loading) { 151 | val width = listOf(0.9f, 1f, 0.87f, 0.83f, 0.89f, 0.86f) 152 | repeat(6) { 153 | Spacer( 154 | modifier = Modifier 155 | .fillMaxWidth(width[it]) 156 | .height(90.dp) 157 | .padding(16.dp) 158 | .placeholder(visible = true, highlight = PlaceholderHighlight.shimmer()) 159 | ) 160 | } 161 | } 162 | 163 | // 加载错误 164 | if (indexScreenVideoModel.error) { 165 | Box( 166 | modifier = Modifier 167 | .fillMaxWidth() 168 | .height(120.dp), contentAlignment = Alignment.Center 169 | ) { 170 | Column { 171 | Text(text = "加载错误!😨", fontWeight = FontWeight.Bold) 172 | Text(text = "请检查你的网络连接,或者可能是查重服务器维护中") 173 | } 174 | } 175 | } 176 | 177 | // 结果 178 | response?.let { 179 | when (it.code) { 180 | 0 -> { 181 | Box( 182 | modifier = Modifier 183 | .fillMaxWidth() 184 | .padding(16.dp), 185 | //elevation = 4.dp 186 | ) { 187 | Column( 188 | modifier = Modifier.padding(16.dp), 189 | horizontalAlignment = Alignment.CenterHorizontally 190 | ) { 191 | Row( 192 | Modifier.fillMaxWidth(), 193 | verticalAlignment = Alignment.CenterVertically 194 | ) { 195 | Text( 196 | text = "总文字复制比: ${(it.data.rate * 100).format()}%", 197 | fontWeight = FontWeight.Bold, 198 | fontSize = 23.sp, 199 | modifier = Modifier.padding(4.dp), 200 | color = MaterialTheme.colors.secondary 201 | ) 202 | CircularProgressIndicator( 203 | modifier = Modifier 204 | .padding(horizontal = 16.dp) 205 | .size(25.dp), 206 | progress = it.data.rate.toFloat() 207 | ) 208 | } 209 | Spacer(modifier = Modifier.height(8.dp)) 210 | // "复制查重结果"按钮 211 | OutlinedButton(modifier = Modifier.fillMaxWidth(), onClick = { 212 | val clipboardManager = 213 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 214 | clipboardManager.setPrimaryClip( 215 | ClipData.newPlainText( 216 | null, """ 217 | 枝网文本复制检测报告(APP版) 218 | 查重时间: ${getDateTime(System.currentTimeMillis())} 219 | 总文字复制比: ${(it.data.rate * 100).format()}% 220 | 相似小作文: ${it.data.related[0].replyUrl} 221 | 作者: ${it.data.related[0].reply.mName} 222 | 发表时间: ${getDateTime(it.data.related[0].reply.ctime.toLong() * 1000L)} 223 | 224 | 查重结果仅作参考,请注意辨别是否为原创 225 | """.trimIndent() 226 | ) 227 | ) 228 | Toast.makeText(context, "已复制到剪贴板", Toast.LENGTH_SHORT).show() 229 | }) { 230 | Text(text = "点击复制查重结果") 231 | } 232 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 233 | // 防止某些乱复制的 234 | Text(text = "请自行判别是否原创,请勿到处刷查重报告") 235 | Text(text = "A友们都是有素质的人捏") 236 | } 237 | } 238 | } 239 | // 查重结果概述 240 | Text( 241 | text = "相似小作文: (${it.data.related.size}篇)", 242 | fontWeight = FontWeight.Bold, 243 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) 244 | ) 245 | // 相似小作文列表 246 | 247 | it.data.related.forEach { zuowen -> 248 | XiaoZuoWen(zuowen) 249 | } 250 | } 251 | 4003 -> { 252 | Text(text = "服务器内部错误") 253 | } 254 | } 255 | } 256 | 257 | Spacer( 258 | modifier = Modifier 259 | .padding(vertical = 8.dp) 260 | .fillMaxWidth() 261 | .height(0.5.dp) 262 | .background(Color.Gray) 263 | ) 264 | Text( 265 | modifier = Modifier.fillMaxWidth(), 266 | text = "数据来源于: https://asoulcnki.asia/", 267 | textAlign = TextAlign.Center 268 | ) 269 | Text( 270 | modifier = Modifier.fillMaxWidth(), 271 | text = "目前仅收录了官方账号下面的小作文,二创下的小作文并未收录,所以查重结果仅供参考哦", 272 | textAlign = TextAlign.Center 273 | ) 274 | } 275 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/page/MemeWiki.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index.page 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.material.Card 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.unit.sp 18 | import androidx.navigation.NavController 19 | import com.google.accompanist.placeholder.PlaceholderHighlight 20 | import com.google.accompanist.placeholder.material.placeholder 21 | import com.google.accompanist.placeholder.material.shimmer 22 | import com.google.accompanist.swiperefresh.SwipeRefresh 23 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 24 | import me.rerere.zhiwang.api.wiki.WikiList 25 | import me.rerere.zhiwang.ui.screen.index.IndexScreenVideoModel 26 | import me.rerere.zhiwang.util.noRippleClickable 27 | 28 | @Composable 29 | fun WikiPage(navController: NavController, indexViewModel: IndexScreenVideoModel) { 30 | val context = LocalContext.current 31 | when { 32 | indexViewModel.wikiLoading -> { 33 | Column { 34 | repeat(7) { 35 | Box( 36 | modifier = Modifier 37 | .padding(16.dp) 38 | .fillMaxWidth() 39 | .height(70.dp) 40 | .placeholder(visible = true, highlight = PlaceholderHighlight.shimmer()) 41 | ) 42 | } 43 | } 44 | } 45 | indexViewModel.wikiError -> { 46 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 47 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 48 | Text( 49 | text = "加载失败,点击重新加载", 50 | fontWeight = FontWeight.Bold, 51 | modifier = Modifier.noRippleClickable { 52 | indexViewModel.loadWiki() 53 | }) 54 | } 55 | } 56 | } 57 | else -> { 58 | SwipeRefresh(state = rememberSwipeRefreshState(isRefreshing = indexViewModel.wikiLoading), onRefresh = { indexViewModel.loadWiki() }) { 59 | LazyColumn(Modifier.fillMaxSize()) { 60 | item { 61 | Card( 62 | modifier = Modifier 63 | .fillMaxWidth() 64 | .padding(16.dp) 65 | .clickable { 66 | val intent = Intent( 67 | Intent.ACTION_VIEW, 68 | Uri.parse(("https://github.com/jiangdashao/asoulzhiwang").trim()) 69 | ) 70 | context.startActivity(intent) 71 | }, 72 | ) { 73 | Column(Modifier.padding(16.dp)) { 74 | Text(text = "声明", fontWeight = FontWeight.Bold, fontSize = 20.sp) 75 | Spacer(modifier = Modifier.height(10.dp)) 76 | Text(text = "本WIKI中许多梗介绍来自 深紫色的白") 77 | Text(text = "如果你想添加梗介绍,请前往github提交对 wiki.json 的PR") 78 | Text(text = "https://github.com/jiangdashao/asoulzhiwang") 79 | } 80 | } 81 | } 82 | items(indexViewModel.wikiList){ 83 | WikiItem(it) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | @Composable 92 | private fun WikiItem(wikiListItem: WikiList.WikiListItem) { 93 | Card( 94 | modifier = Modifier 95 | .fillMaxWidth() 96 | .padding(16.dp), 97 | ) { 98 | Column(Modifier.padding(16.dp)) { 99 | Text(text = wikiListItem.title, fontWeight = FontWeight.Bold, fontSize = 20.sp) 100 | Spacer(modifier = Modifier.height(10.dp)) 101 | Text(text = wikiListItem.description) 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/index/page/Zuowen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.index.page 2 | 3 | import android.util.Log 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.material.* 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.CompositionLocalProvider 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.unit.sp 18 | import androidx.navigation.NavController 19 | import androidx.navigation.NavType 20 | import androidx.navigation.Navigator 21 | import androidx.paging.LoadState 22 | import androidx.paging.compose.collectAsLazyPagingItems 23 | import androidx.paging.compose.items 24 | import com.google.accompanist.placeholder.PlaceholderHighlight 25 | import com.google.accompanist.placeholder.material.fade 26 | import com.google.accompanist.placeholder.material.placeholder 27 | import com.google.accompanist.swiperefresh.SwipeRefresh 28 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 29 | import me.rerere.zhiwang.api.zuowen.ZuowenResponse 30 | import me.rerere.zhiwang.ui.screen.index.IndexScreenVideoModel 31 | import me.rerere.zhiwang.util.noRippleClickable 32 | import java.lang.StringBuilder 33 | 34 | @Composable 35 | fun Zuowen(indexScreenVideoModel: IndexScreenVideoModel, navController: NavController) { 36 | val articleList = indexScreenVideoModel.pager.collectAsLazyPagingItems() 37 | SwipeRefresh( 38 | state = rememberSwipeRefreshState(articleList.loadState.refresh == LoadState.Loading), 39 | onRefresh = { articleList.refresh() }) { 40 | LazyColumn(Modifier.fillMaxSize()) { 41 | if(articleList.loadState.refresh is LoadState.Error) { 42 | item { 43 | Column(modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { 44 | Text(text = "加载失败,下拉重试 😱") 45 | } 46 | } 47 | } 48 | 49 | if(articleList.loadState.refresh == LoadState.Loading && articleList.itemCount == 0){ 50 | repeat(10){ 51 | item { 52 | Box( 53 | modifier = Modifier 54 | .padding(16.dp) 55 | .fillMaxWidth() 56 | .height(70.dp) 57 | .placeholder(visible = true, highlight = PlaceholderHighlight.fade()) 58 | ) 59 | } 60 | } 61 | } 62 | 63 | items(articleList) { 64 | Article(it!!, navController) 65 | } 66 | 67 | when (articleList.loadState.append) { 68 | LoadState.Loading -> { 69 | item { 70 | Box( 71 | modifier = Modifier.fillMaxWidth(), 72 | contentAlignment = Alignment.Center 73 | ) { 74 | CircularProgressIndicator() 75 | } 76 | } 77 | } 78 | is LoadState.Error -> { 79 | item { 80 | Box( 81 | modifier = Modifier 82 | .fillMaxWidth() 83 | .noRippleClickable { 84 | articleList.retry() 85 | }, contentAlignment = Alignment.Center 86 | ) { 87 | Text(text = "加载更多页面失败,点击重试!", fontWeight = FontWeight.Bold) 88 | } 89 | } 90 | } 91 | else -> {} 92 | } 93 | } 94 | } 95 | } 96 | 97 | @Composable 98 | private fun Article(article: ZuowenResponse.Article, navController: NavController) { 99 | val context = LocalContext.current 100 | Card( 101 | modifier = Modifier 102 | .fillMaxWidth() 103 | .padding(16.dp) 104 | .clickable { 105 | navController.navigate("zuowen?id=${article.id}&title=${article.title}&author=${article.author}&tags=${article.tags.joinToString(",")}") 106 | }, 107 | elevation = 4.dp 108 | ) { 109 | Column(Modifier.padding(16.dp)) { 110 | // 标题 111 | Text(text = article.title, fontWeight = FontWeight.Bold, fontSize = 20.sp) 112 | // 作者 113 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 114 | Text(text = article.author) 115 | } 116 | // 分割线 117 | Spacer( 118 | modifier = Modifier 119 | .padding(horizontal = 4.dp, vertical = 4.dp) 120 | .fillMaxWidth() 121 | .height(0.5.dp) 122 | .background(Color.LightGray) 123 | ) 124 | // 内容 125 | Text(text = article.plainContent) 126 | // 分割线 127 | Spacer( 128 | modifier = Modifier 129 | .padding(horizontal = 4.dp, vertical = 4.dp) 130 | .fillMaxWidth() 131 | .height(0.5.dp) 132 | .background(Color.LightGray) 133 | ) 134 | // 标签 135 | Row { 136 | article.tags.forEach { 137 | Text( 138 | text = it, 139 | color = MaterialTheme.colors.secondary, 140 | modifier = Modifier 141 | .padding(vertical = 2.dp, horizontal = 4.dp) 142 | ) 143 | } 144 | } 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/zuowen/ZuowenScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.zuowen 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | import android.util.Log 7 | import android.widget.Toast 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.text.selection.SelectionContainer 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.Icon 14 | import androidx.compose.material.IconButton 15 | import androidx.compose.material.Scaffold 16 | import androidx.compose.material.Text 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.filled.ArrowBack 19 | import androidx.compose.material.icons.filled.ContentCopy 20 | import androidx.compose.material.icons.filled.FindReplace 21 | import androidx.compose.runtime.* 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.platform.LocalContext 26 | import androidx.compose.ui.text.font.FontWeight 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.unit.sp 29 | import androidx.hilt.navigation.compose.hiltViewModel 30 | import androidx.navigation.NavController 31 | import com.google.accompanist.insets.LocalWindowInsets 32 | import com.google.accompanist.insets.navigationBarsPadding 33 | import com.google.accompanist.insets.rememberInsetsPaddingValues 34 | import com.google.accompanist.insets.ui.TopAppBar 35 | import com.google.accompanist.placeholder.PlaceholderHighlight 36 | import com.google.accompanist.placeholder.material.placeholder 37 | import com.google.accompanist.placeholder.material.shimmer 38 | import com.vanpra.composematerialdialogs.MaterialDialog 39 | import com.vanpra.composematerialdialogs.listItemsSingleChoice 40 | import com.vanpra.composematerialdialogs.rememberMaterialDialogState 41 | import com.vanpra.composematerialdialogs.title 42 | import me.rerere.zhiwang.util.MEMBERS 43 | import me.rerere.zhiwang.util.replaceASoulMemberName 44 | 45 | private const val TAG = "ZuowenScreen" 46 | 47 | @Composable 48 | fun ZuowenScreen( 49 | navController: NavController, 50 | id: String, 51 | title: String, 52 | author: String, 53 | tags: List, 54 | zuowenViewModel: ZuowenViewModel = hiltViewModel() 55 | ) { 56 | val context = LocalContext.current 57 | LaunchedEffect(Unit) { 58 | Log.i(TAG, "ZuowenScreen: Load = $id, $title, $author") 59 | zuowenViewModel.load(id, title, author) 60 | } 61 | val replaceDialog = rememberMaterialDialogState() 62 | var replaceSelection by remember { 63 | mutableStateOf(0) 64 | } 65 | MaterialDialog( 66 | dialogState = replaceDialog, 67 | buttons = { 68 | button("复制") { 69 | replaceDialog.hide() 70 | val clipboardManager = 71 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 72 | clipboardManager.setPrimaryClip( 73 | ClipData.newPlainText( 74 | null, 75 | zuowenViewModel.content.replaceASoulMemberName(tags[0], MEMBERS[replaceSelection]) 76 | ) 77 | ) 78 | Toast.makeText(context, "已复制到剪贴板", Toast.LENGTH_SHORT).show() 79 | } 80 | } 81 | ) { 82 | title("快速替换小作文的主语并复制到剪贴板") 83 | listItemsSingleChoice( 84 | list = MEMBERS, 85 | initialSelection = replaceSelection, 86 | onChoiceChange = { 87 | replaceSelection = it 88 | }, 89 | waitForPositiveButton = false 90 | ) 91 | } 92 | 93 | Scaffold( 94 | topBar = { 95 | TopAppBar( 96 | contentPadding = rememberInsetsPaddingValues(insets = LocalWindowInsets.current.statusBars, applyBottom = false), 97 | navigationIcon = { 98 | IconButton(onClick = { navController.popBackStack() }) { 99 | Icon(Icons.Default.ArrowBack, null) 100 | } 101 | }, 102 | title = { 103 | Text(text = "阅读小作文") 104 | }, 105 | actions = { 106 | // 直接复制小作文 107 | IconButton(onClick = { 108 | if (!zuowenViewModel.error && !zuowenViewModel.loading) { 109 | val clipboardManager = 110 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 111 | clipboardManager.setPrimaryClip( 112 | ClipData.newPlainText( 113 | null, zuowenViewModel.content 114 | ) 115 | ) 116 | Toast.makeText(context, "已复制到剪贴板", Toast.LENGTH_SHORT).show() 117 | } 118 | }) { 119 | Icon(Icons.Default.ContentCopy, null) 120 | } 121 | 122 | if (tags.size == 1) { 123 | // 小作文主语替换复制 124 | IconButton(onClick = { 125 | replaceDialog.show() 126 | }) { 127 | Icon(Icons.Default.FindReplace, null) 128 | } 129 | } 130 | } 131 | ) 132 | } 133 | ) { 134 | Box( 135 | modifier = Modifier 136 | .fillMaxSize() 137 | .padding(it) 138 | .navigationBarsPadding() 139 | ) { 140 | ZuowenContent(zuowenViewModel) 141 | } 142 | } 143 | } 144 | 145 | @Composable 146 | private fun ZuowenContent(zuowenViewModel: ZuowenViewModel) { 147 | when { 148 | // 加载中 149 | zuowenViewModel.loading -> { 150 | Column(Modifier.fillMaxWidth()) { 151 | repeat(8) { 152 | Box( 153 | modifier = Modifier 154 | .padding(16.dp) 155 | .fillMaxWidth() 156 | .height(70.dp) 157 | .placeholder(visible = true, highlight = PlaceholderHighlight.shimmer()) 158 | ) 159 | } 160 | } 161 | } 162 | // 加载错误 163 | zuowenViewModel.error -> { 164 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 165 | Text(text = "加载失败 😨", fontWeight = FontWeight.Bold) 166 | } 167 | } 168 | // 加载完成 169 | else -> { 170 | ArticleContent(zuowenViewModel) 171 | } 172 | } 173 | } 174 | 175 | @Composable 176 | private fun ArticleContent(zuowenViewModel: ZuowenViewModel) { 177 | Column( 178 | Modifier 179 | .padding(16.dp) 180 | .verticalScroll(rememberScrollState()) 181 | ) { 182 | Text(text = zuowenViewModel.title, fontSize = 25.sp, fontWeight = FontWeight.Bold) 183 | Text(text = zuowenViewModel.author) 184 | Spacer(modifier = Modifier.height(10.dp)) 185 | Spacer( 186 | modifier = Modifier 187 | .padding(horizontal = 4.dp, vertical = 4.dp) 188 | .fillMaxWidth() 189 | .height(0.5.dp) 190 | .background(Color.LightGray) 191 | ) 192 | // 显示作文内容 193 | SelectionContainer { 194 | Column { 195 | zuowenViewModel.content.split("\n").forEach { 196 | Box(Modifier.padding(vertical = 4.dp)) { 197 | Text(text = " $it") 198 | } 199 | } 200 | } 201 | } 202 | } 203 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/screen/zuowen/ZuowenViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.screen.zuowen 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.launch 10 | import me.rerere.zhiwang.repo.ZuowenRepo 11 | import me.rerere.zhiwang.util.format.HtmlFormatUtil 12 | import org.jsoup.Jsoup 13 | import javax.inject.Inject 14 | 15 | 16 | @HiltViewModel 17 | class ZuowenViewModel @Inject constructor( 18 | private val zuowenRepo: ZuowenRepo 19 | ) : ViewModel() { 20 | var loading by mutableStateOf(false) 21 | var error by mutableStateOf(false) 22 | 23 | // article info 24 | var id by mutableStateOf("") 25 | var title by mutableStateOf("") 26 | var author by mutableStateOf("") 27 | var content by mutableStateOf("") 28 | 29 | // 加载小作文详细内容 30 | fun load(id: String, title: String, author: String) { 31 | 32 | this.id = id 33 | this.title = title 34 | this.author = author 35 | 36 | viewModelScope.launch { 37 | loading = true 38 | error = false 39 | val content = zuowenRepo.getZuowenContent(id) 40 | content?.let { 41 | // 利用JSOUP移除HTML元素 42 | this@ZuowenViewModel.content = 43 | HtmlFormatUtil.getPlainText(Jsoup.parse(it.htmlContent).root()) 44 | ?.replace(regex = Regex("[\\r\\n]+"), replacement = "\n") 45 | ?.trim() 46 | ?: "解析错误" 47 | } ?: kotlin.run { 48 | // 加载错误 49 | error = true 50 | println("加载小作文内容失败") 51 | } 52 | 53 | loading = false 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val PINK = Color(0xfff45a8d) 6 | val BACKGROUND = Color(0xFFF2F3F5) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material.Colors 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.darkColors 8 | import androidx.compose.material.lightColors 9 | import androidx.compose.material3.* 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorPalette = darkColors( 15 | primary = Color(0xff05aa85), 16 | secondary = Color(0xffaa0529) 17 | ) 18 | 19 | private val LightColorPalette = lightColors( 20 | primary = Color(0xff05aa85), 21 | secondary = Color(0xffaa0529) 22 | ) 23 | 24 | val Colors.uiBackGroundColor 25 | get() = if(isLight){ 26 | Color.White 27 | } else { 28 | Color.Black 29 | } 30 | 31 | @Composable 32 | fun md3Color( 33 | darkTheme: Boolean 34 | ): ColorScheme { 35 | val context = LocalContext.current 36 | return if (darkTheme) { 37 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 38 | dynamicDarkColorScheme(context) 39 | } else { 40 | darkColorScheme() 41 | } 42 | } else { 43 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 44 | dynamicLightColorScheme(context) 45 | }else { 46 | lightColorScheme() 47 | } 48 | } 49 | } 50 | 51 | @Composable 52 | fun ZhiWangTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { 53 | val colors = if (darkTheme) { 54 | DarkColorPalette 55 | } else { 56 | LightColorPalette 57 | } 58 | androidx.compose.material3.MaterialTheme( 59 | colorScheme = md3Color(darkTheme) 60 | ) { 61 | MaterialTheme( 62 | colors = colors, 63 | typography = Typography, 64 | shapes = Shapes, 65 | content = content 66 | ) 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/ModifierEx.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.composed 9 | 10 | @Composable 11 | inline fun Modifier.noRippleClickable(crossinline onClick: ()->Unit): Modifier = composed { 12 | clickable(indication = null, 13 | interactionSource = remember { MutableInteractionSource() }) { 14 | onClick() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/ReplaceUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util 2 | 3 | val MEMBERS = listOf( 4 | "嘉然", 5 | "向晚", 6 | "乃琳", 7 | "珈乐", 8 | "贝拉" 9 | ) 10 | 11 | fun String.replaceASoulMemberName(fromName: String, toName: String): String { 12 | return replace(fromName, toName) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/UpdateChecker.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageInfo 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | import android.util.Log 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.withContext 10 | import me.rerere.zhiwang.util.net.await 11 | import okhttp3.CacheControl 12 | import okhttp3.OkHttpClient 13 | import okhttp3.Request 14 | 15 | private const val TAG = "UpdateChecker" 16 | 17 | suspend fun checkUpdate(context: Context): Boolean { 18 | return withContext(Dispatchers.IO) { 19 | try { 20 | val okHttpClient = OkHttpClient() 21 | val request = Request.Builder() 22 | .cacheControl(CacheControl.Builder().noCache().build()) 23 | .url("https://cdn.jsdelivr.net/gh/jiangdashao/ASoulZhiWang/app/build.gradle.kts") 24 | .get() 25 | .build() 26 | val response = okHttpClient.newCall(request).await() 27 | require(response.isSuccessful) 28 | val content = response.body!!.string() 29 | require(content.isNotEmpty()) 30 | val latestVersion = content.let { 31 | it.substring( 32 | it.indexOf("versionCode = ") + "versionCode = ".length, 33 | it.indexOf(char = '\n', startIndex = it.indexOf("versionCode")) 34 | ) 35 | } 36 | val latestVersionCode = latestVersion.toInt() 37 | val currentVersionCode = getAppVersionCode(context) 38 | Log.i(TAG, "checkUpdate: Latest: $latestVersionCode") 39 | if (latestVersionCode > currentVersionCode) { 40 | Log.i( 41 | TAG, 42 | "checkUpdate: Found a update! (current: $currentVersionCode, latest: $latestVersionCode)" 43 | ) 44 | } else { 45 | Log.i(TAG, "checkUpdate: There is no update") 46 | } 47 | latestVersionCode > currentVersionCode 48 | } catch (e: Exception) { 49 | e.printStackTrace() 50 | false 51 | } 52 | } 53 | } 54 | 55 | fun getAppVersionCode(context: Context): Long { 56 | var appVersionCode: Long = 0 57 | try { 58 | val packageInfo: PackageInfo = context.applicationContext 59 | .packageManager 60 | .getPackageInfo(context.packageName, 0) 61 | appVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 62 | packageInfo.longVersionCode 63 | } else { 64 | packageInfo.versionCode.toLong() 65 | } 66 | } catch (e: PackageManager.NameNotFoundException) { 67 | e.printStackTrace() 68 | } 69 | return appVersionCode 70 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/android/ClipboardUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.android 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | 7 | fun Context.getClipboardContent(): String? { 8 | val clipboardManager = this.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 9 | clipboardManager.let { 10 | if (it.hasPrimaryClip() && it.primaryClip!!.itemCount > 0) { 11 | val text = it.primaryClip!!.getItemAt(0).text 12 | if (text.length >= 10) { 13 | return text.toString() 14 | } 15 | } 16 | } 17 | return null 18 | } 19 | 20 | fun Context.setClipboardText(text: String) { 21 | val clipboardManager = this.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 22 | clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)) 23 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/ChartShape.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts 2 | 3 | import androidx.compose.foundation.shape.CircleShape 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.Shape 6 | import androidx.compose.ui.unit.Dp 7 | import androidx.compose.ui.unit.dp 8 | 9 | data class ChartShape( 10 | val size: Dp, 11 | val color: Color, 12 | val shape: Shape, 13 | ) { 14 | companion object { 15 | val Default = ChartShape( 16 | size = 8.dp, 17 | color = Color.Cyan, 18 | shape = CircleShape 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/DropdownContent.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.dp 14 | import me.rerere.zhiwang.util.charts.bars.data.StackedBarData 15 | import me.rerere.zhiwang.util.charts.bars.data.StackedBarItem 16 | 17 | sealed class PopupState { 18 | object Idle : PopupState() 19 | data class Showing(val data: StackedBarData) : PopupState() 20 | } 21 | 22 | @Composable 23 | internal fun DropdownContent( 24 | colors: List = emptyList(), 25 | data: StackedBarData, 26 | ) { 27 | val entries = remember(data) { 28 | data.entries.mapIndexed { idx, item -> 29 | StackedBarItem( 30 | text = item.text, 31 | value = item.value, 32 | color = item.color ?: colors[idx] 33 | ) 34 | } 35 | } 36 | 37 | Text( 38 | data.title, 39 | style = MaterialTheme.typography.caption, 40 | ) 41 | 42 | Spacer( 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .requiredHeight(8.dp) 46 | ) 47 | 48 | entries.forEachIndexed { idx, (text, value, color) -> 49 | Row(Modifier.requiredHeight(20.dp), verticalAlignment = Alignment.CenterVertically) { 50 | Box( 51 | Modifier 52 | .requiredSize(8.dp) 53 | .background(color, CircleShape) 54 | ) 55 | 56 | Text( 57 | text = text, 58 | modifier = Modifier.padding(start = 8.dp), 59 | style = MaterialTheme.typography.caption, 60 | ) 61 | 62 | Spacer( 63 | modifier = Modifier 64 | .requiredHeight(20.dp) 65 | .weight(1f) 66 | ) 67 | 68 | Text( 69 | text = value.toInt().toString(), 70 | style = MaterialTheme.typography.caption, 71 | ) 72 | } 73 | 74 | if (idx != entries.lastIndex) 75 | Spacer( 76 | modifier = Modifier 77 | .fillMaxWidth() 78 | .requiredHeight(8.dp) 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/HorizontalBarsChart.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.DropdownMenu 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.layout.layout 12 | import androidx.compose.ui.platform.LocalDensity 13 | import androidx.compose.ui.text.AnnotatedString 14 | import androidx.compose.ui.unit.Dp 15 | import androidx.compose.ui.unit.DpOffset 16 | import androidx.compose.ui.unit.dp 17 | import me.rerere.zhiwang.util.charts.bars.data.HorizontalBarsData 18 | import me.rerere.zhiwang.util.charts.bars.data.StackedBarData 19 | import me.rerere.zhiwang.util.charts.bars.data.StackedBarItem 20 | import me.rerere.zhiwang.util.charts.internal.DefaultText 21 | import me.rerere.zhiwang.util.charts.internal.safeGet 22 | import me.rerere.zhiwang.util.charts.legend.DrawHorizontalLegend 23 | import me.rerere.zhiwang.util.charts.legend.LegendEntry 24 | import kotlin.math.min 25 | 26 | internal val MinimumBarWidth = 24.dp 27 | internal val OptionalBarOffset = 75.dp 28 | internal val MaximumBarWidth = 275.dp 29 | 30 | typealias TextRowFactory = @Composable RowScope.(title: AnnotatedString) -> Unit 31 | 32 | internal val DropdownDefaultModifier = Modifier 33 | .requiredWidth(176.dp) 34 | .padding(16.dp) 35 | 36 | @Composable 37 | fun HorizontalBarsChart( 38 | data: HorizontalBarsData, 39 | modifier: Modifier = Modifier, 40 | divider: @Composable (() -> Unit)? = null, 41 | legend: @Composable (ColumnScope.(entries: List) -> Unit)? = null, 42 | legendOffset: Dp = 4.dp, 43 | dropdownModifier: Modifier = DropdownDefaultModifier, 44 | dropdownContent: @Composable (StackedBarData) -> Unit = { 45 | DropdownContent(data = it, colors = data.colors) 46 | }, 47 | textContent: TextRowFactory = { DefaultText(text = it) }, 48 | valueContent: TextRowFactory = { DefaultText(text = it) }, 49 | ) { 50 | val legendEntries = remember(data) { 51 | data.customLegendEntries.takeIf { it.isNotEmpty() } ?: data.legendEntries() 52 | } 53 | var popupState: PopupState by remember { mutableStateOf(PopupState.Idle) } 54 | 55 | Column(modifier = modifier) { 56 | if (legend != null) { 57 | legend(legendEntries) 58 | } else { 59 | DrawHorizontalLegend(legendEntries = legendEntries) 60 | } 61 | 62 | Spacer( 63 | modifier = Modifier 64 | .fillMaxWidth() 65 | .requiredHeight(legendOffset) 66 | ) 67 | 68 | val maxValue = data.bars.maxByOrNull { bar -> bar.count }?.count ?: 0 69 | 70 | data.bars.forEachIndexed { idx, bar -> 71 | Box { 72 | StackedHorizontalBar( 73 | modifier = Modifier.clickable( 74 | interactionSource = MutableInteractionSource(), 75 | indication = null, 76 | enabled = data.isPopupEnabled, 77 | onClick = { popupState = PopupState.Showing(bar) }, 78 | ), 79 | colors = data.colors, 80 | data = bar, 81 | maxBarValue = maxValue, 82 | shouldDrawDivider = idx != data.bars.lastIndex, 83 | divider = divider, 84 | title = textContent, 85 | value = valueContent, 86 | ) 87 | 88 | DropdownMenu( 89 | modifier = dropdownModifier, 90 | offset = DpOffset(0.dp, (-48).dp), 91 | expanded = popupState.let { it is PopupState.Showing && it.data == bar }, 92 | onDismissRequest = { popupState = PopupState.Idle } 93 | ) { 94 | popupState.let { 95 | if (it is PopupState.Showing) 96 | dropdownContent(it.data) 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | internal fun StackedHorizontalBar( 106 | modifier: Modifier = Modifier, 107 | colors: List, 108 | data: StackedBarData, 109 | maxBarValue: Int, 110 | shouldDrawDivider: Boolean, 111 | divider: @Composable (() -> Unit)? = null, 112 | title: TextRowFactory = {}, 113 | value: TextRowFactory = {}, 114 | ) { 115 | val entries = data.entries.mapIndexed { idx, item -> 116 | StackedBarItem( 117 | text = item.text, 118 | value = item.value, 119 | color = item.color ?: colors.safeGet(idx) 120 | ) 121 | } 122 | 123 | Column(modifier) { 124 | Spacer( 125 | modifier = Modifier 126 | .fillMaxWidth() 127 | .requiredHeight(12.dp) 128 | ) 129 | Row( 130 | modifier = Modifier.heightIn(min = 24.dp), 131 | verticalAlignment = Alignment.CenterVertically 132 | ) { 133 | Row( 134 | // Trim the width of text to [container.width - 50.dp] 135 | Modifier.layout { measurable, constraints -> 136 | val width = constraints.maxWidth - 50.dp.roundToPx() 137 | val placeable = measurable.measure(constraints.copy(maxWidth = width, minWidth = width)) 138 | layout(width, placeable.height) { 139 | placeable.placeRelative(0, 0) 140 | } 141 | } 142 | ) { 143 | title(data.title) 144 | } 145 | 146 | Spacer( 147 | modifier = Modifier.weight(1f) 148 | ) 149 | 150 | value(AnnotatedString(data.count.toString())) 151 | } 152 | 153 | Spacer( 154 | modifier = Modifier 155 | .fillMaxWidth() 156 | .requiredHeight(4.dp) 157 | ) 158 | 159 | BoxWithConstraints { 160 | val rightPadding = with(LocalDensity.current) { OptionalBarOffset.roundToPx() } 161 | val maxWidthPx = with(LocalDensity.current) { MaximumBarWidth.roundToPx() } 162 | val maxBarWidthPx = min(maxWidthPx, constraints.maxWidth - rightPadding) 163 | val minWidthPx = with(LocalDensity.current) { MinimumBarWidth.roundToPx() } 164 | 165 | val percentage = data.count / maxBarValue.toFloat() 166 | val width = maxBarWidthPx * percentage 167 | 168 | DrawStackedBar( 169 | entries = entries, 170 | widthPx = if (width < minWidthPx) width + minWidthPx else width 171 | ) 172 | } 173 | 174 | Spacer( 175 | modifier = Modifier 176 | .fillMaxWidth() 177 | .requiredHeight(12.dp) 178 | ) 179 | 180 | if (shouldDrawDivider && divider != null) { 181 | divider() 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/StackedHorizontalBar.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.draw.drawBehind 8 | import androidx.compose.ui.geometry.Size 9 | import androidx.compose.ui.graphics.Path 10 | import androidx.compose.ui.platform.LocalDensity 11 | import androidx.compose.ui.unit.dp 12 | import me.rerere.zhiwang.util.charts.bars.data.StackedBarItem 13 | 14 | internal typealias EntryPathFactory = (entry: EntryDrawShape, size: Size) -> Path 15 | 16 | internal val EntrySpacing = 2.dp 17 | 18 | @Composable 19 | internal fun DrawStackedBar( 20 | entries: List, 21 | widthPx: Float, 22 | entryPathFactory: EntryPathFactory = { entry, size -> createBarEntryShape(entry, size) }, 23 | ) { 24 | val total = remember(entries) { entries.sumByDouble { it.value.toDouble() }.toFloat() } 25 | val width = with(LocalDensity.current) { widthPx.toDp() } 26 | val spacingPx = with(LocalDensity.current) { EntrySpacing.roundToPx() } 27 | 28 | val totalSpacing = (entries.size - 1) * spacingPx 29 | 30 | val values = remember(entries) { 31 | entries.map { 32 | it.copy(value = (widthPx - totalSpacing) * it.value / total) 33 | } 34 | } 35 | 36 | Row( 37 | modifier = Modifier.requiredWidth(width), 38 | horizontalArrangement = Arrangement.spacedBy(EntrySpacing) 39 | ) { 40 | values.forEachIndexed { idx, item -> 41 | val shape = when { 42 | idx == 0 && values.size == 1 -> EntryDrawShape.Single 43 | idx == 0 -> EntryDrawShape.First 44 | idx == values.lastIndex -> EntryDrawShape.Last 45 | else -> EntryDrawShape.Middle 46 | } 47 | 48 | Box( 49 | modifier = Modifier 50 | .requiredWidth(with(LocalDensity.current) { item.value.toDp() }) 51 | .requiredHeight(8.dp) 52 | .drawBehind { 53 | drawPath(entryPathFactory(shape, size), item.color) 54 | } 55 | ) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/Utils.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars 2 | 3 | import androidx.compose.foundation.shape.CircleShape 4 | import androidx.compose.ui.geometry.* 5 | import androidx.compose.ui.graphics.Path 6 | import androidx.compose.ui.unit.dp 7 | import me.rerere.zhiwang.util.charts.ChartShape 8 | import me.rerere.zhiwang.util.charts.bars.data.HorizontalBarsData 9 | import me.rerere.zhiwang.util.charts.internal.safeGet 10 | import me.rerere.zhiwang.util.charts.legend.LegendEntry 11 | 12 | internal fun HorizontalBarsData.uniqueBarEntries() = 13 | bars.asSequence() 14 | .map { bar -> bar.entries } 15 | .flatten() 16 | .distinctBy { it.text } 17 | .toList() 18 | 19 | internal fun HorizontalBarsData.legendEntries() = uniqueBarEntries().mapIndexed { idx, item -> 20 | LegendEntry( 21 | text = item.text, 22 | value = item.value, 23 | percent = 0f, 24 | shape = ChartShape( 25 | size = 8.dp, 26 | color = item.color ?: colors.safeGet(idx), 27 | shape = CircleShape, 28 | ) 29 | ) 30 | } 31 | 32 | internal enum class EntryDrawShape { 33 | // Rounded on all sides 34 | Single, 35 | 36 | // Rounded left side 37 | First, 38 | 39 | // Not rounded 40 | Middle, 41 | 42 | // Rounded right side 43 | Last; 44 | } 45 | 46 | // TODO: make this customisable 47 | internal fun createBarEntryShape(shape: EntryDrawShape, size: Size): Path { 48 | val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height)) 49 | return Path().apply { 50 | when (shape) { 51 | EntryDrawShape.Single -> addRoundRect( 52 | RoundRect( 53 | rect = rect, 54 | cornerRadius = CornerRadius(size.height) 55 | ) 56 | ) 57 | EntryDrawShape.First -> addRoundRect( 58 | RoundRect( 59 | rect = rect, 60 | topRight = CornerRadius.Zero, 61 | bottomRight = CornerRadius.Zero, 62 | topLeft = CornerRadius(size.height), 63 | bottomLeft = CornerRadius(size.height), 64 | ) 65 | ) 66 | EntryDrawShape.Middle -> addRect(rect = rect) 67 | EntryDrawShape.Last -> addRoundRect( 68 | RoundRect( 69 | rect = rect, 70 | topLeft = CornerRadius.Zero, 71 | bottomLeft = CornerRadius.Zero, 72 | topRight = CornerRadius(size.height), 73 | bottomRight = CornerRadius(size.height), 74 | ) 75 | ) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/data/HorizontalBarsData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars.data 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import me.rerere.zhiwang.util.charts.legend.LegendEntry 5 | 6 | data class HorizontalBarsData( 7 | /** 8 | * List of horizontal bars to be drawn 9 | */ 10 | val bars: List, 11 | /** 12 | * Optional 13 | * 14 | * Colors for every bar item 15 | */ 16 | val colors: List = emptyList(), 17 | /** 18 | * Optional. Items specified here will replace items inferred from [bars] 19 | */ 20 | val customLegendEntries: List = emptyList(), 21 | /** 22 | * Whether to enabled popup on bar click or not 23 | */ 24 | val isPopupEnabled: Boolean = true, 25 | ) 26 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/data/StackedBarData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars.data 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | 5 | data class StackedBarData( 6 | val title: AnnotatedString, 7 | val entries: List, 8 | ) { 9 | val count get() = entries.sumBy { it.value.toInt() } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/data/StackedBarEntry.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars.data 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.AnnotatedString 5 | 6 | data class StackedBarEntry( 7 | val text: AnnotatedString, 8 | val value: Float, 9 | val color: Color? = null, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/bars/data/StackedBarItem.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.bars.data 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.AnnotatedString 5 | 6 | internal data class StackedBarItem( 7 | val text: AnnotatedString, 8 | val value: Float, 9 | val color: Color, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/internal/DefaultText.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.internal 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.text.AnnotatedString 6 | 7 | @Composable 8 | internal fun DefaultText(text: AnnotatedString?) { 9 | if (text != null) 10 | Text(text = text) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/internal/Utils.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.internal 2 | 3 | internal fun List.safeGet(idx: Int): T = when { 4 | idx in 0..lastIndex -> this[idx] 5 | idx > lastIndex -> this[idx - size] 6 | else -> error("Can't get a color at $idx") 7 | } 8 | 9 | internal const val DEG2RAD = Math.PI / 180.0 10 | internal const val FDEG2RAD = Math.PI.toFloat() / 180f 11 | internal val FLOAT_EPSILON = Float.fromBits(1) 12 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/legend/HorizontalLegend.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.legend 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.requiredSize 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import com.google.accompanist.flowlayout.FlowRow 13 | import me.rerere.zhiwang.util.charts.internal.DefaultText 14 | 15 | @Composable 16 | fun DrawHorizontalLegend( 17 | legendEntries: List, 18 | text: @Composable (item: LegendEntry) -> Unit = { DefaultText(text = it.text) }, 19 | ) { 20 | FlowRow( 21 | mainAxisSpacing = 16.dp, 22 | crossAxisSpacing = 8.dp, 23 | ) { 24 | legendEntries.forEachIndexed { _,item -> 25 | Row(verticalAlignment = Alignment.CenterVertically) { 26 | Box( 27 | modifier = Modifier 28 | .requiredSize(item.shape.size) 29 | .background(item.shape.color, item.shape.shape) 30 | ) 31 | 32 | Spacer(modifier = Modifier.requiredSize(8.dp)) 33 | 34 | text(item) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/legend/LegendEntry.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.legend 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | import me.rerere.zhiwang.util.charts.ChartShape 5 | 6 | data class LegendEntry( 7 | val text: AnnotatedString, 8 | val value: Float, 9 | val percent: Float = Float.MAX_VALUE, 10 | val shape: ChartShape = ChartShape.Default 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/legend/VerticalLegend.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.legend 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | import me.rerere.zhiwang.util.charts.internal.DefaultText 10 | 11 | @Composable 12 | fun RowScope.DrawVerticalLegend( 13 | legendEntries: List, 14 | text: @Composable (entry: LegendEntry) -> Unit = { 15 | DefaultText(text = it.text) 16 | }, 17 | ) { 18 | Column( 19 | modifier = Modifier.weight(1f), 20 | verticalArrangement = Arrangement.Center 21 | ) { 22 | legendEntries.forEachIndexed { idx, item -> 23 | Row(verticalAlignment = Alignment.CenterVertically) { 24 | Box( 25 | modifier = Modifier 26 | .requiredSize(item.shape.size) 27 | .background(item.shape.color, item.shape.shape) 28 | ) 29 | 30 | Spacer(modifier = Modifier.requiredSize(8.dp)) 31 | 32 | text(item) 33 | } 34 | 35 | if (idx != legendEntries.lastIndex) 36 | Spacer(modifier = Modifier.requiredSize(8.dp)) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/LegendPosition.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | enum class LegendPosition { 4 | Start, End, Top, Bottom; 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/PieChart.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.remember 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalDensity 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.dp 11 | import me.rerere.zhiwang.util.charts.legend.DrawVerticalLegend 12 | import me.rerere.zhiwang.util.charts.legend.LegendEntry 13 | 14 | @Composable 15 | fun PieChart( 16 | data: PieChartData, 17 | modifier: Modifier = Modifier, 18 | chartSize: Dp = 100.dp, 19 | sliceWidth: Dp = 16.dp, 20 | legendOffset: Dp = 24.dp, 21 | chartShapeSize: Dp = 8.dp, 22 | sliceSpacing: Dp = 2.dp, 23 | legend: @Composable (RowScope.(entries: List) -> Unit)? = null, 24 | ) { 25 | val fractions = remember(data) { data.calculateFractions() } 26 | val legendEntries = remember(data) { data.createLegendEntries(chartShapeSize) } 27 | 28 | val chartSizePx = with(LocalDensity.current) { chartSize.toPx() } 29 | val sliceWidthPx = with(LocalDensity.current) { sliceWidth.toPx() } 30 | val sliceSpacingPx = with(LocalDensity.current) { sliceSpacing.toPx() } 31 | 32 | @Composable 33 | fun RowScope.legend() { 34 | if (legend == null) { 35 | DrawVerticalLegend(legendEntries) 36 | } else { 37 | legend(legendEntries) 38 | } 39 | } 40 | 41 | Column(Modifier.fillMaxWidth()) { 42 | if (data.legendPosition == LegendPosition.Top) { 43 | Row { 44 | legend() 45 | } 46 | Spacer(modifier = Modifier.requiredSize(legendOffset)) 47 | } 48 | 49 | Row( 50 | modifier = modifier.fillMaxWidth(), 51 | verticalAlignment = Alignment.CenterVertically, 52 | horizontalArrangement = Arrangement.Center 53 | ) { 54 | val entryColors = data.entries.mapNotNull { it.color } 55 | 56 | if (data.legendPosition == LegendPosition.Start) { 57 | legend() 58 | Spacer(modifier = Modifier.requiredSize(legendOffset)) 59 | } 60 | 61 | PieChartRenderer( 62 | modifier = Modifier.requiredSize(chartSize), 63 | chartSizePx = chartSizePx, 64 | sliceWidthPx = sliceWidthPx, 65 | sliceSpacingPx = sliceSpacingPx, 66 | fractions = fractions, 67 | composeColors = entryColors.takeIf { it.size == data.entries.size } ?: data.colors, 68 | animate = data.animate, 69 | ) 70 | 71 | if (data.legendPosition == LegendPosition.End) { 72 | Spacer(modifier = Modifier.requiredSize(legendOffset)) 73 | legend() 74 | } 75 | } 76 | 77 | if (data.legendPosition == LegendPosition.Bottom) { 78 | Spacer(modifier = Modifier.requiredSize(legendOffset)) 79 | Row { 80 | legend() 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/PieChartData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | import androidx.compose.foundation.shape.CircleShape 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.graphics.Shape 6 | 7 | data class PieChartData( 8 | val entries: List, 9 | val colors: List = emptyList(), 10 | val legendPosition: LegendPosition = LegendPosition.Bottom, 11 | val legendShape: Shape = CircleShape, 12 | val animate: Boolean = true, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/PieChartEntry.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.AnnotatedString 5 | 6 | data class PieChartEntry( 7 | val value: Float, 8 | val label: AnnotatedString, 9 | /** 10 | * Color of the pie slice and legend entry, if not provided [PieChartData.colors] will be used 11 | */ 12 | val color: Color? = null, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/PieChartRenderer.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | import android.graphics.Paint 4 | import android.graphics.Path 5 | import android.graphics.RectF 6 | import androidx.compose.animation.core.Animatable 7 | import androidx.compose.animation.core.tween 8 | import androidx.compose.foundation.Canvas 9 | import androidx.compose.runtime.* 10 | import androidx.compose.runtime.saveable.rememberSaveable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.nativeCanvas 14 | import androidx.compose.ui.graphics.toArgb 15 | import me.rerere.zhiwang.util.charts.internal.FDEG2RAD 16 | import me.rerere.zhiwang.util.charts.internal.FLOAT_EPSILON 17 | import me.rerere.zhiwang.util.charts.internal.safeGet 18 | import kotlin.math.cos 19 | import kotlin.math.max 20 | import kotlin.math.sin 21 | 22 | private const val StartDegree = -90f 23 | 24 | @Composable 25 | internal fun PieChartRenderer( 26 | modifier: Modifier = Modifier, 27 | chartSizePx: Float, 28 | sliceWidthPx: Float, 29 | sliceSpacingPx: Float, 30 | fractions: List, 31 | composeColors: List, 32 | animate: Boolean, 33 | ) { 34 | var animationRan by rememberSaveable(fractions, animate) { mutableStateOf(false) } 35 | val animation = remember { 36 | Animatable( 37 | when { 38 | animate -> if (animationRan) 1f else 0f 39 | else -> 1f 40 | } 41 | ) 42 | } 43 | val phase by animation.asState() 44 | 45 | LaunchedEffect(Unit) { 46 | if (!animationRan) 47 | animation.animateTo(1f, tween(325)) { animationRan = true } 48 | } 49 | 50 | val pathBuffer by remember { mutableStateOf(Path()) } 51 | val innerRectBuffer by remember { mutableStateOf(RectF()) } 52 | 53 | val holeRadius = remember(chartSizePx, sliceWidthPx) { 54 | (chartSizePx - (sliceWidthPx * 2f)) / 2f 55 | } 56 | 57 | Canvas(modifier = modifier) { 58 | val circleBox = RectF(0f, 0f, size.width, size.height) 59 | val nativeCanvas = drawContext.canvas.nativeCanvas 60 | 61 | var angle = 0f 62 | 63 | val radius = chartSizePx / 2f 64 | val drawInnerArc = sliceWidthPx > FLOAT_EPSILON && sliceWidthPx < chartSizePx / 2f 65 | val userInnerRadius = if (drawInnerArc) holeRadius else 0f 66 | 67 | val rotationAngle = StartDegree 68 | 69 | fractions.forEachIndexed { idx, sliceAngle -> 70 | val piecePaint = Paint().apply { 71 | color = composeColors.safeGet(idx).toArgb() 72 | isAntiAlias = true 73 | } 74 | var innerRadius = userInnerRadius 75 | 76 | val accountForSliceSpacing = sliceSpacingPx > 0f && sliceAngle <= 180f 77 | 78 | val sliceSpaceAngleOuter = sliceSpacingPx / (FDEG2RAD * radius) 79 | val startAngleOuter = rotationAngle + (angle + sliceSpaceAngleOuter / 2f) * phase 80 | val sweepAngleOuter = ((sliceAngle - sliceSpaceAngleOuter) * phase).coerceAtLeast(0f) 81 | 82 | pathBuffer.reset() 83 | 84 | val arcStartPointX = center.x + radius * cos(startAngleOuter * FDEG2RAD) 85 | val arcStartPointY = center.y + radius * sin(startAngleOuter * FDEG2RAD) 86 | 87 | if (sweepAngleOuter >= 360f && sweepAngleOuter % 360f <= FLOAT_EPSILON) { 88 | // Android is doing "mod 360" 89 | pathBuffer.addCircle(center.x, center.y, radius, Path.Direction.CW) 90 | } else { 91 | pathBuffer.arcTo( 92 | circleBox, 93 | startAngleOuter, 94 | sweepAngleOuter 95 | ) 96 | } 97 | 98 | innerRectBuffer.set( 99 | center.x - innerRadius, 100 | center.y - innerRadius, 101 | center.x + innerRadius, 102 | center.y + innerRadius 103 | ) 104 | 105 | if (drawInnerArc && (innerRadius > 0f || accountForSliceSpacing)) { 106 | if (accountForSliceSpacing) { 107 | val minSpacedRadius = calculateMinimumRadiusForSpacedSlice( 108 | center, 109 | radius, 110 | sliceAngle * phase, 111 | arcStartPointX, arcStartPointY, 112 | startAngleOuter, 113 | sweepAngleOuter 114 | ).let { 115 | if (it < 0f) -it else it 116 | } 117 | 118 | innerRadius = max(innerRadius, minSpacedRadius) 119 | } 120 | 121 | val sliceSpaceAngleInner = if (fractions.size == 1 || innerRadius == 0f) 0f 122 | else sliceSpacingPx / (FDEG2RAD * innerRadius) 123 | 124 | val startAngleInner = rotationAngle + (angle + sliceSpaceAngleInner / 2f) * phase 125 | val sweepAngleInner = ((sliceAngle - sliceSpaceAngleInner) * phase).coerceAtLeast(0f) 126 | 127 | val endAngleInner = startAngleInner + sweepAngleInner 128 | if (sweepAngleOuter >= 360f && sweepAngleOuter % 360f <= FLOAT_EPSILON) { 129 | // Android is doing "mod 360" 130 | pathBuffer.addCircle(center.x, center.y, innerRadius, Path.Direction.CCW) 131 | } else { 132 | pathBuffer.lineTo( 133 | center.x + innerRadius * cos(endAngleInner * FDEG2RAD), 134 | center.y + innerRadius * sin(endAngleInner * FDEG2RAD) 135 | ) 136 | 137 | pathBuffer.arcTo( 138 | innerRectBuffer, 139 | endAngleInner, 140 | -sweepAngleInner 141 | ) 142 | } 143 | } else { 144 | if (sweepAngleOuter % 360f > FLOAT_EPSILON) { 145 | if (accountForSliceSpacing) { 146 | val angleMiddle = startAngleOuter + sweepAngleOuter / 2f 147 | val sliceSpaceOffset = calculateMinimumRadiusForSpacedSlice( 148 | center, 149 | radius, 150 | sliceAngle * phase, 151 | arcStartPointX, 152 | arcStartPointY, 153 | startAngleOuter, 154 | sweepAngleOuter 155 | ) 156 | val arcEndPointX = center.x + sliceSpaceOffset * cos(angleMiddle * FDEG2RAD) 157 | val arcEndPointY = center.y + sliceSpaceOffset * sin(angleMiddle * FDEG2RAD) 158 | pathBuffer.lineTo( 159 | arcEndPointX, 160 | arcEndPointY 161 | ) 162 | } else { 163 | pathBuffer.lineTo( 164 | center.x, 165 | center.y 166 | ) 167 | } 168 | } 169 | } 170 | 171 | pathBuffer.close() 172 | 173 | nativeCanvas.drawPath(pathBuffer, piecePaint) 174 | 175 | angle += sliceAngle * phase 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/pie/Utils.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.pie 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.unit.Dp 5 | import me.rerere.zhiwang.util.charts.ChartShape 6 | import me.rerere.zhiwang.util.charts.internal.DEG2RAD 7 | import me.rerere.zhiwang.util.charts.internal.FDEG2RAD 8 | import me.rerere.zhiwang.util.charts.internal.safeGet 9 | import me.rerere.zhiwang.util.charts.legend.LegendEntry 10 | import kotlin.math.* 11 | 12 | internal fun PieChartData.createLegendEntries( 13 | shapeSize: Dp, 14 | ): List = 15 | entries.mapIndexed { index, item -> 16 | LegendEntry( 17 | text = item.label, 18 | value = item.value, 19 | percent = item.value * 100f / entries.map { it.value }.reduce { acc, i -> acc + i }, 20 | shape = ChartShape( 21 | color = item.color ?: colors.safeGet(index), 22 | shape = legendShape, 23 | size = shapeSize, 24 | ) 25 | ) 26 | } 27 | 28 | internal fun PieChartData.calculateFractions( 29 | minAngle: Float = 16f, 30 | maxAngle: Float = 360f 31 | ): List { 32 | val total = entries.sumByDouble { it.value.toDouble() }.toFloat() 33 | val entryCount = entries.size 34 | 35 | val hasMinAngle = minAngle != 0f && entryCount * minAngle <= maxAngle 36 | val minAngles = MutableList(entryCount) { 0f } 37 | 38 | val fractions = entries 39 | .map { it.value / total } 40 | .map { it * 360f } 41 | 42 | var offset = 0f 43 | var diff = 0f 44 | 45 | if (hasMinAngle) { 46 | fractions.forEachIndexed { idx, angle -> 47 | val temp = angle - minAngle 48 | 49 | if (temp <= 0) { 50 | offset += -temp 51 | minAngles[idx] = minAngle 52 | } else { 53 | minAngles[idx] = angle 54 | diff += temp 55 | } 56 | } 57 | 58 | fractions.forEachIndexed { idx, _ -> 59 | minAngles[idx] -= (minAngles[idx] - minAngle) / diff * offset 60 | } 61 | 62 | return minAngles 63 | } 64 | 65 | return fractions 66 | } 67 | 68 | internal fun calculateMinimumRadiusForSpacedSlice( 69 | center: Offset, 70 | radius: Float, 71 | angle: Float, 72 | arcStartPointX: Float, 73 | arcStartPointY: Float, 74 | startAngle: Float, 75 | sweepAngle: Float 76 | ): Float { 77 | val angleMiddle = startAngle + sweepAngle / 2f 78 | 79 | // Other point of the arc 80 | val arcEndPointX: Float = center.x + radius * cos((startAngle + sweepAngle) * FDEG2RAD) 81 | val arcEndPointY: Float = center.y + radius * sin((startAngle + sweepAngle) * FDEG2RAD) 82 | 83 | // Middle point on the arc 84 | val arcMidPointX: Float = center.x + radius * cos(angleMiddle * FDEG2RAD) 85 | val arcMidPointY: Float = center.y + radius * sin(angleMiddle * FDEG2RAD) 86 | 87 | // This is the base of the contained triangle 88 | val basePointsDistance = sqrt( 89 | (arcEndPointX - arcStartPointX).toDouble().pow(2.0) + 90 | (arcEndPointY - arcStartPointY).toDouble().pow(2.0) 91 | ) 92 | 93 | // After reducing space from both sides of the "slice", 94 | // the angle of the contained triangle should stay the same. 95 | // So let's find out the height of that triangle. 96 | val containedTriangleHeight = 97 | (basePointsDistance / 2.0 * tan((180.0 - angle) / 2.0 * DEG2RAD)).toFloat() 98 | 99 | // Now we subtract that from the radius 100 | var spacedRadius = radius - containedTriangleHeight 101 | 102 | // And now subtract the height of the arc that's between the triangle and the outer circle 103 | spacedRadius -= sqrt( 104 | (arcMidPointX - (arcEndPointX + arcStartPointX) / 2f).toDouble().pow(2.0) + 105 | (arcMidPointY - (arcEndPointY + arcStartPointY) / 2f).toDouble().pow(2.0) 106 | ).toFloat() 107 | 108 | return spacedRadius 109 | } 110 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/table/Table.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.table 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.RowScope 5 | import androidx.compose.foundation.layout.requiredSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.AnnotatedString 9 | import androidx.compose.ui.unit.dp 10 | import me.rerere.zhiwang.util.charts.internal.DefaultText 11 | 12 | @Deprecated( 13 | "Use TableChart instead", 14 | ReplaceWith("TableChart(data, modifier, shapeModifier, keyText, valueText, divider)") 15 | ) 16 | @Composable 17 | fun Table( 18 | data: List, 19 | modifier: Modifier = Modifier, 20 | shapeModifier: Modifier = Modifier.requiredSize(8.dp), 21 | keyText: @Composable RowScope.(key: AnnotatedString?) -> Unit = { DefaultText(text = it) }, 22 | valueText: @Composable RowScope.(value: AnnotatedString?) -> Unit = { DefaultText(text = it) }, 23 | divider: @Composable (() -> Unit)? = null, 24 | ) { 25 | TableChart(data, modifier, shapeModifier, keyText, valueText, divider) 26 | } 27 | 28 | @Composable 29 | fun TableChart( 30 | data: List, 31 | modifier: Modifier = Modifier, 32 | shapeModifier: Modifier = Modifier.requiredSize(8.dp), 33 | keyText: @Composable RowScope.(key: AnnotatedString?) -> Unit = { DefaultText(text = it) }, 34 | valueText: @Composable RowScope.(value: AnnotatedString?) -> Unit = { DefaultText(text = it) }, 35 | divider: @Composable (() -> Unit)? = null, 36 | ) { 37 | Column(modifier) { 38 | data.forEachIndexed { idx, item -> 39 | TableRow( 40 | entry = item, 41 | shapeModifier = shapeModifier, 42 | keyText = keyText, 43 | valueText = valueText, 44 | ) 45 | if (idx != data.lastIndex && divider != null) 46 | divider() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/table/TableEntry.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.table 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | import me.rerere.zhiwang.util.charts.ChartShape 5 | 6 | data class TableEntry( 7 | val key: AnnotatedString?, 8 | val value: AnnotatedString?, 9 | val drawShape: ChartShape? = null, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/charts/table/TableRow.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.charts.table 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.text.AnnotatedString 9 | 10 | @Composable 11 | internal fun TableRow( 12 | modifier: Modifier = Modifier, 13 | entry: TableEntry, 14 | shapeModifier: Modifier, 15 | keyText: @Composable RowScope.(key: AnnotatedString?) -> Unit, 16 | valueText: @Composable RowScope.(value: AnnotatedString?) -> Unit, 17 | ) { 18 | Row( 19 | modifier = modifier, 20 | verticalAlignment = Alignment.CenterVertically 21 | ) { 22 | if (entry.drawShape != null) { 23 | Box( 24 | modifier = shapeModifier.background(entry.drawShape.color, entry.drawShape.shape) 25 | ) 26 | Spacer(modifier = Modifier.requiredSize(entry.drawShape.size)) 27 | } 28 | 29 | keyText(entry.key) 30 | 31 | Spacer(modifier = Modifier.weight(1f)) 32 | 33 | valueText(entry.value) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/format/Format.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.format 2 | 3 | import kotlin.math.roundToInt 4 | 5 | // format double 6 | fun Double.format() = (this * 1000).roundToInt() / 1000.0 7 | fun Double.formatToString() = format().toString() 8 | 9 | // format float 10 | fun Float.format() = (this * 1000).roundToInt() / 1000.0 11 | fun Float.formatToString() = format().toString() -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/format/HtmlFormatUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.format 2 | 3 | import org.jsoup.internal.StringUtil 4 | import org.jsoup.nodes.Element 5 | import org.jsoup.nodes.Node 6 | import org.jsoup.nodes.TextNode 7 | import org.jsoup.select.NodeTraversor 8 | import org.jsoup.select.NodeVisitor 9 | 10 | object HtmlFormatUtil { 11 | fun getPlainText(element: Element?): String? { 12 | val formatter = FormattingVisitor() 13 | NodeTraversor.traverse( 14 | formatter, 15 | element 16 | ) // walk the DOM, and call .head() and .tail() for each node 17 | return formatter.toString() 18 | } 19 | 20 | // the formatting rules, implemented in a breadth-first DOM traverse 21 | private class FormattingVisitor : NodeVisitor { 22 | private var width = 0 23 | private val accum = StringBuilder() // holds the accumulated text 24 | 25 | // hit when the node is first seen 26 | override fun head(node: Node, depth: Int) { 27 | val name: String = node.nodeName() 28 | if (node is TextNode) append((node as TextNode).text()) // TextNodes carry all user-readable text in the DOM. 29 | else if (name == "li") append("\n * ") else if (name == "dt") append(" ") else if (StringUtil.`in`( 30 | name, 31 | "p", 32 | "h1", 33 | "h2", 34 | "h3", 35 | "h4", 36 | "h5", 37 | "tr" 38 | ) 39 | ) append("\n") 40 | } 41 | 42 | // hit when all of the node's children (if any) have been visited 43 | override fun tail(node: Node, depth: Int) { 44 | val name: String = node.nodeName() 45 | if (StringUtil.`in`( 46 | name, 47 | "br", 48 | "dd", 49 | "dt", 50 | "p", 51 | "h1", 52 | "h2", 53 | "h3", 54 | "h4", 55 | "h5" 56 | ) 57 | ) append("\n") else if (name == "a") append( 58 | java.lang.String.format( 59 | " <%s>", 60 | node.absUrl("href") 61 | ) 62 | ) 63 | } 64 | 65 | // appends text to the string builder with a simple word wrap method 66 | private fun append(text: String) { 67 | if (text.startsWith("\n")) width = 68 | 0 // reset counter if starts with a newline. only from formats above, not in natural text 69 | if (text == " " && 70 | (accum.length == 0 || StringUtil.`in`(accum.substring(accum.length - 1), " ", "\n")) 71 | ) return // don't accumulate long runs of empty spaces 72 | if (text.length + width > maxWidth) { // won't fit, needs to wrap 73 | val words = text.split("\\s+").toTypedArray() 74 | for (i in words.indices) { 75 | var word = words[i] 76 | val last = i == words.size - 1 77 | if (!last) // insert a space if not the last word 78 | word = "$word " 79 | if (word.length + width > maxWidth) { // wrap and reset counter 80 | accum.append("\n").append(word) 81 | width = word.length 82 | } else { 83 | accum.append(word) 84 | width += word.length 85 | } 86 | } 87 | } else { // fits as is, without need to wrap text 88 | accum.append(text) 89 | width += text.length 90 | } 91 | } 92 | 93 | override fun toString(): String { 94 | return accum.toString() 95 | } 96 | 97 | companion object { 98 | private const val maxWidth = 80 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/net/AutoRetry.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.net 2 | 3 | import android.util.Log 4 | import androidx.annotation.IntRange 5 | import me.rerere.zhiwang.api.zhiwang.Response 6 | 7 | private const val TAG = "AutoRetry" 8 | 9 | /** 10 | * 自动重试函数 11 | * 12 | * @param maxRetry 重试次数 13 | * @param action 重试体 14 | * @return 最终响应 15 | */ 16 | suspend fun autoRetry( 17 | @IntRange(from = 2) maxRetry: Int = 3, // 重连次数 18 | action: suspend () -> Response 19 | ): Response? { 20 | repeat(maxRetry - 1) { 21 | Log.i(TAG, "autoRetry: Try to get response: ${it + 1}/$maxRetry") 22 | val start = System.currentTimeMillis() 23 | val response = try { 24 | action() 25 | } catch (e: Exception) { 26 | e.printStackTrace() 27 | null 28 | } 29 | if (response != null) { 30 | Log.i( 31 | TAG, 32 | "autoRetry: Successful get response (${System.currentTimeMillis() - start} ms)" 33 | ) 34 | return response 35 | } 36 | } 37 | Log.i(TAG, "autoRetry: Try to get response: $maxRetry*/$maxRetry") 38 | return try { action() } catch (e: Exception){ 39 | e.printStackTrace() 40 | null 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/net/OkhttpUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.net 2 | 3 | import kotlinx.coroutines.suspendCancellableCoroutine 4 | import okhttp3.Call 5 | import okhttp3.Callback 6 | import okhttp3.Response 7 | import java.io.IOException 8 | import kotlin.coroutines.resume 9 | import kotlin.coroutines.resumeWithException 10 | 11 | suspend fun Call.await(): Response { 12 | return suspendCancellableCoroutine { 13 | enqueue(object : Callback { 14 | override fun onFailure(call: Call, e: IOException) { 15 | if(it.isCancelled) return 16 | it.resumeWithException(e) 17 | } 18 | 19 | override fun onResponse(call: Call, response: Response) { 20 | it.resume(response) 21 | } 22 | }) 23 | 24 | it.invokeOnCancellation { 25 | try { 26 | cancel() 27 | } catch (e: Exception){ 28 | println("===== CANCEL ======") 29 | // IGNORE 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/zhiwang/util/net/UserAgentInterceptor.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.zhiwang.util.net 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Request 5 | import okhttp3.Response 6 | 7 | class UserAgentInterceptor(private val userAgent: String) : Interceptor { 8 | override fun intercept(chain: Interceptor.Chain): Response { 9 | val userAgentRequest: Request = chain.request() 10 | .newBuilder() 11 | .header("User-Agent", userAgent) 12 | .build() 13 | return chain.proceed(userAgentRequest) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/asoul.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/drawable-v24/asoul.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/chengfen.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/ASoulZhiWang/a39289b0bfe9a56c591869517fbb90cf5df968cf/app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 小作文助手 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | 22 | 28 | 29 |