Initial commit: Android题库APP - Jetpack Compose + Retrofit
Some checks failed
Build and Release APK / build (push) Has been cancelled
Build and Release APK / release (push) Has been cancelled

This commit is contained in:
爱喝水的木子
2026-04-01 18:32:18 +08:00
commit 98fc3ff879
43 changed files with 3116 additions and 0 deletions

88
app/build.gradle.kts Normal file
View File

@@ -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")
}

29
app/proguard-rules.pro vendored Normal file
View File

@@ -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.** { *; }

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TikuApp"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TikuApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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
)
}

View File

@@ -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<String>?,
val createdAt: String?,
val updatedAt: String?
)
data class QuestionsResponse(
val total: Int,
val page: Int,
val pages: Int,
val results: List<Question>
)
data class QuestionIdsResponse(
val ids: List<String>,
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<Question>,
val blogs: List<Blog>,
val cases: List<Case>,
val quotes: List<Quote>,
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<Blog>
)
data class QuoteListResponse(
val total: Int,
val page: Int,
val pages: Int,
val results: List<Quote>
)
data class Article(
val id: String,
val title: String,
val content: String,
val author: String?,
val createdAt: String?
)

View File

@@ -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() }
)
}
}
}

View File

@@ -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<CheckResponse>
@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@GET("api/auth/me")
suspend fun getCurrentUser(@Header("Authorization") token: String): Response<UserInfo>
// 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<QuestionsResponse>
@GET("api/questions/categories")
suspend fun getCategories(): Response<List<String>>
@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<QuestionIdsResponse>
@GET("api/questions/{id}")
suspend fun getQuestionById(@Path("id") id: String): Response<Question>
@POST("api/questions/{id}/favorite")
suspend fun toggleQuestionFavorite(
@Path("id") id: String,
@Header("Authorization") token: String
): Response<FavoriteResponse>
@POST("api/questions/{id}/learn")
suspend fun recordLearning(
@Path("id") id: String,
@Header("Authorization") token: String,
@Body request: LearnRequest
): Response<LearnResponse>
// User
@GET("api/users/me/learning-stats")
suspend fun getLearningStats(
@Header("Authorization") token: String
): Response<LearningStats>
@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<FavoritesResponse>
@POST("api/users/me/favorites")
suspend fun toggleFavorite(
@Header("Authorization") token: String,
@Body request: FavoriteRequest
): Response<FavoriteResponse>
@GET("api/users/me/profile")
suspend fun getProfile(
@Header("Authorization") token: String
): Response<UserProfile>
// 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<BlogListResponse>
@GET("api/blogs/categories")
suspend fun getBlogCategories(): Response<List<String>>
@GET("api/blogs/{id}")
suspend fun getBlogById(@Path("id") id: String): Response<Blog>
// Quotes
@GET("api/quotes")
suspend fun getQuotes(
@Query("page") page: Int = 1,
@Query("limit") limit: Int = 20
): Response<QuoteListResponse>
}

View File

@@ -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)
}
}

View File

@@ -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<CheckResponse> {
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<LoginResponse> {
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<UserInfo> {
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<QuestionsResponse> {
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<List<String>> {
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<Question> {
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<FavoriteResponse> {
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<LearnResponse> {
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<LearningStats> {
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<FavoritesResponse> {
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<BlogListResponse> {
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<QuoteListResponse> {
return try {
val response = api.getQuotes(page)
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取语录失败"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -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) }
)
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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("登录")
}
}
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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("查看答案")
}
}
}
}
}
}

View File

@@ -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
)
}
)
}
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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<Preferences> 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<String?>
get() = context.dataStore.data.map { preferences ->
preferences[TOKEN_KEY]
}
val username: Flow<String?>
get() = context.dataStore.data.map { preferences ->
preferences[USERNAME_KEY]
}
val userId: Flow<String?>
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"
}

View File

@@ -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<Boolean> = _isLoggedIn.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _currentUser = MutableStateFlow<UserInfo?>(null)
val currentUser: StateFlow<UserInfo?> = _currentUser.asStateFlow()
val token: StateFlow<String?> = tokenManager.token.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
null
)
val username: StateFlow<String?> = 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
}
}

View File

@@ -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<List<Blog>>(emptyList())
val blogs: StateFlow<List<Blog>> = _blogs.asStateFlow()
private val _categories = MutableStateFlow<List<String>>(emptyList())
val categories: StateFlow<List<String>> = _categories.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _currentPage = MutableStateFlow(1)
val currentPage: StateFlow<Int> = _currentPage.asStateFlow()
private val _totalPages = MutableStateFlow(1)
val totalPages: StateFlow<Int> = _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)
}
}
}

View File

@@ -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<LearningStats?>(null)
val learningStats: StateFlow<LearningStats?> = _learningStats.asStateFlow()
private val _favoriteQuestions = MutableStateFlow<List<Question>>(emptyList())
val favoriteQuestions: StateFlow<List<Question>> = _favoriteQuestions.asStateFlow()
private val _favoriteBlogs = MutableStateFlow<List<Blog>>(emptyList())
val favoriteBlogs: StateFlow<List<Blog>> = _favoriteBlogs.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _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
}
}

View File

@@ -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<List<Question>>(emptyList())
val questions: StateFlow<List<Question>> = _questions.asStateFlow()
private val _currentQuestion = MutableStateFlow<Question?>(null)
val currentQuestion: StateFlow<Question?> = _currentQuestion.asStateFlow()
private val _categories = MutableStateFlow<List<String>>(emptyList())
val categories: StateFlow<List<String>> = _categories.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _currentPage = MutableStateFlow(1)
val currentPage: StateFlow<Int> = _currentPage.asStateFlow()
private val _totalPages = MutableStateFlow(1)
val totalPages: StateFlow<Int> = _totalPages.asStateFlow()
private val _totalQuestions = MutableStateFlow(0)
val totalQuestions: StateFlow<Int> = _totalQuestions.asStateFlow()
private val _selectedCategory = MutableStateFlow<String?>(null)
val selectedCategory: StateFlow<String?> = _selectedCategory.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _showAnswer = MutableStateFlow(false)
val showAnswer: StateFlow<Boolean> = _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
}
}

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M54,27c-14.9,0 -27,12.1 -27,27s12.1,27 27,27 27,-12.1 27,-27 -12.1,-27 -27,-27zM54,75.6c-11.9,0 -21.6,-9.7 -21.6,-21.6s9.7,-21.6 21.6,-21.6 21.6,9.7 21.6,21.6 -9.7,21.6 -21.6,21.6z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M54,42c-6.6,0 -12,5.4 -12,12s5.4,12 12,12 12,-5.4 12,-12 -5.4,-12 -12,-12zM54,60c-3.3,0 -6,-2.7 -6,-6s2.7,-6 6,-6 6,2.7 6,6 -2.7,6 -6,6z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M54,27c-14.9,0 -27,12.1 -27,27s12.1,27 27,27 27,-12.1 27,-27 -12.1,-27 -27,-27zM54,75.6c-11.9,0 -21.6,-9.7 -21.6,-21.6s9.7,-21.6 21.6,-21.6 21.6,9.7 21.6,21.6 -9.7,21.6 -21.6,21.6z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M54,42c-6.6,0 -12,5.4 -12,12s5.4,12 12,12 12,-5.4 12,-12 -5.4,-12 -12,-12zM54,60c-3.3,0 -6,-2.7 -6,-6s2.7,-6 6,-6 6,2.7 6,6 -2.7,6 -6,6z"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h48v48h-48z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M24,8c-8.8,0 -16,7.2 -16,16s7.2,16 16,16 16,-7.2 16,-16 -7.2,-16 -16,-16zM24,36c-6.6,0 -12,-5.4 -12,-12s5.4,-12 12,-12 12,5.4 12,12 -5.4,12 -12,12z"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h48v48h-48z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M24,8c-8.8,0 -16,7.2 -16,16s7.2,16 16,16 16,-7.2 16,-16 -7.2,-16 -16,-16zM24,36c-6.6,0 -12,-5.4 -12,-12s5.4,-12 12,-12 12,5.4 12,12 -5.4,12 -12,12z"/>
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">题库</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TikuApp" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<include domain="file" path="."/>
</cloud-backup>
</data-extraction-rules>