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

View File

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

38
.gitignore vendored Normal file
View File

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

39
README.md Normal file
View File

@@ -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安装使用。

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>

45
build.bat Normal file
View File

@@ -0,0 +1,45 @@
@echo off
echo ========================================
echo Build Android Project
echo ========================================
echo.
:: Check gradle-wrapper.jar exists
if not exist "gradle\wrapper\gradle-wrapper.jar" (
echo [ERROR] gradle-wrapper.jar not found!
echo.
echo Please run fix script first:
echo fix_gradle_wrapper.bat
echo.
echo Or download manually:
echo https://github.com/gradle/gradle/tree/v8.13.0/gradle/wrapper
echo.
pause
exit /b 1
)
echo [1/4] Clean project build directory...
if exist "build" rmdir /s /q build
if exist "app\build" rmdir /s /q app\build
echo Done!
echo.
echo [2/4] Clean Gradle cache...
if exist "%USERPROFILE%\.gradle\caches\8.13\groovy-dsl\070ba2b51e09fa9c0e770e7c757cc8a3" rmdir /s /q "%USERPROFILE%\.gradle\caches\8.13\groovy-dsl\070ba2b51e09fa9c0e770e7c757cc8a3" 2>nul
if exist "%USERPROFILE%\.gradle\caches\8.13\transforms\44e7089f2786c22da3ed6c59f9f06cc8" rmdir /s /q "%USERPROFILE%\.gradle\caches\8.13\transforms\44e7089f2786c22da3ed6c59f9f06cc8" 2>nul
echo Done!
echo.
echo [3/4] Run Gradle clean...
call gradlew.bat clean --no-daemon
echo.
echo [4/4] Build Release version...
call gradlew.bat assembleRelease --no-daemon --stacktrace
echo.
echo ========================================
echo Build Complete!
echo APK location: app\build\outputs\apk\release\app-release-unsigned.apk
echo ========================================
pause

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}

44
clean_and_build.ps1 Normal file
View File

@@ -0,0 +1,44 @@
# 清理 Gradle 缓存和项目构建目录
Write-Host "开始清理..." -ForegroundColor Green
# 清理项目 build 目录
Write-Host "清理项目 build 目录..." -ForegroundColor Yellow
if (Test-Path ".\build") {
Remove-Item -Path ".\build" -Recurse -Force
Write-Host "已清理 build 目录" -ForegroundColor Green
}
# 清理 app build 目录
Write-Host "清理 app build 目录..." -ForegroundColor Yellow
if (Test-Path ".\app\build") {
Remove-Item -Path ".\app\build" -Recurse -Force
Write-Host "已清理 app build 目录" -ForegroundColor Green
}
# 清理有问题的 Gradle 缓存
Write-Host "清理 Gradle 缓存..." -ForegroundColor Yellow
$gradleCachePath = "C:\Users\lee\.gradle\caches\8.13"
if (Test-Path $gradleCachePath) {
# 清理 groovy-dsl 缓存
$groovyDslPath = Join-Path $gradleCachePath "groovy-dsl\070ba2b51e09fa9c0e770e7c757cc8a3"
if (Test-Path $groovyDslPath) {
Remove-Item -Path $groovyDslPath -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "已清理 groovy-dsl 缓存" -ForegroundColor Green
}
# 清理 transforms 缓存
$transformsPath = Join-Path $gradleCachePath "transforms\44e7089f2786c22da3ed6c59f9f06cc8"
if (Test-Path $transformsPath) {
Remove-Item -Path $transformsPath -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "已清理 transforms 缓存" -ForegroundColor Green
}
}
Write-Host "`n清理完成!" -ForegroundColor Green
Write-Host "`n现在开始构建..." -ForegroundColor Yellow
# 执行构建
.\gradlew.bat clean assembleRelease --no-daemon --refresh-dependencies
Write-Host "`n构建完成!" -ForegroundColor Green
Write-Host "APK 文件位置app\build\outputs\apk\release\app-release-unsigned.apk" -ForegroundColor Cyan

42
fix_gradle_wrapper.bat Normal file
View File

@@ -0,0 +1,42 @@
@echo off
echo ========================================
echo Fix Gradle Wrapper
echo ========================================
echo.
set WRAPPER_JAR=gradle\wrapper\gradle-wrapper.jar
echo Check existing file...
if exist "%WRAPPER_JAR%" (
echo Delete old gradle-wrapper.jar
del /q "%WRAPPER_JAR%"
)
echo.
echo Download gradle-wrapper.jar...
echo.
powershell -Command "Invoke-WebRequest -Uri 'https://github.com/gradle/gradle/raw/v8.13.0/gradle/wrapper/gradle-wrapper.jar' -OutFile '%WRAPPER_JAR%' -UseBasicParsing"
echo.
if exist "%WRAPPER_JAR%" (
echo ========================================
echo SUCCESS! Download completed!
echo ========================================
echo.
echo Now you can run:
echo .\gradlew.bat assembleRelease --no-daemon
echo.
) else (
echo ========================================
echo FAILED! Download failed!
echo ========================================
echo.
echo Please download manually:
echo 1. Visit: https://github.com/gradle/gradle/tree/v8.13.0/gradle/wrapper
echo 2. Right-click gradle-wrapper.jar, save link as
echo 3. Save to: %CD%\gradle\wrapper\
echo.
)
pause

31
fix_gradle_wrapper.ps1 Normal file
View File

@@ -0,0 +1,31 @@
# Fix Gradle Wrapper - Download gradle-wrapper.jar
Write-Host "Starting Gradle Wrapper fix..." -ForegroundColor Green
$wrapperJarPath = ".\gradle\wrapper\gradle-wrapper.jar"
# Remove old file if exists
if (Test-Path $wrapperJarPath) {
Write-Host "Removing old gradle-wrapper.jar..." -ForegroundColor Yellow
Remove-Item -Path $wrapperJarPath -Force
}
# Download new file
Write-Host "Downloading gradle-wrapper.jar..." -ForegroundColor Yellow
try {
Invoke-WebRequest -Uri "https://github.com/gradle/gradle/raw/v8.13.0/gradle/wrapper/gradle-wrapper.jar" -OutFile $wrapperJarPath -UseBasicParsing
if (Test-Path $wrapperJarPath) {
Write-Host "SUCCESS! Download completed." -ForegroundColor Green
Write-Host "You can now run: .\gradlew.bat assembleRelease --no-daemon" -ForegroundColor Cyan
} else {
Write-Host "FAILED! File not found after download." -ForegroundColor Red
}
} catch {
Write-Host "Download failed: $_" -ForegroundColor Red
Write-Host "`nPlease download manually:" -ForegroundColor Yellow
Write-Host "1. Visit: https://github.com/gradle/gradle/tree/v8.13.0/gradle/wrapper" -ForegroundColor Cyan
Write-Host "2. Save gradle-wrapper.jar to: $($PWD.Path)\gradle\wrapper\" -ForegroundColor Cyan
}
Write-Host "`nPress any key to exit..." -ForegroundColor Gray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

91
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %OS%==Windows_NT endlocal
:omega
@exit /b %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%

17
settings.gradle.kts Normal file
View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TiKuApp"
include(":app")