From 98fc3ff8798857ac7860e15e25c12c55bb9911ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E5=96=9D=E6=B0=B4=E7=9A=84=E6=9C=A8=E5=AD=90?= Date: Wed, 1 Apr 2026 18:32:18 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Android=E9=A2=98=E5=BA=93AP?= =?UTF-8?q?P=20-=20Jetpack=20Compose=20+=20Retrofit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/build-release.yml | 91 +++++++ .gitignore | 38 +++ README.md | 39 +++ app/build.gradle.kts | 88 +++++++ app/proguard-rules.pro | 29 +++ app/src/main/AndroidManifest.xml | 30 +++ app/src/main/java/cn/tiku/app/MainActivity.kt | 42 +++ app/src/main/java/cn/tiku/app/model/Models.kt | 149 +++++++++++ .../java/cn/tiku/app/navigation/NavGraph.kt | 108 ++++++++ .../java/cn/tiku/app/network/ApiService.kt | 103 ++++++++ .../cn/tiku/app/network/RetrofitClient.kt | 31 +++ .../cn/tiku/app/repository/TikuRepository.kt | 187 ++++++++++++++ .../java/cn/tiku/app/screens/BlogsScreen.kt | 156 ++++++++++++ .../cn/tiku/app/screens/FavoritesScreen.kt | 144 +++++++++++ .../java/cn/tiku/app/screens/HomeScreen.kt | 195 ++++++++++++++ .../java/cn/tiku/app/screens/LoginScreen.kt | 114 +++++++++ .../java/cn/tiku/app/screens/ProfileScreen.kt | 236 +++++++++++++++++ .../tiku/app/screens/QuestionDetailScreen.kt | 193 ++++++++++++++ .../cn/tiku/app/screens/QuestionsScreen.kt | 239 ++++++++++++++++++ .../main/java/cn/tiku/app/ui/theme/Theme.kt | 96 +++++++ .../java/cn/tiku/app/utils/TokenManager.kt | 57 +++++ .../cn/tiku/app/viewmodel/AuthViewModel.kt | 94 +++++++ .../cn/tiku/app/viewmodel/BlogViewModel.kt | 62 +++++ .../cn/tiku/app/viewmodel/ProfileViewModel.kt | 85 +++++++ .../tiku/app/viewmodel/QuestionViewModel.kt | 155 ++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 15 ++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 15 ++ app/src/main/res/mipmap-hdpi/ic_launcher.xml | 12 + .../res/mipmap-hdpi/ic_launcher_round.xml | 12 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/backup_rules.xml | 3 + .../main/res/xml/data_extraction_rules.xml | 7 + build.bat | 45 ++++ build.gradle.kts | 4 + clean_and_build.ps1 | 44 ++++ fix_gradle_wrapper.bat | 42 +++ fix_gradle_wrapper.ps1 | 31 +++ gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew.bat | 91 +++++++ settings.gradle.kts | 17 ++ 43 files changed, 3116 insertions(+) create mode 100644 .gitea/workflows/build-release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/cn/tiku/app/MainActivity.kt create mode 100644 app/src/main/java/cn/tiku/app/model/Models.kt create mode 100644 app/src/main/java/cn/tiku/app/navigation/NavGraph.kt create mode 100644 app/src/main/java/cn/tiku/app/network/ApiService.kt create mode 100644 app/src/main/java/cn/tiku/app/network/RetrofitClient.kt create mode 100644 app/src/main/java/cn/tiku/app/repository/TikuRepository.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/BlogsScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/FavoritesScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/HomeScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/LoginScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/ProfileScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/QuestionDetailScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/screens/QuestionsScreen.kt create mode 100644 app/src/main/java/cn/tiku/app/ui/theme/Theme.kt create mode 100644 app/src/main/java/cn/tiku/app/utils/TokenManager.kt create mode 100644 app/src/main/java/cn/tiku/app/viewmodel/AuthViewModel.kt create mode 100644 app/src/main/java/cn/tiku/app/viewmodel/BlogViewModel.kt create mode 100644 app/src/main/java/cn/tiku/app/viewmodel/ProfileViewModel.kt create mode 100644 app/src/main/java/cn/tiku/app/viewmodel/QuestionViewModel.kt create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 build.bat create mode 100644 build.gradle.kts create mode 100644 clean_and_build.ps1 create mode 100644 fix_gradle_wrapper.bat create mode 100644 fix_gradle_wrapper.ps1 create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml new file mode 100644 index 0000000..2c0eea9 --- /dev/null +++ b/.gitea/workflows/build-release.yml @@ -0,0 +1,91 @@ +name: Build and Release APK + +on: + push: + tags: + - 'v*' + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug + + - name: Build Release APK + run: ./gradlew assembleRelease + continue-on-error: true + + - name: List APK files + run: find . -name "*.apk" -type f + + - name: Copy APK to output + run: | + mkdir -p output + find app/build/outputs -name "*.apk" -exec cp {} output/ \; + ls -la output/ + + - name: Upload APK artifacts + uses: actions/upload-artifact@v4 + with: + name: apk-files + path: output/*.apk + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download APK artifacts + uses: actions/download-artifact@v4 + with: + name: apk-files + path: output/ + + - name: List downloaded files + run: ls -la output/ + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: | + ## 题库APP ${{ github.ref_name }} + + ### 更新内容 + - 自动构建发布 + + ### 下载 + 请从下方Assets中下载APK文件安装使用 + files: output/*.apk + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a24338b --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml +*.ipr +*.iws + +# OS +.DS_Store +Thumbs.db + +# Android +captures/ +.externalNativeBuild/ +.cxx/ + +# 忽略构建产物,但保留output目录下的发布包 +app/build/ +*.ap_ +*.dex +*.class + +# 保留发布APK(不忽略output目录) +!output/ +!output/** + +# 签名文件(敏感信息) +*.jks +*.keystore +.signing/ + +# 日志 +*.log +*.hprof diff --git a/README.md b/README.md new file mode 100644 index 0000000..34db3d2 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# 题库APP + +基于Jetpack Compose开发的Android题库应用,后端API地址:https://tiku.lideshan.cn/ + +## 功能特性 + +- 用户登录认证 +- 题库浏览(支持分类筛选、搜索) +- 题目详情查看 +- 收藏功能 +- 文章浏览 +- 学习统计 + +## 技术栈 + +- Kotlin +- Jetpack Compose +- Retrofit2 +- ViewModel + Flow +- Material3 + +## 构建 + +```bash +./gradlew assembleDebug +``` + +## 发布 + +推送tag即可自动触发构建发布: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +## 下载 + +从Releases页面下载最新APK安装使用。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..235d879 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "cn.tiku.app" + compileSdk = 34 + + defaultConfig { + applicationId = "cn.tiku.app" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + applicationIdSuffix = ".debug" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + + // Compose BOM + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.5") + + // ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") + + // Retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // DataStore for token storage + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // Coil for images + implementation("io.coil-kt:coil-compose:2.5.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..45ee6b1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,29 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Retrofit +-dontwarn retrofit2.** +-keep class retrofit2.** { *; } +-keepattributes Signature +-keepattributes Exceptions + +# Gson +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Keep model classes +-keep class cn.tiku.app.model.** { *; } + +# OkHttp +-dontwarn okhttp3.** +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bfd26ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/cn/tiku/app/MainActivity.kt b/app/src/main/java/cn/tiku/app/MainActivity.kt new file mode 100644 index 0000000..ae7b0bf --- /dev/null +++ b/app/src/main/java/cn/tiku/app/MainActivity.kt @@ -0,0 +1,42 @@ +package cn.tiku.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.rememberNavController +import cn.tiku.app.navigation.TikuNavGraph +import cn.tiku.app.ui.theme.TikuTheme +import cn.tiku.app.viewmodel.AuthViewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TikuTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + TikuApp() + } + } + } + } +} + +@Composable +fun TikuApp(authViewModel: AuthViewModel = viewModel()) { + val navController = rememberNavController() + val isLoggedIn by authViewModel.isLoggedIn.collectAsState() + + TikuNavGraph( + navController = navController, + isLoggedIn = isLoggedIn + ) +} diff --git a/app/src/main/java/cn/tiku/app/model/Models.kt b/app/src/main/java/cn/tiku/app/model/Models.kt new file mode 100644 index 0000000..ccb5bcd --- /dev/null +++ b/app/src/main/java/cn/tiku/app/model/Models.kt @@ -0,0 +1,149 @@ +package cn.tiku.app.model + +import com.google.gson.annotations.SerializedName + +data class Question( + @SerializedName("_id") val id: String, + val text: String, + val answer: String, + val category: String?, + val difficulty: String?, + val tags: List?, + val createdAt: String?, + val updatedAt: String? +) + +data class QuestionsResponse( + val total: Int, + val page: Int, + val pages: Int, + val results: List +) + +data class QuestionIdsResponse( + val ids: List, + val total: Int, + val page: Int, + val pages: Int +) + +data class LoginRequest( + val username: String, + val safekey: String +) + +data class LoginResponse( + val token: String, + val user: UserInfo +) + +data class UserInfo( + val id: String, + val username: String +) + +data class CheckResponse( + val isSetup: Boolean, + val count: Int +) + +data class FavoriteRequest( + val type: String, + val itemId: String +) + +data class FavoriteResponse( + val success: Boolean, + val action: String +) + +data class FavoritesResponse( + val questions: List, + val blogs: List, + val cases: List, + val quotes: List, + val meta: FavoritesMeta +) + +data class FavoritesMeta( + val questions: PaginationMeta, + val blogs: PaginationMeta, + val cases: PaginationMeta, + val quotes: PaginationMeta +) + +data class PaginationMeta( + val total: Int, + val page: Int, + val pages: Int +) + +data class Blog( + @SerializedName("_id") val id: String, + val name: String?, + val author: String?, + val source: String?, + val category: String?, + val content: String?, + val createdAt: String? +) + +data class Case( + @SerializedName("_id") val id: String, + val title: String?, + val content: String?, + val category: String?, + val createdAt: String? +) + +data class Quote( + @SerializedName("_id") val id: String, + val text: String?, + val author: String?, + val category: String?, + val createdAt: String? +) + +data class LearningStats( + val totalLearned: Int, + val correctCount: Int, + val progress: Int +) + +data class LearnRequest( + val correct: Boolean +) + +data class LearnResponse( + val learningStats: LearningStats +) + +data class UserProfile( + val id: String, + val username: String, + val email: String?, + val avatar: String?, + val role: String? +) + +data class BlogListResponse( + val total: Int, + val page: Int, + val pages: Int, + val results: List +) + +data class QuoteListResponse( + val total: Int, + val page: Int, + val pages: Int, + val results: List +) + +data class Article( + val id: String, + val title: String, + val content: String, + val author: String?, + val createdAt: String? +) diff --git a/app/src/main/java/cn/tiku/app/navigation/NavGraph.kt b/app/src/main/java/cn/tiku/app/navigation/NavGraph.kt new file mode 100644 index 0000000..90c9742 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/navigation/NavGraph.kt @@ -0,0 +1,108 @@ +package cn.tiku.app.navigation + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import cn.tiku.app.screens.* + +sealed class Screen(val route: String) { + object Login : Screen("login") + object Home : Screen("home") + object Questions : Screen("questions") + object QuestionDetail : Screen("question/{questionId}") { + fun createRoute(questionId: String) = "question/$questionId" + } + object Favorites : Screen("favorites") + object Profile : Screen("profile") + object Blogs : Screen("blogs") +} + +@Composable +fun TikuNavGraph( + navController: NavHostController, + isLoggedIn: Boolean +) { + val startDestination = if (isLoggedIn) Screen.Home.route else Screen.Login.route + + NavHost( + navController = navController, + startDestination = startDestination + ) { + composable(Screen.Login.route) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + } + ) + } + + composable(Screen.Home.route) { + HomeScreen( + onNavigateToQuestions = { + navController.navigate(Screen.Questions.route) + }, + onNavigateToFavorites = { + navController.navigate(Screen.Favorites.route) + }, + onNavigateToProfile = { + navController.navigate(Screen.Profile.route) + }, + onNavigateToBlogs = { + navController.navigate(Screen.Blogs.route) + } + ) + } + + composable(Screen.Questions.route) { + QuestionsScreen( + onQuestionClick = { questionId -> + navController.navigate(Screen.QuestionDetail.createRoute(questionId)) + }, + onBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.QuestionDetail.route, + arguments = listOf(navArgument("questionId") { type = NavType.StringType }) + ) { backStackEntry -> + val questionId = backStackEntry.arguments?.getString("questionId") ?: "" + QuestionDetailScreen( + questionId = questionId, + onBack = { navController.popBackStack() } + ) + } + + composable(Screen.Favorites.route) { + FavoritesScreen( + onBack = { navController.popBackStack() }, + onQuestionClick = { questionId -> + navController.navigate(Screen.QuestionDetail.createRoute(questionId)) + } + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + onBack = { navController.popBackStack() }, + onLogout = { + navController.navigate(Screen.Login.route) { + popUpTo(0) { inclusive = true } + } + } + ) + } + + composable(Screen.Blogs.route) { + BlogsScreen( + onBack = { navController.popBackStack() } + ) + } + } +} diff --git a/app/src/main/java/cn/tiku/app/network/ApiService.kt b/app/src/main/java/cn/tiku/app/network/ApiService.kt new file mode 100644 index 0000000..f13a0fd --- /dev/null +++ b/app/src/main/java/cn/tiku/app/network/ApiService.kt @@ -0,0 +1,103 @@ +package cn.tiku.app.network + +import cn.tiku.app.model.* +import retrofit2.Response +import retrofit2.http.* + +interface ApiService { + + // Auth + @GET("api/auth/check") + suspend fun checkSetup(): Response + + @POST("api/auth/login") + suspend fun login(@Body request: LoginRequest): Response + + @GET("api/auth/me") + suspend fun getCurrentUser(@Header("Authorization") token: String): Response + + // Questions + @GET("api/questions") + suspend fun getQuestions( + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 20, + @Query("q") query: String? = null, + @Query("category") category: String? = null, + @Query("difficulty") difficulty: String? = null, + @Query("tags") tags: String? = null + ): Response + + @GET("api/questions/categories") + suspend fun getCategories(): Response> + + @GET("api/questions/ids") + suspend fun getQuestionIds( + @Query("category") category: String? = null, + @Query("difficulty") difficulty: String? = null, + @Query("tags") tags: String? = null, + @Query("q") query: String? = null + ): Response + + @GET("api/questions/{id}") + suspend fun getQuestionById(@Path("id") id: String): Response + + @POST("api/questions/{id}/favorite") + suspend fun toggleQuestionFavorite( + @Path("id") id: String, + @Header("Authorization") token: String + ): Response + + @POST("api/questions/{id}/learn") + suspend fun recordLearning( + @Path("id") id: String, + @Header("Authorization") token: String, + @Body request: LearnRequest + ): Response + + // User + @GET("api/users/me/learning-stats") + suspend fun getLearningStats( + @Header("Authorization") token: String + ): Response + + @GET("api/users/me/favorites") + suspend fun getFavorites( + @Header("Authorization") token: String, + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 10, + @Query("type") type: String? = null + ): Response + + @POST("api/users/me/favorites") + suspend fun toggleFavorite( + @Header("Authorization") token: String, + @Body request: FavoriteRequest + ): Response + + @GET("api/users/me/profile") + suspend fun getProfile( + @Header("Authorization") token: String + ): Response + + // Blogs + @GET("api/blogs") + suspend fun getBlogs( + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 20, + @Query("q") query: String? = null, + @Query("category") category: String? = null + ): Response + + @GET("api/blogs/categories") + suspend fun getBlogCategories(): Response> + + @GET("api/blogs/{id}") + suspend fun getBlogById(@Path("id") id: String): Response + + // Quotes + @GET("api/quotes") + suspend fun getQuotes( + @Query("page") page: Int = 1, + @Query("limit") limit: Int = 20 + ): Response +} diff --git a/app/src/main/java/cn/tiku/app/network/RetrofitClient.kt b/app/src/main/java/cn/tiku/app/network/RetrofitClient.kt new file mode 100644 index 0000000..51a4d4a --- /dev/null +++ b/app/src/main/java/cn/tiku/app/network/RetrofitClient.kt @@ -0,0 +1,31 @@ +package cn.tiku.app.network + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + private const val BASE_URL = "https://tiku.lideshan.cn/" + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + val apiService: ApiService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiService::class.java) + } +} diff --git a/app/src/main/java/cn/tiku/app/repository/TikuRepository.kt b/app/src/main/java/cn/tiku/app/repository/TikuRepository.kt new file mode 100644 index 0000000..11ae3a1 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/repository/TikuRepository.kt @@ -0,0 +1,187 @@ +package cn.tiku.app.repository + +import cn.tiku.app.model.* +import cn.tiku.app.network.RetrofitClient +import cn.tiku.app.utils.TokenManager + +class TikuRepository(private val tokenManager: TokenManager) { + + private val api = RetrofitClient.apiService + + // Auth + suspend fun checkSetup(): Result { + return try { + val response = api.checkSetup() + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("检查失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun login(username: String, safekey: String): Result { + return try { + val response = api.login(LoginRequest(username, safekey)) + if (response.isSuccessful) { + val body = response.body()!! + tokenManager.saveToken(body.token) + tokenManager.saveUserInfo(body.user.username, body.user.id) + Result.success(body) + } else { + Result.failure(Exception("登录失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getCurrentUser(token: String): Result { + return try { + val response = api.getCurrentUser(tokenManager.getAuthHeader(token)) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取用户信息失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + // Questions + suspend fun getQuestions( + page: Int = 1, + limit: Int = 20, + query: String? = null, + category: String? = null, + difficulty: String? = null, + tags: String? = null + ): Result { + return try { + val response = api.getQuestions(page, limit, query, category, difficulty, tags) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取题目失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getCategories(): Result> { + return try { + val response = api.getCategories() + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取分类失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getQuestionById(id: String): Result { + return try { + val response = api.getQuestionById(id) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取题目详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun toggleFavorite(token: String, type: String, itemId: String): Result { + return try { + val response = api.toggleFavorite( + tokenManager.getAuthHeader(token), + FavoriteRequest(type, itemId) + ) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("操作失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun recordLearning(token: String, questionId: String, correct: Boolean): Result { + return try { + val response = api.recordLearning( + questionId, + tokenManager.getAuthHeader(token), + LearnRequest(correct) + ) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("记录学习失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getLearningStats(token: String): Result { + return try { + val response = api.getLearningStats(tokenManager.getAuthHeader(token)) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取学习统计失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getFavorites(token: String, page: Int = 1, type: String? = null): Result { + return try { + val response = api.getFavorites(tokenManager.getAuthHeader(token), page, type = type) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取收藏失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + // Blogs + suspend fun getBlogs(page: Int = 1, query: String? = null, category: String? = null): Result { + return try { + val response = api.getBlogs(page, query = query, category = category) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取文章失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + // Quotes + suspend fun getQuotes(page: Int = 1): Result { + return try { + val response = api.getQuotes(page) + if (response.isSuccessful) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取语录失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/BlogsScreen.kt b/app/src/main/java/cn/tiku/app/screens/BlogsScreen.kt new file mode 100644 index 0000000..20d6abc --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/BlogsScreen.kt @@ -0,0 +1,156 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.model.Blog +import cn.tiku.app.viewmodel.BlogViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BlogsScreen( + onBack: () -> Unit, + viewModel: BlogViewModel = viewModel() +) { + val blogs by viewModel.blogs.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val currentPage by viewModel.currentPage.collectAsState() + val totalPages by viewModel.totalPages.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("文章") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { paddingValues -> + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (blogs.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Article, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "暂无文章", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(blogs) { blog -> + BlogItem(blog = blog) + } + } + + if (totalPages > 1) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { viewModel.prevPage() }, + enabled = currentPage > 1 + ) { + Icon(Icons.Default.ChevronLeft, contentDescription = null) + Text("上一页") + } + + Text("$currentPage / $totalPages") + + TextButton( + onClick = { viewModel.nextPage() }, + enabled = currentPage < totalPages + ) { + Text("下一页") + Icon(Icons.Default.ChevronRight, contentDescription = null) + } + } + } + } + } + } +} + +@Composable +fun BlogItem(blog: Blog) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = blog.name ?: "无标题", + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (!blog.author.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "作者: ${blog.author}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!blog.category.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + AssistChip( + onClick = { }, + label = { Text(blog.category, style = MaterialTheme.typography.labelSmall) } + ) + } + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/FavoritesScreen.kt b/app/src/main/java/cn/tiku/app/screens/FavoritesScreen.kt new file mode 100644 index 0000000..dd40518 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/FavoritesScreen.kt @@ -0,0 +1,144 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.model.Question +import cn.tiku.app.viewmodel.ProfileViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoritesScreen( + onBack: () -> Unit, + onQuestionClick: (String) -> Unit, + viewModel: ProfileViewModel = viewModel() +) { + val favorites by viewModel.favoriteQuestions.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadFavorites("questions") + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("我的收藏") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { paddingValues -> + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (favorites.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.FavoriteBorder, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "暂无收藏", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(favorites) { question -> + FavoriteQuestionItem( + question = question, + onClick = { onQuestionClick(question.id) }, + onRemoveFavorite = { + viewModel.removeFavorite("question", question.id) + } + ) + } + } + } + } +} + +@Composable +fun FavoriteQuestionItem( + question: Question, + onClick: () -> Unit, + onRemoveFavorite: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = question.text, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + if (!question.category.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = question.category, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + IconButton(onClick = onRemoveFavorite) { + Icon( + Icons.Default.Favorite, + contentDescription = "取消收藏", + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/HomeScreen.kt b/app/src/main/java/cn/tiku/app/screens/HomeScreen.kt new file mode 100644 index 0000000..ae19d66 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/HomeScreen.kt @@ -0,0 +1,195 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.viewmodel.AuthViewModel +import cn.tiku.app.viewmodel.QuestionViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onNavigateToQuestions: () -> Unit, + onNavigateToFavorites: () -> Unit, + onNavigateToProfile: () -> Unit, + onNavigateToBlogs: () -> Unit, + authViewModel: AuthViewModel = viewModel(), + questionViewModel: QuestionViewModel = viewModel() +) { + val username by authViewModel.username.collectAsState() + val categories by questionViewModel.categories.collectAsState() + val totalQuestions by questionViewModel.totalQuestions.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("题库") }, + actions = { + IconButton(onClick = onNavigateToProfile) { + Icon(Icons.Default.Person, contentDescription = "个人中心") + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "欢迎回来, ${username ?: "用户"}", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "共 $totalQuestions 道题目", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + item { + Text( + text = "功能", + style = MaterialTheme.typography.titleMedium + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + HomeMenuItem( + icon = Icons.Default.Quiz, + title = "题库", + subtitle = "浏览题目", + onClick = onNavigateToQuestions, + modifier = Modifier.weight(1f) + ) + HomeMenuItem( + icon = Icons.Default.Favorite, + title = "收藏", + subtitle = "我的收藏", + onClick = onNavigateToFavorites, + modifier = Modifier.weight(1f) + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + HomeMenuItem( + icon = Icons.Default.Article, + title = "文章", + subtitle = "学习资料", + onClick = onNavigateToBlogs, + modifier = Modifier.weight(1f) + ) + HomeMenuItem( + icon = Icons.Default.Person, + title = "我的", + subtitle = "个人中心", + onClick = onNavigateToProfile, + modifier = Modifier.weight(1f) + ) + } + } + + if (categories.isNotEmpty()) { + item { + Text( + text = "分类", + style = MaterialTheme.typography.titleMedium + ) + } + + items(categories.chunked(2)) { rowCategories -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowCategories.forEach { category -> + Card( + modifier = Modifier + .weight(1f) + .clickable { + questionViewModel.setCategory(category) + onNavigateToQuestions() + } + ) { + Text( + text = category, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + if (rowCategories.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } +} + +@Composable +fun HomeMenuItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.clickable(onClick = onClick) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + icon, + contentDescription = title, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/LoginScreen.kt b/app/src/main/java/cn/tiku/app/screens/LoginScreen.kt new file mode 100644 index 0000000..9197ed0 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/LoginScreen.kt @@ -0,0 +1,114 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.viewmodel.AuthViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + authViewModel: AuthViewModel = viewModel() +) { + var username by remember { mutableStateOf("") } + var safekey by remember { mutableStateOf("") } + + val isLoading by authViewModel.isLoading.collectAsState() + val error by authViewModel.error.collectAsState() + val isLoggedIn by authViewModel.isLoggedIn.collectAsState() + + LaunchedEffect(isLoggedIn) { + if (isLoggedIn) { + onLoginSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("题库登录") } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "题库", + style = MaterialTheme.typography.headlineLarge + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("用户名") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = safekey, + onValueChange = { safekey = it }, + label = { Text("密钥") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ) + ) + + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (username.isNotBlank() && safekey.isNotBlank()) { + authViewModel.login(username, safekey) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + enabled = !isLoading && username.isNotBlank() && safekey.isNotBlank() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("登录") + } + } + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/ProfileScreen.kt b/app/src/main/java/cn/tiku/app/screens/ProfileScreen.kt new file mode 100644 index 0000000..875cc5e --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/ProfileScreen.kt @@ -0,0 +1,236 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.viewmodel.AuthViewModel +import cn.tiku.app.viewmodel.ProfileViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + onBack: () -> Unit, + onLogout: () -> Unit, + authViewModel: AuthViewModel = viewModel(), + profileViewModel: ProfileViewModel = viewModel() +) { + val currentUser by authViewModel.currentUser.collectAsState() + val username by authViewModel.username.collectAsState() + val learningStats by profileViewModel.learningStats.collectAsState() + val isLoading by profileViewModel.isLoading.collectAsState() + + var showLogoutDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + profileViewModel.loadLearningStats() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("个人中心") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = currentUser?.username ?: username ?: "用户", + style = MaterialTheme.typography.titleLarge + ) + } + } + + if (learningStats != null) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "学习统计", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + value = learningStats!!.totalLearned.toString(), + label = "已学习" + ) + StatItem( + value = learningStats!!.correctCount.toString(), + label = "正确数" + ) + StatItem( + value = "${learningStats!!.progress}%", + label = "正确率" + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = learningStats!!.progress / 100f, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column { + ProfileMenuItem( + icon = Icons.Default.History, + title = "学习记录", + onClick = { } + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp)) + ProfileMenuItem( + icon = Icons.Default.Settings, + title = "设置", + onClick = { } + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp)) + ProfileMenuItem( + icon = Icons.Default.Info, + title = "关于", + onClick = { } + ) + } + } + + OutlinedButton( + onClick = { showLogoutDialog = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Logout, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("退出登录") + } + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("确认退出") }, + text = { Text("确定要退出登录吗?") }, + confirmButton = { + TextButton( + onClick = { + showLogoutDialog = false + authViewModel.logout() + onLogout() + } + ) { + Text("确定", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +fun StatItem( + value: String, + label: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun ProfileMenuItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/QuestionDetailScreen.kt b/app/src/main/java/cn/tiku/app/screens/QuestionDetailScreen.kt new file mode 100644 index 0000000..0ff5d3f --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/QuestionDetailScreen.kt @@ -0,0 +1,193 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.viewmodel.QuestionViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuestionDetailScreen( + questionId: String, + onBack: () -> Unit, + viewModel: QuestionViewModel = viewModel() +) { + val question by viewModel.currentQuestion.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val showAnswer by viewModel.showAnswer.collectAsState() + + LaunchedEffect(questionId) { + viewModel.loadQuestionById(questionId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("题目详情") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + actions = { + if (question != null) { + IconButton(onClick = { viewModel.toggleFavorite(questionId) }) { + Icon(Icons.Default.FavoriteBorder, contentDescription = "收藏") + } + IconButton(onClick = { /* Share */ }) { + Icon(Icons.Default.Share, contentDescription = "分享") + } + } + } + ) + } + ) { paddingValues -> + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (question != null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (!question!!.category.isNullOrEmpty()) { + AssistChip( + onClick = { }, + label = { Text(question!!.category!!) } + ) + } + if (!question!!.difficulty.isNullOrEmpty()) { + AssistChip( + onClick = { }, + label = { + Text( + when (question!!.difficulty) { + "easy" -> "简单" + "medium" -> "中等" + "hard" -> "困难" + else -> question!!.difficulty!! + } + ) + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = question!!.text, + style = MaterialTheme.typography.titleMedium + ) + + if (!question!!.tags.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + question!!.tags!!.forEach { tag -> + SuggestionChip( + onClick = { }, + label = { Text(tag, style = MaterialTheme.typography.labelSmall) } + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (showAnswer) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "答案", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = question!!.answer, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + viewModel.recordLearning(questionId, false) + onBack() + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Close, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("记错了") + } + Button( + onClick = { + viewModel.recordLearning(questionId, true) + onBack() + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Check, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("记住了") + } + } + } else { + Button( + onClick = { viewModel.toggleShowAnswer() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Visibility, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("查看答案") + } + } + } + } + } +} diff --git a/app/src/main/java/cn/tiku/app/screens/QuestionsScreen.kt b/app/src/main/java/cn/tiku/app/screens/QuestionsScreen.kt new file mode 100644 index 0000000..c91f517 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/screens/QuestionsScreen.kt @@ -0,0 +1,239 @@ +package cn.tiku.app.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.tiku.app.model.Question +import cn.tiku.app.viewmodel.QuestionViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuestionsScreen( + onQuestionClick: (String) -> Unit, + onBack: () -> Unit, + viewModel: QuestionViewModel = viewModel() +) { + val questions by viewModel.questions.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val categories by viewModel.categories.collectAsState() + val currentPage by viewModel.currentPage.collectAsState() + val totalPages by viewModel.totalPages.collectAsState() + val totalQuestions by viewModel.totalQuestions.collectAsState() + val selectedCategory by viewModel.selectedCategory.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + + var showCategoryDialog by remember { mutableStateOf(false) } + var isSearchActive by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("题库 ($totalQuestions)") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + actions = { + IconButton(onClick = { isSearchActive = !isSearchActive }) { + Icon(Icons.Default.Search, contentDescription = "搜索") + } + IconButton(onClick = { showCategoryDialog = true }) { + Icon(Icons.Default.FilterList, contentDescription = "筛选") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (isSearchActive) { + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.setSearchQuery(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("搜索题目...") }, + singleLine = true, + trailingIcon = { + IconButton(onClick = { viewModel.search() }) { + Icon(Icons.Default.Search, contentDescription = "搜索") + } + } + ) + } + + if (selectedCategory != null) { + InputChip( + selected = true, + onClick = { viewModel.setCategory(null) }, + label = { Text(selectedCategory!!) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "清除", modifier = Modifier.size(16.dp)) + } + ) + } + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(questions) { question -> + QuestionItem( + question = question, + onClick = { onQuestionClick(question.id) } + ) + } + } + + if (totalPages > 1) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { viewModel.prevPage() }, + enabled = currentPage > 1 + ) { + Icon(Icons.Default.ChevronLeft, contentDescription = null) + Text("上一页") + } + + Text("$currentPage / $totalPages") + + TextButton( + onClick = { viewModel.nextPage() }, + enabled = currentPage < totalPages + ) { + Text("下一页") + Icon(Icons.Default.ChevronRight, contentDescription = null) + } + } + } + } + } + } + + if (showCategoryDialog) { + AlertDialog( + onDismissRequest = { showCategoryDialog = false }, + title = { Text("选择分类") }, + text = { + LazyColumn { + item { + TextButton( + onClick = { + viewModel.setCategory(null) + showCategoryDialog = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("全部") + } + } + items(categories) { category -> + TextButton( + onClick = { + viewModel.setCategory(category) + showCategoryDialog = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(category) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showCategoryDialog = false }) { + Text("关闭") + } + } + ) + } +} + +@Composable +fun QuestionItem( + question: Question, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = question.text, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (!question.category.isNullOrEmpty()) { + AssistChip( + onClick = { }, + label = { Text(question.category, style = MaterialTheme.typography.labelSmall) } + ) + } + if (!question.difficulty.isNullOrEmpty()) { + val color = when (question.difficulty) { + "easy" -> MaterialTheme.colorScheme.primary + "medium" -> MaterialTheme.colorScheme.tertiary + "hard" -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.outline + } + AssistChip( + onClick = { }, + label = { + Text( + when (question.difficulty) { + "easy" -> "简单" + "medium" -> "中等" + "hard" -> "困难" + else -> question.difficulty + }, + style = MaterialTheme.typography.labelSmall + ) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/cn/tiku/app/ui/theme/Theme.kt b/app/src/main/java/cn/tiku/app/ui/theme/Theme.kt new file mode 100644 index 0000000..cab3195 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/ui/theme/Theme.kt @@ -0,0 +1,96 @@ +package cn.tiku.app.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF9ECAFF), + onPrimary = Color(0xFF003258), + primaryContainer = Color(0xFF004881), + onPrimaryContainer = Color(0xFFD1E4FF), + secondary = Color(0xFFBBC7DB), + onSecondary = Color(0xFF253140), + secondaryContainer = Color(0xFF3B4858), + onSecondaryContainer = Color(0xFFD7E3F7), + tertiary = Color(0xFFD6BEE4), + onTertiary = Color(0xFF3B2948), + tertiaryContainer = Color(0xFF52405F), + onTertiaryContainer = Color(0xFFF2DAFF), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF1A1C1E), + onBackground = Color(0xFFE3E2E6), + surface = Color(0xFF1A1C1E), + onSurface = Color(0xFFE3E2E6), + surfaceVariant = Color(0xFF43474E), + onSurfaceVariant = Color(0xFFC3C7CF), + outline = Color(0xFF8D9199) +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF0061A4), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD1E4FF), + onPrimaryContainer = Color(0xFF001D36), + secondary = Color(0xFF535F70), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFD7E3F7), + onSecondaryContainer = Color(0xFF101C2B), + tertiary = Color(0xFF6B5778), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFF2DAFF), + onTertiaryContainer = Color(0xFF251431), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + background = Color(0xFFFDFCFF), + onBackground = Color(0xFF1A1C1E), + surface = Color(0xFFFDFCFF), + onSurface = Color(0xFF1A1C1E), + surfaceVariant = Color(0xFFDFE2EB), + onSurfaceVariant = Color(0xFF43474E), + outline = Color(0xFF73777F) +) + +@Composable +fun TikuTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content + ) +} diff --git a/app/src/main/java/cn/tiku/app/utils/TokenManager.kt b/app/src/main/java/cn/tiku/app/utils/TokenManager.kt new file mode 100644 index 0000000..0f8869c --- /dev/null +++ b/app/src/main/java/cn/tiku/app/utils/TokenManager.kt @@ -0,0 +1,57 @@ +package cn.tiku.app.utils + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class TokenManager(private val context: Context) { + + companion object { + private val TOKEN_KEY = stringPreferencesKey("auth_token") + private val USERNAME_KEY = stringPreferencesKey("username") + private val USER_ID_KEY = stringPreferencesKey("user_id") + } + + val token: Flow + get() = context.dataStore.data.map { preferences -> + preferences[TOKEN_KEY] + } + + val username: Flow + get() = context.dataStore.data.map { preferences -> + preferences[USERNAME_KEY] + } + + val userId: Flow + get() = context.dataStore.data.map { preferences -> + preferences[USER_ID_KEY] + } + + suspend fun saveToken(token: String) { + context.dataStore.edit { preferences -> + preferences[TOKEN_KEY] = token + } + } + + suspend fun saveUserInfo(username: String, userId: String) { + context.dataStore.edit { preferences -> + preferences[USERNAME_KEY] = username + preferences[USER_ID_KEY] = userId + } + } + + suspend fun clearAll() { + context.dataStore.edit { preferences -> + preferences.clear() + } + } + + fun getAuthHeader(token: String): String = "Bearer $token" +} diff --git a/app/src/main/java/cn/tiku/app/viewmodel/AuthViewModel.kt b/app/src/main/java/cn/tiku/app/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..178d877 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/viewmodel/AuthViewModel.kt @@ -0,0 +1,94 @@ +package cn.tiku.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import cn.tiku.app.model.LoginResponse +import cn.tiku.app.model.UserInfo +import cn.tiku.app.repository.TikuRepository +import cn.tiku.app.utils.TokenManager +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class AuthViewModel(application: Application) : AndroidViewModel(application) { + + private val tokenManager = TokenManager(application) + private val repository = TikuRepository(tokenManager) + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _currentUser.asStateFlow() + + val token: StateFlow = tokenManager.token.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null + ) + + val username: StateFlow = tokenManager.username.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null + ) + + init { + checkLoginStatus() + } + + private fun checkLoginStatus() { + viewModelScope.launch { + token.collect { tokenValue -> + _isLoggedIn.value = !tokenValue.isNullOrEmpty() + if (!tokenValue.isNullOrEmpty()) { + fetchCurrentUser(tokenValue) + } + } + } + } + + private fun fetchCurrentUser(token: String) { + viewModelScope.launch { + repository.getCurrentUser(token).onSuccess { user -> + _currentUser.value = user + } + } + } + + fun login(username: String, safekey: String) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + repository.login(username, safekey) + .onSuccess { response -> + _currentUser.value = response.user + _isLoggedIn.value = true + } + .onFailure { exception -> + _error.value = exception.message ?: "登录失败" + } + + _isLoading.value = false + } + } + + fun logout() { + viewModelScope.launch { + tokenManager.clearAll() + _currentUser.value = null + _isLoggedIn.value = false + } + } + + fun clearError() { + _error.value = null + } +} diff --git a/app/src/main/java/cn/tiku/app/viewmodel/BlogViewModel.kt b/app/src/main/java/cn/tiku/app/viewmodel/BlogViewModel.kt new file mode 100644 index 0000000..15047e7 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/viewmodel/BlogViewModel.kt @@ -0,0 +1,62 @@ +package cn.tiku.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import cn.tiku.app.model.Blog +import cn.tiku.app.repository.TikuRepository +import cn.tiku.app.utils.TokenManager +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class BlogViewModel(application: Application) : AndroidViewModel(application) { + + private val tokenManager = TokenManager(application) + private val repository = TikuRepository(tokenManager) + + private val _blogs = MutableStateFlow>(emptyList()) + val blogs: StateFlow> = _blogs.asStateFlow() + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _currentPage = MutableStateFlow(1) + val currentPage: StateFlow = _currentPage.asStateFlow() + + private val _totalPages = MutableStateFlow(1) + val totalPages: StateFlow = _totalPages.asStateFlow() + + init { + loadBlogs() + } + + fun loadBlogs(page: Int = 1) { + viewModelScope.launch { + _isLoading.value = true + + val result = repository.getBlogs(page) + result.onSuccess { response -> + _blogs.value = response.results + _currentPage.value = response.page + _totalPages.value = response.pages + } + + _isLoading.value = false + } + } + + fun nextPage() { + if (_currentPage.value < _totalPages.value) { + loadBlogs(_currentPage.value + 1) + } + } + + fun prevPage() { + if (_currentPage.value > 1) { + loadBlogs(_currentPage.value - 1) + } + } +} diff --git a/app/src/main/java/cn/tiku/app/viewmodel/ProfileViewModel.kt b/app/src/main/java/cn/tiku/app/viewmodel/ProfileViewModel.kt new file mode 100644 index 0000000..fb722bd --- /dev/null +++ b/app/src/main/java/cn/tiku/app/viewmodel/ProfileViewModel.kt @@ -0,0 +1,85 @@ +package cn.tiku.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import cn.tiku.app.model.Blog +import cn.tiku.app.model.LearningStats +import cn.tiku.app.model.Question +import cn.tiku.app.repository.TikuRepository +import cn.tiku.app.utils.TokenManager +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class ProfileViewModel(application: Application) : AndroidViewModel(application) { + + private val tokenManager = TokenManager(application) + private val repository = TikuRepository(tokenManager) + + private val _learningStats = MutableStateFlow(null) + val learningStats: StateFlow = _learningStats.asStateFlow() + + private val _favoriteQuestions = MutableStateFlow>(emptyList()) + val favoriteQuestions: StateFlow> = _favoriteQuestions.asStateFlow() + + private val _favoriteBlogs = MutableStateFlow>(emptyList()) + val favoriteBlogs: StateFlow> = _favoriteBlogs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun loadLearningStats() { + viewModelScope.launch { + _isLoading.value = true + + tokenManager.token.first()?.let { token -> + repository.getLearningStats(token) + .onSuccess { stats -> + _learningStats.value = stats + } + .onFailure { exception -> + _error.value = exception.message + } + } + + _isLoading.value = false + } + } + + fun loadFavorites(type: String? = null) { + viewModelScope.launch { + _isLoading.value = true + + tokenManager.token.first()?.let { token -> + repository.getFavorites(token, type = type) + .onSuccess { response -> + _favoriteQuestions.value = response.questions + _favoriteBlogs.value = response.blogs + } + .onFailure { exception -> + _error.value = exception.message + } + } + + _isLoading.value = false + } + } + + fun removeFavorite(type: String, itemId: String) { + viewModelScope.launch { + tokenManager.token.first()?.let { token -> + repository.toggleFavorite(token, type, itemId) + .onSuccess { + loadFavorites() + } + } + } + } + + fun clearError() { + _error.value = null + } +} diff --git a/app/src/main/java/cn/tiku/app/viewmodel/QuestionViewModel.kt b/app/src/main/java/cn/tiku/app/viewmodel/QuestionViewModel.kt new file mode 100644 index 0000000..c0d0924 --- /dev/null +++ b/app/src/main/java/cn/tiku/app/viewmodel/QuestionViewModel.kt @@ -0,0 +1,155 @@ +package cn.tiku.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import cn.tiku.app.model.Question +import cn.tiku.app.repository.TikuRepository +import cn.tiku.app.utils.TokenManager +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class QuestionViewModel(application: Application) : AndroidViewModel(application) { + + private val tokenManager = TokenManager(application) + private val repository = TikuRepository(tokenManager) + + private val _questions = MutableStateFlow>(emptyList()) + val questions: StateFlow> = _questions.asStateFlow() + + private val _currentQuestion = MutableStateFlow(null) + val currentQuestion: StateFlow = _currentQuestion.asStateFlow() + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _currentPage = MutableStateFlow(1) + val currentPage: StateFlow = _currentPage.asStateFlow() + + private val _totalPages = MutableStateFlow(1) + val totalPages: StateFlow = _totalPages.asStateFlow() + + private val _totalQuestions = MutableStateFlow(0) + val totalQuestions: StateFlow = _totalQuestions.asStateFlow() + + private val _selectedCategory = MutableStateFlow(null) + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _showAnswer = MutableStateFlow(false) + val showAnswer: StateFlow = _showAnswer.asStateFlow() + + init { + loadCategories() + loadQuestions() + } + + fun loadQuestions(page: Int = 1) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + repository.getQuestions( + page = page, + query = _searchQuery.value.ifEmpty { null }, + category = _selectedCategory.value + ) + .onSuccess { response -> + _questions.value = response.results + _currentPage.value = response.page + _totalPages.value = response.pages + _totalQuestions.value = response.total + } + .onFailure { exception -> + _error.value = exception.message ?: "加载失败" + } + + _isLoading.value = false + } + } + + fun loadCategories() { + viewModelScope.launch { + repository.getCategories() + .onSuccess { categories -> + _categories.value = categories + } + } + } + + fun loadQuestionById(id: String) { + viewModelScope.launch { + _isLoading.value = true + _showAnswer.value = false + + repository.getQuestionById(id) + .onSuccess { question -> + _currentQuestion.value = question + } + .onFailure { exception -> + _error.value = exception.message ?: "加载失败" + } + + _isLoading.value = false + } + } + + fun toggleShowAnswer() { + _showAnswer.value = !_showAnswer.value + } + + fun setCategory(category: String?) { + _selectedCategory.value = category + _currentPage.value = 1 + loadQuestions(1) + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun search() { + _currentPage.value = 1 + loadQuestions(1) + } + + fun nextPage() { + if (_currentPage.value < _totalPages.value) { + loadQuestions(_currentPage.value + 1) + } + } + + fun prevPage() { + if (_currentPage.value > 1) { + loadQuestions(_currentPage.value - 1) + } + } + + fun recordLearning(questionId: String, correct: Boolean) { + viewModelScope.launch { + tokenManager.token.first()?.let { token -> + repository.recordLearning(token, questionId, correct) + } + } + } + + fun toggleFavorite(questionId: String) { + viewModelScope.launch { + tokenManager.token.first()?.let { token -> + repository.toggleFavorite(token, "question", questionId) + } + } + } + + fun clearError() { + _error.value = null + } +} diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d8601a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d8601a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..2440976 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml new file mode 100644 index 0000000..2440976 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..85a6939 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 题库 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ff12767 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +