Przeglądaj źródła

每日打卡,记录生活

sequoia00 1 miesiąc temu
commit
7d7aac9bd6

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+*.iml
+.gradle/
+local.properties
+.idea/
+.DS_Store
+/build/
+/app/build/
+/captures/
+.externalNativeBuild/
+.cxx/

+ 27 - 0
README.md

@@ -0,0 +1,27 @@
+# 目标打卡(Android)
+
+一个基于 Kotlin + Jetpack Compose 的中文打卡 App,支持:
+
+- 自定义目标(如健身、戒酒、学习英语)
+- 每日提醒时间设置
+- 当日打卡勾选
+- 历史记录追踪
+- 连续打卡与 30 天完成率统计
+
+## 技术栈
+
+- Kotlin
+- Jetpack Compose (Material 3)
+- Room
+- AlarmManager + BroadcastReceiver + Notification
+
+## 运行方式
+
+1. 使用 Android Studio 打开项目根目录。
+2. 等待 Gradle 同步完成。
+3. 连接真机或启动模拟器,直接运行 `app` 模块。
+
+## 说明
+
+- 首次启动会请求通知权限(Android 13+)。
+- 提醒使用系统闹钟能力;部分机型可能需要在系统设置中允许精确闹钟与后台活动。

+ 91 - 0
app/build.gradle.kts

@@ -0,0 +1,91 @@
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+    id("com.google.devtools.ksp")
+}
+
+android {
+    namespace = "com.tixing.app"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "com.tixing.app"
+        minSdk = 24
+        targetSdk = 35
+        versionCode = 1
+        versionName = "1.0"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+        isCoreLibraryDesugaringEnabled = true
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+
+    buildFeatures {
+        compose = true
+    }
+
+    composeOptions {
+        kotlinCompilerExtensionVersion = "1.5.14"
+    }
+
+    packaging {
+        resources {
+            excludes += "/META-INF/{AL2.0,LGPL2.1}"
+        }
+    }
+}
+
+dependencies {
+    val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
+    implementation(composeBom)
+    androidTestImplementation(composeBom)
+
+    implementation("androidx.core:core-ktx:1.13.1")
+    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
+    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
+    implementation("androidx.activity:activity-compose:1.9.1")
+    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
+
+    implementation("androidx.compose.ui:ui")
+    implementation("androidx.compose.ui:ui-tooling-preview")
+    implementation("androidx.compose.material3:material3")
+    implementation("androidx.compose.material:material-icons-extended")
+    implementation("androidx.navigation:navigation-compose:2.7.7")
+    implementation("com.google.android.material:material:1.12.0")
+
+    implementation("androidx.room:room-runtime:2.6.1")
+    implementation("androidx.room:room-ktx:2.6.1")
+    ksp("androidx.room:room-compiler:2.6.1")
+
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+    implementation("androidx.work:work-runtime-ktx:2.9.1")
+    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
+
+    testImplementation("junit:junit:4.13.2")
+    androidTestImplementation("androidx.test.ext:junit:1.2.1")
+    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+    debugImplementation("androidx.compose.ui:ui-tooling")
+    debugImplementation("androidx.compose.ui:ui-test-manifest")
+}

+ 1 - 0
app/proguard-rules.pro

@@ -0,0 +1 @@
+# Keep default for now.

+ 30 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="目标打卡"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.TiXingApp">
+
+        <receiver
+            android:name=".reminder.ReminderReceiver"
+            android:exported="false" />
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:theme="@style/Theme.TiXingApp">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>

+ 40 - 0
app/src/main/java/com/tixing/app/MainActivity.kt

@@ -0,0 +1,40 @@
+package com.tixing.app
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.tixing.app.ui.AppScreen
+import com.tixing.app.ui.AppViewModel
+import com.tixing.app.ui.TiXingTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+
+        setContent {
+            TiXingTheme {
+                val permissionLauncher = rememberLauncherForActivityResult(
+                    contract = ActivityResultContracts.RequestPermission(),
+                    onResult = {}
+                )
+                val vm: AppViewModel = viewModel()
+
+                LaunchedEffect(Unit) {
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                        permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+                    }
+                }
+
+                AppScreen(vm)
+            }
+        }
+    }
+}

+ 27 - 0
app/src/main/java/com/tixing/app/data/AppDatabase.kt

@@ -0,0 +1,27 @@
+package com.tixing.app.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(entities = [GoalEntity::class, CheckInEntity::class], version = 1, exportSchema = false)
+abstract class AppDatabase : RoomDatabase() {
+    abstract fun goalDao(): GoalDao
+    abstract fun checkInDao(): CheckInDao
+
+    companion object {
+        @Volatile
+        private var instance: AppDatabase? = null
+
+        fun getInstance(context: Context): AppDatabase {
+            return instance ?: synchronized(this) {
+                instance ?: Room.databaseBuilder(
+                    context.applicationContext,
+                    AppDatabase::class.java,
+                    "tixing.db"
+                ).build().also { instance = it }
+            }
+        }
+    }
+}

+ 25 - 0
app/src/main/java/com/tixing/app/data/CheckInDao.kt

@@ -0,0 +1,25 @@
+package com.tixing.app.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface CheckInDao {
+    @Query("SELECT * FROM checkins ORDER BY dateKey DESC, updatedAtMillis DESC")
+    fun observeAllCheckIns(): Flow<List<CheckInEntity>>
+
+    @Query("SELECT * FROM checkins WHERE goalId = :goalId ORDER BY dateKey DESC")
+    suspend fun getCheckInsByGoal(goalId: Int): List<CheckInEntity>
+
+    @Query("SELECT * FROM checkins WHERE goalId = :goalId AND dateKey = :dateKey LIMIT 1")
+    suspend fun getByGoalAndDate(goalId: Int, dateKey: String): CheckInEntity?
+
+    @Insert
+    suspend fun insertCheckIn(checkIn: CheckInEntity): Long
+
+    @Update
+    suspend fun updateCheckIn(checkIn: CheckInEntity)
+}

+ 47 - 0
app/src/main/java/com/tixing/app/data/Entities.kt

@@ -0,0 +1,47 @@
+package com.tixing.app.data
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "goals")
+data class GoalEntity(
+    @PrimaryKey(autoGenerate = true) val id: Int = 0,
+    val title: String,
+    val category: String,
+    val reminderHour: Int,
+    val reminderMinute: Int,
+    val isActive: Boolean = true,
+    val createdAtMillis: Long = System.currentTimeMillis()
+)
+
+@Entity(
+    tableName = "checkins",
+    foreignKeys = [
+        ForeignKey(
+            entity = GoalEntity::class,
+            parentColumns = ["id"],
+            childColumns = ["goalId"],
+            onDelete = ForeignKey.CASCADE
+        )
+    ],
+    indices = [Index(value = ["goalId"]), Index(value = ["dateKey", "goalId"], unique = true)]
+)
+data class CheckInEntity(
+    @PrimaryKey(autoGenerate = true) val id: Int = 0,
+    val goalId: Int,
+    val dateKey: String,
+    val isCompleted: Boolean,
+    val updatedAtMillis: Long = System.currentTimeMillis()
+)
+
+
+data class GoalProgress(
+    val goal: GoalEntity,
+    val completedToday: Boolean,
+    val currentStreak: Int,
+    val bestStreak: Int,
+    val completionRate30Days: Int,
+    val totalCompletedDays: Int
+)

+ 29 - 0
app/src/main/java/com/tixing/app/data/GoalDao.kt

@@ -0,0 +1,29 @@
+package com.tixing.app.data
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface GoalDao {
+    @Query("SELECT * FROM goals WHERE isActive = 1 ORDER BY createdAtMillis DESC")
+    fun observeActiveGoals(): Flow<List<GoalEntity>>
+
+    @Query("SELECT * FROM goals ORDER BY createdAtMillis DESC")
+    fun observeAllGoals(): Flow<List<GoalEntity>>
+
+    @Query("SELECT * FROM goals WHERE id = :goalId LIMIT 1")
+    suspend fun getGoalById(goalId: Int): GoalEntity?
+
+    @Insert
+    suspend fun insertGoal(goal: GoalEntity): Long
+
+    @Update
+    suspend fun updateGoal(goal: GoalEntity)
+
+    @Delete
+    suspend fun deleteGoal(goal: GoalEntity)
+}

+ 118 - 0
app/src/main/java/com/tixing/app/data/HabitRepository.kt

@@ -0,0 +1,118 @@
+package com.tixing.app.data
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+class HabitRepository(
+    private val goalDao: GoalDao,
+    private val checkInDao: CheckInDao
+) {
+    private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
+
+    fun observeGoalProgress(): Flow<List<GoalProgress>> {
+        return combine(goalDao.observeActiveGoals(), checkInDao.observeAllCheckIns()) { goals, checkIns ->
+            val today = LocalDate.now()
+            goals.map { goal ->
+                val goalCheckIns = checkIns.filter { it.goalId == goal.id && it.isCompleted }
+                val completedDays = goalCheckIns.map { LocalDate.parse(it.dateKey, formatter) }.toSet()
+                val todayCompleted = completedDays.contains(today)
+
+                val streaks = calculateStreaks(completedDays, today)
+                val completion30 = (0 until 30).count { offset ->
+                    completedDays.contains(today.minusDays(offset.toLong()))
+                }
+
+                GoalProgress(
+                    goal = goal,
+                    completedToday = todayCompleted,
+                    currentStreak = streaks.first,
+                    bestStreak = streaks.second,
+                    completionRate30Days = ((completion30 / 30f) * 100).toInt(),
+                    totalCompletedDays = completedDays.size
+                )
+            }
+        }
+    }
+
+    fun observeHistory(): Flow<List<HistoryRow>> {
+        return combine(goalDao.observeAllGoals(), checkInDao.observeAllCheckIns()) { goals, checkIns ->
+            val goalMap = goals.associateBy { it.id }
+            checkIns
+                .sortedByDescending { it.dateKey }
+                .mapNotNull { checkIn ->
+                    val goal = goalMap[checkIn.goalId] ?: return@mapNotNull null
+                    HistoryRow(
+                        goalTitle = goal.title,
+                        category = goal.category,
+                        dateKey = checkIn.dateKey,
+                        isCompleted = checkIn.isCompleted
+                    )
+                }
+        }
+    }
+
+    suspend fun addGoal(title: String, category: String, reminderHour: Int, reminderMinute: Int): GoalEntity {
+        val id = goalDao.insertGoal(
+            GoalEntity(
+                title = title,
+                category = category,
+                reminderHour = reminderHour,
+                reminderMinute = reminderMinute
+            )
+        ).toInt()
+        return goalDao.getGoalById(id)!!
+    }
+
+    suspend fun deleteGoal(goal: GoalEntity) {
+        goalDao.deleteGoal(goal)
+    }
+
+    suspend fun setTodayCheckIn(goalId: Int, isCompleted: Boolean) {
+        val todayKey = LocalDate.now().format(formatter)
+        val existing = checkInDao.getByGoalAndDate(goalId, todayKey)
+        if (existing == null) {
+            checkInDao.insertCheckIn(
+                CheckInEntity(
+                    goalId = goalId,
+                    dateKey = todayKey,
+                    isCompleted = isCompleted
+                )
+            )
+        } else {
+            checkInDao.updateCheckIn(existing.copy(isCompleted = isCompleted, updatedAtMillis = System.currentTimeMillis()))
+        }
+    }
+
+    private fun calculateStreaks(days: Set<LocalDate>, today: LocalDate): Pair<Int, Int> {
+        var current = 0
+        var cursor = if (days.contains(today)) today else today.minusDays(1)
+        while (days.contains(cursor)) {
+            current++
+            cursor = cursor.minusDays(1)
+        }
+
+        val sorted = days.sorted()
+        var best = 0
+        var running = 0
+        var last: LocalDate? = null
+        for (day in sorted) {
+            if (last == null || day == last.plusDays(1)) {
+                running++
+            } else {
+                running = 1
+            }
+            best = maxOf(best, running)
+            last = day
+        }
+        return current to best
+    }
+}
+
+data class HistoryRow(
+    val goalTitle: String,
+    val category: String,
+    val dateKey: String,
+    val isCompleted: Boolean
+)

+ 83 - 0
app/src/main/java/com/tixing/app/reminder/ReminderReceiver.kt

@@ -0,0 +1,83 @@
+package com.tixing.app.reminder
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.pm.PackageManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.tixing.app.MainActivity
+import com.tixing.app.R
+import com.tixing.app.data.GoalEntity
+
+class ReminderReceiver : BroadcastReceiver() {
+    override fun onReceive(context: Context, intent: Intent) {
+        createChannel(context)
+
+        val goalId = intent.getIntExtra(ReminderScheduler.EXTRA_GOAL_ID, -1)
+        val goalTitle = intent.getStringExtra(ReminderScheduler.EXTRA_GOAL_TITLE).orEmpty()
+        val hour = intent.getIntExtra(ReminderScheduler.EXTRA_HOUR, 21)
+        val minute = intent.getIntExtra(ReminderScheduler.EXTRA_MINUTE, 0)
+
+        if (goalId == -1 || goalTitle.isBlank()) return
+
+        val openIntent = Intent(context, MainActivity::class.java)
+        val openPendingIntent = PendingIntent.getActivity(
+            context,
+            goalId,
+            openIntent,
+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+
+        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+            .setSmallIcon(R.drawable.ic_launcher_foreground)
+            .setContentTitle("该打卡了")
+            .setContentText("目标:$goalTitle,别让今天断签")
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .setAutoCancel(true)
+            .setContentIntent(openPendingIntent)
+            .build()
+
+        if (
+            Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+            ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+        ) {
+            NotificationManagerCompat.from(context).notify(goalId, notification)
+        }
+
+        ReminderScheduler.scheduleDailyReminder(
+            context,
+            GoalEntity(
+                id = goalId,
+                title = goalTitle,
+                category = "",
+                reminderHour = hour,
+                reminderMinute = minute,
+                isActive = true
+            )
+        )
+    }
+
+    private fun createChannel(context: Context) {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        val channel = NotificationChannel(
+            CHANNEL_ID,
+            "打卡提醒",
+            NotificationManager.IMPORTANCE_DEFAULT
+        ).apply {
+            description = "目标打卡提醒通知"
+        }
+        manager.createNotificationChannel(channel)
+    }
+
+    companion object {
+        private const val CHANNEL_ID = "check_in_reminder"
+    }
+}

+ 68 - 0
app/src/main/java/com/tixing/app/reminder/ReminderScheduler.kt

@@ -0,0 +1,68 @@
+package com.tixing.app.reminder
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import com.tixing.app.data.GoalEntity
+import java.util.Calendar
+
+object ReminderScheduler {
+    const val EXTRA_GOAL_ID = "goal_id"
+    const val EXTRA_GOAL_TITLE = "goal_title"
+    const val EXTRA_HOUR = "hour"
+    const val EXTRA_MINUTE = "minute"
+
+    fun scheduleDailyReminder(context: Context, goal: GoalEntity) {
+        val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+        val intent = Intent(context, ReminderReceiver::class.java).apply {
+            putExtra(EXTRA_GOAL_ID, goal.id)
+            putExtra(EXTRA_GOAL_TITLE, goal.title)
+            putExtra(EXTRA_HOUR, goal.reminderHour)
+            putExtra(EXTRA_MINUTE, goal.reminderMinute)
+        }
+
+        val pendingIntent = PendingIntent.getBroadcast(
+            context,
+            goal.id,
+            intent,
+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+
+        val triggerAt = nextTriggerMillis(goal.reminderHour, goal.reminderMinute)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
+            alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
+            return
+        }
+
+        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
+    }
+
+    fun cancelReminder(context: Context, goalId: Int) {
+        val intent = Intent(context, ReminderReceiver::class.java)
+        val pendingIntent = PendingIntent.getBroadcast(
+            context,
+            goalId,
+            intent,
+            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+        )
+        val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+        alarmManager.cancel(pendingIntent)
+    }
+
+    private fun nextTriggerMillis(hour: Int, minute: Int): Long {
+        val now = Calendar.getInstance()
+        val trigger = Calendar.getInstance().apply {
+            set(Calendar.HOUR_OF_DAY, hour)
+            set(Calendar.MINUTE, minute)
+            set(Calendar.SECOND, 0)
+            set(Calendar.MILLISECOND, 0)
+            if (before(now)) {
+                add(Calendar.DAY_OF_MONTH, 1)
+            }
+        }
+        return trigger.timeInMillis
+    }
+}

+ 503 - 0
app/src/main/java/com/tixing/app/ui/AppScreen.kt

@@ -0,0 +1,503 @@
+package com.tixing.app.ui
+
+import android.app.TimePickerDialog
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.ListAlt
+import androidx.compose.material.icons.filled.QueryStats
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.tixing.app.data.GoalProgress
+import com.tixing.app.data.HistoryRow
+import com.tixing.app.util.DateUtils
+
+private enum class HomeTab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
+    TODAY("今日", Icons.Default.CheckCircle),
+    GOALS("目标", Icons.Default.ListAlt),
+    HISTORY("历史", Icons.Default.DateRange),
+    STATS("统计", Icons.Default.QueryStats)
+}
+
+@Composable
+fun AppScreen(vm: AppViewModel) {
+    val goals by vm.goalProgress.collectAsStateWithLifecycle()
+    val history by vm.history.collectAsStateWithLifecycle()
+    var tab by remember { mutableStateOf(HomeTab.TODAY) }
+    var showAddDialog by remember { mutableStateOf(false) }
+
+    Scaffold(
+        modifier = Modifier.fillMaxSize(),
+        topBar = {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .statusBarsPadding()
+                    .padding(horizontal = 20.dp, vertical = 12.dp)
+            ) {
+                Text(
+                    text = "目标打卡",
+                    style = MaterialTheme.typography.headlineMedium,
+                    fontWeight = FontWeight.Bold
+                )
+                Text(
+                    text = "长期坚持,结果会说话",
+                    style = MaterialTheme.typography.bodyMedium,
+                    color = MaterialTheme.colorScheme.onSurfaceVariant
+                )
+            }
+        },
+        floatingActionButton = {
+            if (tab == HomeTab.GOALS) {
+                FloatingActionButton(onClick = { showAddDialog = true }) {
+                    Icon(Icons.Default.Add, contentDescription = "添加目标")
+                }
+            }
+        },
+        bottomBar = {
+            NavigationBar(modifier = Modifier.navigationBarsPadding()) {
+                HomeTab.entries.forEach { item ->
+                    NavigationBarItem(
+                        selected = item == tab,
+                        onClick = { tab = item },
+                        icon = { Icon(item.icon, contentDescription = item.label) },
+                        label = { Text(item.label) }
+                    )
+                }
+            }
+        }
+    ) { innerPadding ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(
+                    Brush.verticalGradient(
+                        colors = listOf(
+                            MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.65f),
+                            MaterialTheme.colorScheme.background
+                        )
+                    )
+                )
+                .padding(innerPadding)
+        ) {
+            when (tab) {
+                HomeTab.TODAY -> TodayScreen(goals = goals, onToggle = vm::toggleToday)
+                HomeTab.GOALS -> GoalsScreen(goals = goals, onDelete = vm::deleteGoal)
+                HomeTab.HISTORY -> HistoryScreen(history = history)
+                HomeTab.STATS -> StatsScreen(goals = goals)
+            }
+        }
+    }
+
+    if (showAddDialog) {
+        AddGoalDialog(
+            onDismiss = { showAddDialog = false },
+            onConfirm = { title, category, hour, minute ->
+                vm.addGoal(title, category, hour, minute)
+                showAddDialog = false
+            }
+        )
+    }
+}
+
+@Composable
+private fun TodayScreen(
+    goals: List<GoalProgress>,
+    onToggle: (Int, Boolean) -> Unit
+) {
+    if (goals.isEmpty()) {
+        EmptyTip("还没有目标", "去“目标”页添加你的第一个打卡计划")
+        return
+    }
+
+    LazyColumn(
+        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        item {
+            Card(
+                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+                shape = RoundedCornerShape(20.dp)
+            ) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(16.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.SpaceBetween
+                ) {
+                    Text(
+                        text = "今日完成率",
+                        style = MaterialTheme.typography.titleMedium,
+                        fontWeight = FontWeight.SemiBold
+                    )
+                    val percent = if (goals.isNotEmpty()) {
+                        ((goals.count { it.completedToday } / goals.size.toFloat()) * 100).toInt()
+                    } else {
+                        0
+                    }
+                    Text(
+                        text = "$percent%",
+                        style = MaterialTheme.typography.titleLarge,
+                        color = MaterialTheme.colorScheme.primary,
+                        fontWeight = FontWeight.Bold
+                    )
+                }
+            }
+        }
+
+        items(goals, key = { it.goal.id }) { item ->
+            Card(
+                shape = RoundedCornerShape(18.dp),
+                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+            ) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 14.dp, vertical = 12.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.SpaceBetween
+                ) {
+                    Column(modifier = Modifier.weight(1f)) {
+                        Text(
+                            text = item.goal.title,
+                            style = MaterialTheme.typography.titleMedium,
+                            fontWeight = FontWeight.SemiBold
+                        )
+                        Spacer(modifier = Modifier.height(4.dp))
+                        Text(
+                            text = "${item.goal.category} · 提醒 ${DateUtils.toMinutesLabel(item.goal.reminderHour, item.goal.reminderMinute)}",
+                            style = MaterialTheme.typography.bodySmall,
+                            color = MaterialTheme.colorScheme.onSurfaceVariant
+                        )
+                        Spacer(modifier = Modifier.height(6.dp))
+                        Text(
+                            text = "当前连续 ${item.currentStreak} 天",
+                            style = MaterialTheme.typography.bodyMedium,
+                            color = MaterialTheme.colorScheme.primary
+                        )
+                    }
+                    Checkbox(
+                        checked = item.completedToday,
+                        onCheckedChange = { checked -> onToggle(item.goal.id, checked) }
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun GoalsScreen(
+    goals: List<GoalProgress>,
+    onDelete: (GoalProgress) -> Unit
+) {
+    if (goals.isEmpty()) {
+        EmptyTip("没有进行中的目标", "点击右下角 + 创建新目标")
+        return
+    }
+
+    LazyColumn(
+        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
+        verticalArrangement = Arrangement.spacedBy(10.dp)
+    ) {
+        items(goals, key = { it.goal.id }) { item ->
+            Card(shape = RoundedCornerShape(18.dp)) {
+                Column(modifier = Modifier.padding(14.dp)) {
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.SpaceBetween,
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Column {
+                            Text(item.goal.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                            Text(
+                                "${item.goal.category} · 每天 ${DateUtils.toMinutesLabel(item.goal.reminderHour, item.goal.reminderMinute)}",
+                                style = MaterialTheme.typography.bodySmall,
+                                color = MaterialTheme.colorScheme.onSurfaceVariant
+                            )
+                        }
+                        IconButton(onClick = { onDelete(item) }) {
+                            Icon(Icons.Default.Delete, contentDescription = "删除")
+                        }
+                    }
+                    HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp))
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.SpaceBetween
+                    ) {
+                        MiniStat("总打卡", "${item.totalCompletedDays}天")
+                        MiniStat("当前连签", "${item.currentStreak}天")
+                        MiniStat("最佳连签", "${item.bestStreak}天")
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun HistoryScreen(history: List<HistoryRow>) {
+    if (history.isEmpty()) {
+        EmptyTip("暂无历史记录", "完成一次打卡后,这里会出现你的轨迹")
+        return
+    }
+
+    val grouped = history.groupBy { it.dateKey }
+
+    LazyColumn(
+        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        grouped.forEach { (date, rows) ->
+            item(key = date) {
+                Card(shape = RoundedCornerShape(18.dp)) {
+                    Column(modifier = Modifier.padding(14.dp)) {
+                        Text(
+                            text = DateUtils.toDisplayDate(date),
+                            style = MaterialTheme.typography.titleMedium,
+                            fontWeight = FontWeight.Bold
+                        )
+                        Spacer(modifier = Modifier.height(8.dp))
+                        rows.forEachIndexed { index, row ->
+                            Row(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(vertical = 6.dp),
+                                horizontalArrangement = Arrangement.SpaceBetween,
+                                verticalAlignment = Alignment.CenterVertically
+                            ) {
+                                Text(
+                                    text = "${row.goalTitle}(${row.category})",
+                                    style = MaterialTheme.typography.bodyMedium
+                                )
+                                Text(
+                                    text = if (row.isCompleted) "已完成" else "未完成",
+                                    color = if (row.isCompleted) Color(0xFF227D5F) else MaterialTheme.colorScheme.error,
+                                    style = MaterialTheme.typography.bodyMedium,
+                                    fontWeight = FontWeight.SemiBold
+                                )
+                            }
+                            if (index != rows.lastIndex) HorizontalDivider()
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun StatsScreen(goals: List<GoalProgress>) {
+    if (goals.isEmpty()) {
+        EmptyTip("没有可统计的数据", "添加目标并坚持打卡后,这里会显示趋势")
+        return
+    }
+
+    val totalGoals = goals.size
+    val totalDone = goals.sumOf { it.totalCompletedDays }
+    val avgRate = goals.map { it.completionRate30Days }.average().toInt()
+
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .verticalScroll(rememberScrollState())
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp)
+    ) {
+        Card(shape = RoundedCornerShape(20.dp)) {
+            Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                Text("全局表现", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+                Text("进行中目标:$totalGoals")
+                Text("累计打卡次数:$totalDone")
+                Text("近30天平均完成率:$avgRate%")
+            }
+        }
+
+        goals.forEach { item ->
+            Card(shape = RoundedCornerShape(18.dp)) {
+                Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                    Text(item.goal.title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium)
+                    Text(
+                        "当前 ${item.currentStreak} 天,最佳 ${item.bestStreak} 天",
+                        style = MaterialTheme.typography.bodyMedium,
+                        color = MaterialTheme.colorScheme.primary
+                    )
+                    Text("近30天完成率 ${item.completionRate30Days}%", style = MaterialTheme.typography.bodyMedium)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun MiniStat(label: String, value: String) {
+    Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+        Text(value, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+    }
+}
+
+@Composable
+private fun EmptyTip(title: String, desc: String) {
+    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+        Surface(
+            shape = RoundedCornerShape(20.dp),
+            tonalElevation = 3.dp,
+            modifier = Modifier.padding(24.dp)
+        ) {
+            Column(modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+                Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+                Spacer(modifier = Modifier.height(6.dp))
+                Text(desc, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+            }
+        }
+    }
+}
+
+@Composable
+private fun AddGoalDialog(
+    onDismiss: () -> Unit,
+    onConfirm: (String, String, Int, Int) -> Unit
+) {
+    var title by remember { mutableStateOf("") }
+    var selectedCategory by remember { mutableStateOf("健身") }
+    var hour by remember { mutableIntStateOf(21) }
+    var minute by remember { mutableIntStateOf(0) }
+    val context = LocalContext.current
+
+    val categories = listOf("健身", "戒酒", "学习英语", "阅读", "早睡", "自定义")
+
+    Dialog(onDismissRequest = onDismiss) {
+        Surface(shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth()) {
+            Column(
+                modifier = Modifier.padding(18.dp),
+                verticalArrangement = Arrangement.spacedBy(12.dp)
+            ) {
+                Text("新建目标", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+                OutlinedTextField(
+                    value = title,
+                    onValueChange = { title = it },
+                    label = { Text("目标名称") },
+                    placeholder = { Text("例如:晚饭后跑步30分钟") },
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+
+                Text("目标类别", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+                Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    categories.take(3).forEach { category ->
+                        FilterChip(
+                            selected = selectedCategory == category,
+                            onClick = { selectedCategory = category },
+                            label = { Text(category) }
+                        )
+                    }
+                }
+                Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    categories.drop(3).forEach { category ->
+                        FilterChip(
+                            selected = selectedCategory == category,
+                            onClick = { selectedCategory = category },
+                            label = { Text(category) }
+                        )
+                    }
+                }
+
+                Card(
+                    shape = RoundedCornerShape(16.dp),
+                    colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
+                ) {
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(horizontal = 12.dp, vertical = 10.dp),
+                        horizontalArrangement = Arrangement.SpaceBetween,
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Text("提醒时间:${DateUtils.toMinutesLabel(hour, minute)}")
+                        TextButton(onClick = {
+                            TimePickerDialog(
+                                context,
+                                { _, selectedHour, selectedMinute ->
+                                    hour = selectedHour
+                                    minute = selectedMinute
+                                },
+                                hour,
+                                minute,
+                                true
+                            ).show()
+                        }) {
+                            Text("修改")
+                        }
+                    }
+                }
+
+                Row(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.End
+                ) {
+                    TextButton(onClick = onDismiss) { Text("取消") }
+                    Spacer(modifier = Modifier.size(8.dp))
+                    Button(
+                        onClick = { onConfirm(title.trim(), selectedCategory, hour, minute) },
+                        enabled = title.trim().isNotBlank()
+                    ) {
+                        Text("保存")
+                    }
+                }
+            }
+        }
+    }
+}

+ 53 - 0
app/src/main/java/com/tixing/app/ui/AppTheme.kt

@@ -0,0 +1,53 @@
+package com.tixing.app.ui
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+private val LightColors = lightColorScheme(
+    primary = Color(0xFF1D6F5B),
+    onPrimary = Color.White,
+    primaryContainer = Color(0xFFD7F3EA),
+    onPrimaryContainer = Color(0xFF00382C),
+    secondary = Color(0xFF54776E),
+    background = Color(0xFFF4F8F6),
+    surface = Color(0xFFFFFFFF),
+    onSurface = Color(0xFF1A1C1B)
+)
+
+private val DarkColors = darkColorScheme(
+    primary = Color(0xFF7ED7BC),
+    onPrimary = Color(0xFF00382C),
+    primaryContainer = Color(0xFF00513F),
+    onPrimaryContainer = Color(0xFFD7F3EA)
+)
+
+@Composable
+fun TiXingTheme(
+    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 -> DarkColors
+        else -> LightColors
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = MaterialTheme.typography,
+        content = content
+    )
+}

+ 59 - 0
app/src/main/java/com/tixing/app/ui/AppViewModel.kt

@@ -0,0 +1,59 @@
+package com.tixing.app.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.tixing.app.data.AppDatabase
+import com.tixing.app.data.GoalProgress
+import com.tixing.app.data.HabitRepository
+import com.tixing.app.data.HistoryRow
+import com.tixing.app.reminder.ReminderScheduler
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class AppViewModel(application: Application) : AndroidViewModel(application) {
+    private val repository = HabitRepository(
+        goalDao = AppDatabase.getInstance(application).goalDao(),
+        checkInDao = AppDatabase.getInstance(application).checkInDao()
+    )
+
+    private val _goalProgress = MutableStateFlow<List<GoalProgress>>(emptyList())
+    val goalProgress: StateFlow<List<GoalProgress>> = _goalProgress.asStateFlow()
+
+    private val _history = MutableStateFlow<List<HistoryRow>>(emptyList())
+    val history: StateFlow<List<HistoryRow>> = _history.asStateFlow()
+
+    init {
+        repository.observeGoalProgress()
+            .onEach { _goalProgress.value = it }
+            .launchIn(viewModelScope)
+
+        repository.observeHistory()
+            .onEach { _history.value = it }
+            .launchIn(viewModelScope)
+    }
+
+    fun addGoal(title: String, category: String, reminderHour: Int, reminderMinute: Int) {
+        viewModelScope.launch {
+            val goal = repository.addGoal(title, category, reminderHour, reminderMinute)
+            ReminderScheduler.scheduleDailyReminder(getApplication(), goal)
+        }
+    }
+
+    fun deleteGoal(progress: GoalProgress) {
+        viewModelScope.launch {
+            repository.deleteGoal(progress.goal)
+            ReminderScheduler.cancelReminder(getApplication(), progress.goal.id)
+        }
+    }
+
+    fun toggleToday(goalId: Int, completed: Boolean) {
+        viewModelScope.launch {
+            repository.setTodayCheckIn(goalId, completed)
+        }
+    }
+}

+ 25 - 0
app/src/main/java/com/tixing/app/util/DateUtils.kt

@@ -0,0 +1,25 @@
+package com.tixing.app.util
+
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+object DateUtils {
+    private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
+    private val displayFormatter = DateTimeFormatter.ofPattern("MM月dd日")
+
+    fun todayKey(): String = LocalDate.now().format(dateFormatter)
+
+    fun toDisplayDate(dateKey: String): String {
+        val date = LocalDate.parse(dateKey, dateFormatter)
+        return date.format(displayFormatter)
+    }
+
+    fun lastNDaysKeys(days: Int): List<String> {
+        val today = LocalDate.now()
+        return (0 until days).map { offset ->
+            today.minusDays(offset.toLong()).format(dateFormatter)
+        }
+    }
+
+    fun toMinutesLabel(hour: Int, minute: Int): String = String.format("%02d:%02d", hour, minute)
+}

+ 12 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,12 @@
+<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="#1D6F5B"
+        android:pathData="M54,10C29.7,10 10,29.7 10,54s19.7,44 44,44 44,-19.7 44,-44S78.3,10 54,10z" />
+    <path
+        android:fillColor="#D7F3EA"
+        android:pathData="M76,39l-27.5,31L32,54.5l6.8,-6.8 9.5,9.5L69.2,33z" />
+</vector>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/brand_container" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/brand_container" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 7 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="seed">#1D6F5B</color>
+    <color name="brand_container">#D7F3EA</color>
+    <color name="surface_tint">#2A8A72</color>
+    <color name="app_bg">#F4F8F6</color>
+</resources>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">目标打卡</string>
+</resources>

+ 7 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Theme.TiXingApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
+        <item name="android:statusBarColor" tools:targetApi="l">@android:color/transparent</item>
+        <item name="android:windowLightStatusBar">true</item>
+    </style>
+</resources>

+ 5 - 0
build.gradle.kts

@@ -0,0 +1,5 @@
+plugins {
+    id("com.android.application") version "8.5.2" apply false
+    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+    id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
+}

+ 9 - 0
gradle.properties

@@ -0,0 +1,9 @@
+org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=384m
+org.gradle.daemon=false
+org.gradle.parallel=false
+org.gradle.caching=false
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+kotlin.incremental=false
+org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64

BIN
gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=60000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 249 - 0
gradlew

@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 92 - 0
gradlew.bat

@@ -0,0 +1,92 @@
+@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=-Dfile.encoding=UTF-8 "-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. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+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 %ERRORLEVEL% equ 0 goto mainEnd
+
+: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%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 18 - 0
settings.gradle.kts

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