diff --git a/android-app-requirements.md b/android-app-requirements.md
new file mode 100644
index 0000000..6472126
--- /dev/null
+++ b/android-app-requirements.md
@@ -0,0 +1,396 @@
+# Calendar Widget Android 应用需求文档
+
+## 项目概述
+将现有的 Calendar Widget 任务管理系统迁移到 Android 平台,保持核心功能的同时提供原生移动应用体验。
+
+## 技术栈
+- **开发语言**: Kotlin
+- **UI框架**: Jetpack Compose
+- **架构模式**: MVVM (Model-View-ViewModel)
+- **网络请求**: Retrofit + OkHttp
+- **本地存储**: Room Database
+- **依赖注入**: Hilt
+- **小组件**: Android App Widget API
+
+## 应用结构
+
+### 1. 控制页面 (MainActivity)
+
+#### 功能需求
+- **任务列表展示**
+ - 显示所有任务的列表视图
+ - 每个任务项显示:名称、状态颜色、最小间隔天数、上次执行时间
+ - 支持下拉刷新更新任务列表
+
+- **任务管理**
+ - 添加新任务:浮动操作按钮触发对话框
+ - 编辑任务:长按任务项进入编辑模式
+ - 删除任务:侧滑删除或长按菜单删除
+ - 拖拽排序:长按拖动重新排列任务优先级
+
+- **任务执行**
+ - 点击"执行"按钮记录任务执行时间
+ - 成功执行后自动刷新状态颜色
+
+#### UI设计要点
+```kotlin
+// 主界面布局结构
+@Composable
+fun ControlScreen() {
+ Scaffold(
+ topBar = { /* 应用标题栏 */ },
+ floatingActionButton = { /* 添加任务按钮 */ }
+ ) {
+ LazyColumn {
+ items(tasks) { task ->
+ TaskCard(
+ task = task,
+ onExecute = { /* 执行任务 */ },
+ onEdit = { /* 编辑任务 */ },
+ onDelete = { /* 删除任务 */ }
+ )
+ }
+ }
+ }
+}
+```
+
+### 2. 展示页面 (DisplayActivity)
+
+#### 功能需求
+- **任务状态可视化**
+ - 网格布局展示任务卡片
+ - 动态适配屏幕大小(横屏4列,竖屏2列)
+ - 每个卡片显示任务名称和颜色条
+ - 点击卡片快速执行任务
+
+- **视图模式**
+ - 紧凑模式:仅显示名称和颜色
+ - 详细模式:显示名称、颜色、剩余天数
+
+#### UI设计要点
+```kotlin
+@Composable
+fun DisplayScreen() {
+ val configuration = LocalConfiguration.current
+ val columns = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columns),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+ items(tasks) { task ->
+ TaskDisplayCard(
+ task = task,
+ onClick = { /* 执行任务 */ }
+ )
+ }
+ }
+}
+```
+
+### 3. Android 小组件 (Widget)
+
+#### 功能需求
+- **尺寸支持**
+ - 小型 (2x1): 显示1个任务
+ - 中型 (2x2): 显示4个任务
+ - 大型 (4x2): 显示8个任务
+
+- **交互功能**
+ - 点击任务执行并刷新小组件
+ - 自动每5分钟更新一次
+ - 支持手动刷新按钮
+
+#### 实现要点
+```kotlin
+class TaskWidgetProvider : AppWidgetProvider() {
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ for (appWidgetId in appWidgetIds) {
+ val size = getWidgetSize(appWidgetManager, appWidgetId)
+ val views = createRemoteViews(context, size)
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ // 设置下次更新时间为5分钟后
+ scheduleNextUpdate(context)
+ }
+
+ private fun scheduleNextUpdate(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val intent = Intent(context, TaskWidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ }
+ val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
+
+ // 5分钟后更新
+ val updateInterval = 5 * 60 * 1000L // 5分钟转换为毫秒
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC,
+ System.currentTimeMillis() + updateInterval,
+ pendingIntent
+ )
+ }
+}
+```
+
+#### 小组件布局 (仿照 widget.js 样式)
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+## 数据管理
+
+### API 接口层
+```kotlin
+interface TaskApiService {
+ @GET("tasks")
+ suspend fun getTasks(@Header("X-Api-Key") apiKey: String): List
+
+ @POST("tasks")
+ suspend fun createTask(@Header("X-Api-Key") apiKey: String, @Body task: CreateTaskRequest): Task
+
+ @PUT("tasks/{id}")
+ suspend fun updateTask(@Header("X-Api-Key") apiKey: String, @Path("id") id: Int, @Body task: UpdateTaskRequest): Task
+
+ @DELETE("tasks/{id}")
+ suspend fun deleteTask(@Header("X-Api-Key") apiKey: String, @Path("id") id: Int)
+
+ @POST("tasks/{id}/schedule")
+ suspend fun scheduleTask(@Header("X-Api-Key") apiKey: String, @Path("id") id: Int)
+
+ @POST("tasks/reorder")
+ suspend fun reorderTasks(@Header("X-Api-Key") apiKey: String, @Body request: ReorderRequest)
+}
+```
+
+### 本地缓存
+```kotlin
+@Entity(tableName = "tasks")
+data class TaskEntity(
+ @PrimaryKey val id: Int,
+ val name: String,
+ val color: String,
+ val minIntervalDays: Int,
+ val lastExecutionTime: String?,
+ val priority: Int
+)
+
+@Dao
+interface TaskDao {
+ @Query("SELECT * FROM tasks ORDER BY priority")
+ fun getAllTasks(): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTasks(tasks: List)
+
+ @Query("DELETE FROM tasks")
+ suspend fun deleteAllTasks()
+}
+```
+
+## 状态管理
+
+### ViewModel 实现
+```kotlin
+@HiltViewModel
+class TaskViewModel @Inject constructor(
+ private val repository: TaskRepository
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(TaskUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun loadTasks() {
+ viewModelScope.launch {
+ repository.getTasks()
+ .catch { /* 处理错误,使用缓存 */ }
+ .collect { tasks ->
+ _uiState.update { it.copy(tasks = tasks) }
+ }
+ }
+ }
+
+ fun executeTask(taskId: Int) {
+ viewModelScope.launch {
+ repository.scheduleTask(taskId)
+ loadTasks() // 刷新列表
+ updateWidget() // 更新小组件
+ }
+ }
+}
+```
+
+## 应用设置
+
+### SharedPreferences 配置
+```kotlin
+object AppSettings {
+ private const val PREF_NAME = "calendar_widget_prefs"
+ private const val KEY_API_KEY = "api_key"
+ private const val KEY_API_HOST = "api_host"
+ private const val KEY_AUTO_REFRESH = "auto_refresh"
+ private const val KEY_REFRESH_INTERVAL = "refresh_interval"
+
+ fun saveApiKey(context: Context, apiKey: String) {
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_API_KEY, apiKey)
+ .apply()
+ }
+}
+```
+
+### 设置页面功能
+- API密钥配置
+- 服务器地址配置
+- 小组件自动刷新间隔(默认5分钟,可选1/5/10/15/30/60分钟)
+- 主题选择(浅色/深色)
+- 通知设置
+
+## UI/UX 设计规范
+
+### 颜色主题
+```kotlin
+val CalendarWidgetTheme = lightColorScheme(
+ primary = Color(0xFF667EEA),
+ secondary = Color(0xFF764BA2),
+ background = Color(0xFFF5F5F9),
+ surface = Color(0xFFF2F2F2),
+ error = Color(0xFFFF4757),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onBackground = Color.Black,
+ onSurface = Color.Black
+)
+
+// 任务状态颜色
+object TaskColors {
+ val Green = Color(0xFF00B894)
+ val Yellow = Color(0xFFFDCB6E)
+ val Red = Color(0xFFFF4757)
+}
+```
+
+### Material Design 3 组件
+- 使用 Material You 动态主题
+- 圆角卡片设计(16dp corner radius)
+- 浮动操作按钮
+- 底部导航栏
+- Snackbar 提示信息
+
+## 权限需求
+```xml
+
+
+
+```
+
+## 构建配置
+
+### build.gradle.kts (Module)
+```kotlin
+android {
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.tunpok.calendarwidget"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ // Compose
+ implementation("androidx.compose.ui:ui:1.5.4")
+ implementation("androidx.compose.material3:material3:1.1.2")
+
+ // Navigation
+ implementation("androidx.navigation:navigation-compose:2.7.5")
+
+ // Network
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ // Database
+ implementation("androidx.room:room-runtime:2.6.0")
+ implementation("androidx.room:room-ktx:2.6.0")
+
+ // Dependency Injection
+ implementation("com.google.dagger:hilt-android:2.48")
+
+ // Widget
+ implementation("androidx.glance:glance-appwidget:1.0.0")
+}
+```
+
+## 测试策略
+
+### 单元测试
+- Repository 层测试
+- ViewModel 逻辑测试
+- 日期计算和颜色映射测试
+
+### UI 测试
+- Compose UI 测试
+- 小组件更新测试
+- 网络请求模拟测试
+
+## 发布准备
+
+### ProGuard 规则
+```proguard
+-keep class com.tunpok.calendarwidget.data.model.** { *; }
+-keepattributes Signature
+-keepattributes *Annotation*
+```
+
+### 版本管理
+- 使用语义化版本号
+- 维护 CHANGELOG.md
+- 配置自动化构建和发布流程
+
+## 后续优化建议
+
+1. **性能优化**
+ - 实现图片和数据懒加载
+ - 使用 WorkManager 进行后台同步
+ - 实现智能更新策略(活动时5分钟更新,空闲时降低频率以节省电量)
+
+2. **功能扩展**
+ - 添加任务提醒通知
+ - 支持任务分类和标签
+ - 实现任务统计图表
+ - 添加批量操作功能
+
+3. **用户体验**
+ - 实现手势操作
+ - 添加动画效果
+ - 支持快捷方式
+ - 深色模式自动切换
+
+4. **国际化**
+ - 支持多语言
+ - 适配不同地区日期格式
+ - 时区自动转换
\ No newline at end of file
diff --git a/android-app/.gitignore b/android-app/.gitignore
new file mode 100644
index 0000000..f7415cf
--- /dev/null
+++ b/android-app/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
\ No newline at end of file
diff --git a/android-app/README.md b/android-app/README.md
new file mode 100644
index 0000000..03265ac
--- /dev/null
+++ b/android-app/README.md
@@ -0,0 +1,124 @@
+# Calendar Widget Android App
+
+基于 Calendar Widget 系统的原生 Android 应用实现。
+
+## 功能特性
+
+- 📱 **原生 Android 体验**:使用 Kotlin 和 Jetpack Compose 构建
+- 📋 **任务管理**:完整的任务增删改查功能
+- 🎨 **Material Design 3**:现代化的 UI 设计
+- 📊 **双视图模式**:控制页面和展示页面
+- 🔄 **实时同步**:与服务器 API 实时同步
+- 💾 **离线支持**:本地数据库缓存
+- ⚡ **桌面小组件**:支持多种尺寸,5分钟自动刷新
+
+## 技术架构
+
+- **语言**:Kotlin
+- **UI**:Jetpack Compose
+- **架构**:MVVM + Repository Pattern
+- **依赖注入**:Hilt
+- **网络**:Retrofit + OkHttp
+- **数据库**:Room
+- **协程**:Kotlin Coroutines + Flow
+
+## 项目结构
+
+```
+android-app/
+├── app/
+│ ├── src/main/java/com/tunpok/calendarwidget/
+│ │ ├── data/ # 数据层
+│ │ │ ├── api/ # API 接口定义
+│ │ │ ├── database/ # Room 数据库
+│ │ │ ├── model/ # 数据模型
+│ │ │ └── repository/ # 数据仓库
+│ │ ├── di/ # Hilt 依赖注入
+│ │ ├── ui/ # UI 层
+│ │ │ ├── control/ # 控制页面
+│ │ │ ├── display/ # 展示页面
+│ │ │ ├── widget/ # 桌面小组件
+│ │ │ ├── components/ # 共享组件
+│ │ │ └── theme/ # 主题配置
+│ │ └── utils/ # 工具类
+│ └── src/main/res/ # 资源文件
+├── gradle/ # Gradle 配置
+└── build.gradle.kts # 构建脚本
+```
+
+## 构建与运行
+
+### 前置要求
+
+- Android Studio Arctic Fox 或更高版本
+- JDK 17
+- Android SDK 34
+- Kotlin 1.9.20+
+
+### 构建步骤
+
+1. 克隆项目:
+```bash
+cd android-app
+```
+
+2. 打开 Android Studio,导入项目
+
+3. 配置 API 设置(首次运行时在应用内配置):
+ - API Key: 你的 API 密钥
+ - API Host: 服务器地址
+
+4. 构建并运行:
+ - 点击 Run 按钮或使用快捷键 Shift+F10
+
+### 生成 APK
+
+```bash
+./gradlew assembleRelease
+```
+
+生成的 APK 位于:`app/build/outputs/apk/release/`
+
+## 小组件配置
+
+1. 长按桌面空白处
+2. 选择"小组件"
+3. 找到"Calendar Widget"
+4. 选择合适的尺寸:
+ - 小型 (2x1):显示1个任务
+ - 中型 (2x2):显示4个任务
+ - 大型 (4x2):显示8个任务
+5. 拖动到桌面
+
+小组件会每5分钟自动刷新,也可以点击刷新按钮手动更新。
+
+## 开发说明
+
+### 添加新功能
+
+1. 在对应的包中创建新文件
+2. 使用 Hilt 注解进行依赖注入
+3. 遵循 MVVM 架构模式
+4. 使用 Compose 构建 UI
+
+### 调试
+
+- 启用 OkHttp 日志:已在 `AppModule` 中配置
+- 查看数据库:使用 Android Studio 的 Database Inspector
+- 网络调试:使用 Charles 或 Flipper
+
+## 测试
+
+运行单元测试:
+```bash
+./gradlew test
+```
+
+运行 UI 测试:
+```bash
+./gradlew connectedAndroidTest
+```
+
+## License
+
+MIT
\ No newline at end of file
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..7ea3bb6
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -0,0 +1,115 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-kapt")
+ id("com.google.dagger.hilt.android")
+}
+
+android {
+ namespace = "com.tunpok.calendarwidget"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.tunpok.calendarwidget"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.4"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // Core Android
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("androidx.activity:activity-compose:1.8.0")
+
+ // Compose
+ 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.7.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // Hilt
+ implementation("com.google.dagger:hilt-android:2.48")
+ kapt("com.google.dagger:hilt-compiler:2.48")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ // Room
+ implementation("androidx.room:room-runtime:2.6.0")
+ implementation("androidx.room:room-ktx:2.6.0")
+ kapt("androidx.room:room-compiler:2.6.0")
+
+ // 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")
+
+ // Gson
+ implementation("com.google.code.gson:gson:2.10.1")
+
+ // DataStore (for preferences)
+ implementation("androidx.datastore:datastore-preferences:1.0.0")
+
+ // Widget
+ implementation("androidx.glance:glance-appwidget:1.0.0")
+ implementation("androidx.glance:glance-material3:1.0.0")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
\ No newline at end of file
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..cd69acf
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -0,0 +1,34 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+
+# Retrofit
+-keepattributes Signature
+-keepattributes *Annotation*
+-keep class retrofit2.** { *; }
+-keepclasseswithmembers class * {
+ @retrofit2.http.* ;
+}
+
+# Gson
+-keep class com.google.gson.** { *; }
+-keep class com.tunpok.calendarwidget.data.model.** { *; }
+
+# Room
+-keep class androidx.room.** { *; }
+-keep @androidx.room.Database class * { *; }
+-keep @androidx.room.Entity class * { *; }
+-keep @androidx.room.Dao class * { *; }
+
+# Hilt
+-keep class dagger.hilt.** { *; }
+-keep class javax.inject.** { *; }
+-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel { *; }
+
+# Coroutines
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+
+# Keep data classes
+-keep class com.tunpok.calendarwidget.data.model.** { *; }
+-keepclassmembers class com.tunpok.calendarwidget.data.model.** { *; }
\ No newline at end of file
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..27a7d5e
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/CalendarWidgetApplication.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/CalendarWidgetApplication.kt
new file mode 100644
index 0000000..ec14890
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/CalendarWidgetApplication.kt
@@ -0,0 +1,7 @@
+package com.tunpok.calendarwidget
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class CalendarWidgetApplication : Application()
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/DisplayActivity.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/DisplayActivity.kt
new file mode 100644
index 0000000..9479dfc
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/DisplayActivity.kt
@@ -0,0 +1,29 @@
+package com.tunpok.calendarwidget
+
+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.ui.Modifier
+import com.tunpok.calendarwidget.ui.display.DisplayScreen
+import com.tunpok.calendarwidget.ui.theme.CalendarWidgetTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class DisplayActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CalendarWidgetTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ DisplayScreen()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/MainActivity.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/MainActivity.kt
new file mode 100644
index 0000000..bd4ae55
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/MainActivity.kt
@@ -0,0 +1,116 @@
+package com.tunpok.calendarwidget
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.List
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.tunpok.calendarwidget.ui.control.ControlScreen
+import com.tunpok.calendarwidget.ui.display.DisplayScreen
+import com.tunpok.calendarwidget.ui.theme.CalendarWidgetTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CalendarWidgetTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ MainNavigation()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MainNavigation() {
+ val navController = rememberNavController()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry?.destination
+
+ val items = listOf(
+ NavigationItem("control", "控制", Icons.Default.List),
+ NavigationItem("display", "展示", Icons.Default.Dashboard),
+ NavigationItem("settings", "设置", Icons.Default.Settings)
+ )
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ items.forEach { item ->
+ NavigationBarItem(
+ icon = { Icon(item.icon, contentDescription = item.label) },
+ label = { Text(item.label) },
+ selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
+ onClick = {
+ navController.navigate(item.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ )
+ }
+ }
+ }
+ ) { paddingValues ->
+ NavHost(
+ navController = navController,
+ startDestination = "control",
+ modifier = Modifier.padding(paddingValues)
+ ) {
+ composable("control") {
+ ControlScreen()
+ }
+ composable("display") {
+ DisplayScreen()
+ }
+ composable("settings") {
+ SettingsScreen()
+ }
+ }
+ }
+}
+
+data class NavigationItem(
+ val route: String,
+ val label: String,
+ val icon: androidx.compose.ui.graphics.vector.ImageVector
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen() {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("设置") }
+ )
+ }
+ ) { paddingValues ->
+ Column(modifier = Modifier.padding(paddingValues)) {
+ // Settings content will be implemented later
+ Text("Settings Screen", modifier = Modifier.padding(16.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/data/api/TaskApiService.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/api/TaskApiService.kt
new file mode 100644
index 0000000..f4d65e4
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/api/TaskApiService.kt
@@ -0,0 +1,40 @@
+package com.tunpok.calendarwidget.data.api
+
+import com.tunpok.calendarwidget.data.model.*
+import retrofit2.http.*
+
+interface TaskApiService {
+ @GET("tasks")
+ suspend fun getTasks(@Header("X-API-Key") apiKey: String): List
+
+ @POST("tasks")
+ suspend fun createTask(
+ @Header("X-API-Key") apiKey: String,
+ @Body task: CreateTaskRequest
+ ): Task
+
+ @PUT("tasks/{id}")
+ suspend fun updateTask(
+ @Header("X-API-Key") apiKey: String,
+ @Path("id") id: Int,
+ @Body task: UpdateTaskRequest
+ ): Task
+
+ @DELETE("tasks/{id}")
+ suspend fun deleteTask(
+ @Header("X-API-Key") apiKey: String,
+ @Path("id") id: Int
+ )
+
+ @POST("tasks/{id}/schedule")
+ suspend fun scheduleTask(
+ @Header("X-API-Key") apiKey: String,
+ @Path("id") id: Int
+ )
+
+ @POST("tasks/reorder")
+ suspend fun reorderTasks(
+ @Header("X-API-Key") apiKey: String,
+ @Body request: ReorderRequest
+ )
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDao.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDao.kt
new file mode 100644
index 0000000..f052772
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDao.kt
@@ -0,0 +1,35 @@
+package com.tunpok.calendarwidget.data.database
+
+import androidx.room.*
+import com.tunpok.calendarwidget.data.model.Task
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface TaskDao {
+ @Query("SELECT * FROM tasks ORDER BY priority")
+ fun getAllTasks(): Flow>
+
+ @Query("SELECT * FROM tasks ORDER BY priority")
+ suspend fun getAllTasksOnce(): List
+
+ @Query("SELECT * FROM tasks WHERE id = :taskId")
+ suspend fun getTaskById(taskId: Int): Task?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTasks(tasks: List)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertTask(task: Task)
+
+ @Update
+ suspend fun updateTask(task: Task)
+
+ @Delete
+ suspend fun deleteTask(task: Task)
+
+ @Query("DELETE FROM tasks")
+ suspend fun deleteAllTasks()
+
+ @Query("DELETE FROM tasks WHERE id = :taskId")
+ suspend fun deleteTaskById(taskId: Int)
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDatabase.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDatabase.kt
new file mode 100644
index 0000000..e8905bb
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/database/TaskDatabase.kt
@@ -0,0 +1,14 @@
+package com.tunpok.calendarwidget.data.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.tunpok.calendarwidget.data.model.Task
+
+@Database(
+ entities = [Task::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class TaskDatabase : RoomDatabase() {
+ abstract fun taskDao(): TaskDao
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/data/model/Task.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/model/Task.kt
new file mode 100644
index 0000000..0edc3cd
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/model/Task.kt
@@ -0,0 +1,42 @@
+package com.tunpok.calendarwidget.data.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.google.gson.annotations.SerializedName
+
+@Entity(tableName = "tasks")
+data class Task(
+ @PrimaryKey
+ val id: Int,
+ val name: String,
+ val color: String,
+ @SerializedName("min_interval_days")
+ val minIntervalDays: Int,
+ @SerializedName("last_execution_time")
+ val lastExecutionTime: String?,
+ val priority: Int
+)
+
+data class CreateTaskRequest(
+ val name: String,
+ @SerializedName("min_interval_days")
+ val minIntervalDays: Int,
+ val priority: Int = 0
+)
+
+data class UpdateTaskRequest(
+ val name: String,
+ @SerializedName("min_interval_days")
+ val minIntervalDays: Int
+)
+
+data class ReorderRequest(
+ @SerializedName("task_ids")
+ val taskIds: List
+)
+
+enum class TaskColor {
+ GREEN,
+ YELLOW,
+ RED
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/data/repository/TaskRepository.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/repository/TaskRepository.kt
new file mode 100644
index 0000000..67d5a03
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/data/repository/TaskRepository.kt
@@ -0,0 +1,102 @@
+package com.tunpok.calendarwidget.data.repository
+
+import com.tunpok.calendarwidget.data.api.TaskApiService
+import com.tunpok.calendarwidget.data.database.TaskDao
+import com.tunpok.calendarwidget.data.model.*
+import com.tunpok.calendarwidget.utils.AppSettings
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TaskRepository @Inject constructor(
+ private val api: TaskApiService,
+ private val dao: TaskDao,
+ private val settings: AppSettings
+) {
+ fun getTasks(): Flow> = flow {
+ try {
+ // Try to fetch from API
+ val apiKey = settings.getApiKey()
+ val tasks = api.getTasks(apiKey)
+
+ // Update local cache
+ dao.deleteAllTasks()
+ dao.insertTasks(tasks)
+
+ emit(tasks)
+ } catch (e: Exception) {
+ // Fall back to cached data
+ val cachedTasks = dao.getAllTasksOnce()
+ emit(cachedTasks)
+ }
+ }
+
+ fun getTasksFlow(): Flow> = dao.getAllTasks()
+
+ suspend fun createTask(name: String, minIntervalDays: Int): Result {
+ return try {
+ val apiKey = settings.getApiKey()
+ val request = CreateTaskRequest(name, minIntervalDays)
+ val task = api.createTask(apiKey, request)
+ dao.insertTask(task)
+ Result.success(task)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateTask(id: Int, name: String, minIntervalDays: Int): Result {
+ return try {
+ val apiKey = settings.getApiKey()
+ val request = UpdateTaskRequest(name, minIntervalDays)
+ val task = api.updateTask(apiKey, id, request)
+ dao.updateTask(task)
+ Result.success(task)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun deleteTask(id: Int): Result {
+ return try {
+ val apiKey = settings.getApiKey()
+ api.deleteTask(apiKey, id)
+ dao.deleteTaskById(id)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun scheduleTask(id: Int): Result {
+ return try {
+ val apiKey = settings.getApiKey()
+ api.scheduleTask(apiKey, id)
+ // Refresh tasks after scheduling
+ val tasks = api.getTasks(apiKey)
+ dao.deleteAllTasks()
+ dao.insertTasks(tasks)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun reorderTasks(taskIds: List): Result {
+ return try {
+ val apiKey = settings.getApiKey()
+ val request = ReorderRequest(taskIds)
+ api.reorderTasks(apiKey, request)
+ // Refresh tasks after reordering
+ val tasks = api.getTasks(apiKey)
+ dao.deleteAllTasks()
+ dao.insertTasks(tasks)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/di/AppModule.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/di/AppModule.kt
new file mode 100644
index 0000000..10f4fe4
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/di/AppModule.kt
@@ -0,0 +1,82 @@
+package com.tunpok.calendarwidget.di
+
+import android.content.Context
+import androidx.room.Room
+import com.tunpok.calendarwidget.data.api.TaskApiService
+import com.tunpok.calendarwidget.data.database.TaskDao
+import com.tunpok.calendarwidget.data.database.TaskDatabase
+import com.tunpok.calendarwidget.utils.AppSettings
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideTaskDatabase(
+ @ApplicationContext context: Context
+ ): TaskDatabase {
+ return Room.databaseBuilder(
+ context,
+ TaskDatabase::class.java,
+ "task_database"
+ ).build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideTaskDao(database: TaskDatabase): TaskDao {
+ return database.taskDao()
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(): OkHttpClient {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ return OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(
+ client: OkHttpClient,
+ appSettings: AppSettings
+ ): Retrofit {
+ val baseUrl = runBlocking {
+ appSettings.getApiHost().first()
+ }
+
+ return Retrofit.Builder()
+ .baseUrl(if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/")
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideTaskApiService(retrofit: Retrofit): TaskApiService {
+ return retrofit.create(TaskApiService::class.java)
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/components/TaskCard.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/components/TaskCard.kt
new file mode 100644
index 0000000..b1963b4
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/components/TaskCard.kt
@@ -0,0 +1,167 @@
+package com.tunpok.calendarwidget.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.tunpok.calendarwidget.data.model.Task
+import com.tunpok.calendarwidget.ui.theme.*
+
+@Composable
+fun TaskCard(
+ task: Task,
+ onExecute: () -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = Color.White
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 2.dp
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Color indicator
+ Box(
+ modifier = Modifier
+ .size(12.dp)
+ .clip(CircleShape)
+ .background(
+ when (task.color.lowercase()) {
+ "green" -> TaskGreen
+ "yellow" -> TaskYellow
+ "red" -> TaskRed
+ else -> Color.Gray
+ }
+ )
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Task info
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = task.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row {
+ Text(
+ text = "间隔: ${task.minIntervalDays} 天",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+ task.lastExecutionTime?.let { time ->
+ Text(
+ text = " | 上次: ${formatDate(time)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray
+ )
+ }
+ }
+ }
+
+ // Action buttons
+ Row {
+ IconButton(onClick = onExecute) {
+ Icon(
+ Icons.Default.PlayArrow,
+ contentDescription = "执行",
+ tint = TaskGreen
+ )
+ }
+ IconButton(onClick = onEdit) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = "编辑",
+ tint = PrimaryColor
+ )
+ }
+ IconButton(onClick = onDelete) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "删除",
+ tint = ErrorColor
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TaskDisplayCard(
+ task: Task,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .height(80.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = SurfaceColor
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = task.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .height(10.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ when (task.color.lowercase()) {
+ "green" -> TaskGreen
+ "yellow" -> TaskYellow
+ "red" -> TaskRed
+ else -> Color.Gray
+ }
+ )
+ )
+ }
+ }
+}
+
+private fun formatDate(dateString: String): String {
+ // Simple date formatting, you can enhance this
+ return dateString.split("T").firstOrNull() ?: dateString
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/ControlScreen.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/ControlScreen.kt
new file mode 100644
index 0000000..e8bc981
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/ControlScreen.kt
@@ -0,0 +1,250 @@
+package com.tunpok.calendarwidget.ui.control
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.tunpok.calendarwidget.data.model.Task
+import com.tunpok.calendarwidget.ui.components.TaskCard
+import com.tunpok.calendarwidget.ui.theme.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ControlScreen(
+ viewModel: TaskViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "任务管理",
+ fontWeight = FontWeight.Bold
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = PrimaryColor,
+ titleContentColor = Color.White
+ )
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = { viewModel.showAddDialog() },
+ containerColor = PrimaryColor
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "添加任务", tint = Color.White)
+ }
+ }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ } else if (uiState.tasks.isEmpty()) {
+ EmptyState(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ } else {
+ TaskList(
+ tasks = uiState.tasks,
+ onExecute = { viewModel.scheduleTask(it.id) },
+ onEdit = { viewModel.showEditDialog(it) },
+ onDelete = { viewModel.deleteTask(it.id) }
+ )
+ }
+
+ uiState.errorMessage?.let { error ->
+ Snackbar(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ action = {
+ TextButton(onClick = { viewModel.clearError() }) {
+ Text("关闭")
+ }
+ }
+ ) {
+ Text(error)
+ }
+ }
+ }
+ }
+
+ // Add Task Dialog
+ if (uiState.isAddingTask) {
+ AddTaskDialog(
+ onDismiss = { viewModel.dismissAddDialog() },
+ onConfirm = { name, interval ->
+ viewModel.createTask(name, interval)
+ }
+ )
+ }
+
+ // Edit Task Dialog
+ uiState.editingTask?.let { task ->
+ EditTaskDialog(
+ task = task,
+ onDismiss = { viewModel.dismissEditDialog() },
+ onConfirm = { name, interval ->
+ viewModel.updateTask(task.id, name, interval)
+ }
+ )
+ }
+}
+
+@Composable
+fun TaskList(
+ tasks: List,
+ onExecute: (Task) -> Unit,
+ onEdit: (Task) -> Unit,
+ onDelete: (Task) -> Unit
+) {
+ LazyColumn(
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(tasks, key = { it.id }) { task ->
+ TaskCard(
+ task = task,
+ onExecute = { onExecute(task) },
+ onEdit = { onEdit(task) },
+ onDelete = { onDelete(task) }
+ )
+ }
+ }
+}
+
+@Composable
+fun EmptyState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ "暂无任务",
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.Gray
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "点击右下角添加第一个任务",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.Gray
+ )
+ }
+}
+
+@Composable
+fun AddTaskDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String, Int) -> Unit
+) {
+ var name by remember { mutableStateOf("") }
+ var interval by remember { mutableStateOf("7") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("新增任务") },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("任务名称") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = interval,
+ onValueChange = { interval = it.filter { c -> c.isDigit() } },
+ label = { Text("最少间隔天数") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (name.isNotBlank() && interval.isNotBlank()) {
+ onConfirm(name, interval.toInt())
+ }
+ }
+ ) {
+ Text("确定")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("取消")
+ }
+ }
+ )
+}
+
+@Composable
+fun EditTaskDialog(
+ task: Task,
+ onDismiss: () -> Unit,
+ onConfirm: (String, Int) -> Unit
+) {
+ var name by remember { mutableStateOf(task.name) }
+ var interval by remember { mutableStateOf(task.minIntervalDays.toString()) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("编辑任务") },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("任务名称") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = interval,
+ onValueChange = { interval = it.filter { c -> c.isDigit() } },
+ label = { Text("最少间隔天数") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (name.isNotBlank() && interval.isNotBlank()) {
+ onConfirm(name, interval.toInt())
+ }
+ }
+ ) {
+ Text("保存")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("取消")
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/TaskViewModel.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/TaskViewModel.kt
new file mode 100644
index 0000000..f415efb
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/control/TaskViewModel.kt
@@ -0,0 +1,148 @@
+package com.tunpok.calendarwidget.ui.control
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.tunpok.calendarwidget.data.model.Task
+import com.tunpok.calendarwidget.data.repository.TaskRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class TaskUiState(
+ val tasks: List = emptyList(),
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val isAddingTask: Boolean = false,
+ val editingTask: Task? = null
+)
+
+@HiltViewModel
+class TaskViewModel @Inject constructor(
+ private val repository: TaskRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(TaskUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadTasks()
+ }
+
+ fun loadTasks() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ repository.getTasks()
+ .catch { e ->
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = e.message
+ )
+ }
+ }
+ .collect { tasks ->
+ _uiState.update {
+ it.copy(
+ tasks = tasks,
+ isLoading = false,
+ errorMessage = null
+ )
+ }
+ }
+ }
+ }
+
+ fun createTask(name: String, minIntervalDays: Int) {
+ viewModelScope.launch {
+ repository.createTask(name, minIntervalDays)
+ .onSuccess {
+ loadTasks()
+ dismissAddDialog()
+ }
+ .onFailure { e ->
+ _uiState.update {
+ it.copy(errorMessage = e.message)
+ }
+ }
+ }
+ }
+
+ fun updateTask(id: Int, name: String, minIntervalDays: Int) {
+ viewModelScope.launch {
+ repository.updateTask(id, name, minIntervalDays)
+ .onSuccess {
+ loadTasks()
+ dismissEditDialog()
+ }
+ .onFailure { e ->
+ _uiState.update {
+ it.copy(errorMessage = e.message)
+ }
+ }
+ }
+ }
+
+ fun deleteTask(id: Int) {
+ viewModelScope.launch {
+ repository.deleteTask(id)
+ .onSuccess {
+ loadTasks()
+ }
+ .onFailure { e ->
+ _uiState.update {
+ it.copy(errorMessage = e.message)
+ }
+ }
+ }
+ }
+
+ fun scheduleTask(id: Int) {
+ viewModelScope.launch {
+ repository.scheduleTask(id)
+ .onSuccess {
+ loadTasks()
+ }
+ .onFailure { e ->
+ _uiState.update {
+ it.copy(errorMessage = e.message)
+ }
+ }
+ }
+ }
+
+ fun reorderTasks(taskIds: List) {
+ viewModelScope.launch {
+ repository.reorderTasks(taskIds)
+ .onSuccess {
+ loadTasks()
+ }
+ .onFailure { e ->
+ _uiState.update {
+ it.copy(errorMessage = e.message)
+ }
+ }
+ }
+ }
+
+ fun showAddDialog() {
+ _uiState.update { it.copy(isAddingTask = true) }
+ }
+
+ fun dismissAddDialog() {
+ _uiState.update { it.copy(isAddingTask = false) }
+ }
+
+ fun showEditDialog(task: Task) {
+ _uiState.update { it.copy(editingTask = task) }
+ }
+
+ fun dismissEditDialog() {
+ _uiState.update { it.copy(editingTask = null) }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/display/DisplayScreen.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/display/DisplayScreen.kt
new file mode 100644
index 0000000..cc174c8
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/display/DisplayScreen.kt
@@ -0,0 +1,91 @@
+package com.tunpok.calendarwidget.ui.display
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.tunpok.calendarwidget.ui.components.TaskDisplayCard
+import com.tunpok.calendarwidget.ui.control.TaskViewModel
+import com.tunpok.calendarwidget.ui.theme.PrimaryColor
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DisplayScreen(
+ viewModel: TaskViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val configuration = LocalConfiguration.current
+ val columns = if (configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) 4 else 2
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "任务展示",
+ fontWeight = FontWeight.Bold
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = PrimaryColor,
+ titleContentColor = Color.White
+ )
+ )
+ }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ } else if (uiState.tasks.isEmpty()) {
+ Text(
+ "暂无任务",
+ modifier = Modifier.align(Alignment.Center),
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.Gray
+ )
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columns),
+ contentPadding = PaddingValues(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(uiState.tasks, key = { it.id }) { task ->
+ TaskDisplayCard(
+ task = task,
+ onClick = { viewModel.scheduleTask(task.id) }
+ )
+ }
+ }
+ }
+
+ uiState.errorMessage?.let { error ->
+ Snackbar(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ action = {
+ TextButton(onClick = { viewModel.clearError() }) {
+ Text("关闭")
+ }
+ }
+ ) {
+ Text(error)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Color.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Color.kt
new file mode 100644
index 0000000..ba33054
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Color.kt
@@ -0,0 +1,23 @@
+package com.tunpok.calendarwidget.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
+
+// Custom colors for Calendar Widget
+val PrimaryColor = Color(0xFF667EEA)
+val SecondaryColor = Color(0xFF764BA2)
+val BackgroundColor = Color(0xFFF5F5F9)
+val SurfaceColor = Color(0xFFF2F2F2)
+val ErrorColor = Color(0xFFFF4757)
+
+// Task status colors
+val TaskGreen = Color(0xFF00B894)
+val TaskYellow = Color(0xFFFDCB6E)
+val TaskRed = Color(0xFFFF4757)
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Theme.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Theme.kt
new file mode 100644
index 0000000..2624491
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Theme.kt
@@ -0,0 +1,69 @@
+package com.tunpok.calendarwidget.ui.theme
+
+import android.app.Activity
+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.runtime.SideEffect
+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 = PrimaryColor,
+ secondary = SecondaryColor,
+ tertiary = Pink80,
+ background = Color(0xFF121212),
+ surface = Color(0xFF1E1E1E),
+ error = ErrorColor
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = PrimaryColor,
+ secondary = SecondaryColor,
+ tertiary = Pink40,
+ background = BackgroundColor,
+ surface = SurfaceColor,
+ error = ErrorColor,
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F)
+)
+
+@Composable
+fun CalendarWidgetTheme(
+ 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
+ )
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Type.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Type.kt
new file mode 100644
index 0000000..4926dcd
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/theme/Type.kt
@@ -0,0 +1,32 @@
+package com.tunpok.calendarwidget.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+)
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetProvider.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetProvider.kt
new file mode 100644
index 0000000..6c4c110
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetProvider.kt
@@ -0,0 +1,187 @@
+package com.tunpok.calendarwidget.ui.widget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import com.tunpok.calendarwidget.R
+import com.tunpok.calendarwidget.data.repository.TaskRepository
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class TaskWidgetProvider : AppWidgetProvider() {
+
+ @Inject
+ lateinit var repository: TaskRepository
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ updateAppWidget(context, appWidgetManager, appWidgetId)
+ }
+
+ // Schedule next update in 5 minutes
+ scheduleNextUpdate(context)
+ }
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ scheduleNextUpdate(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ cancelNextUpdate(context)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+
+ when (intent.action) {
+ ACTION_TASK_CLICK -> {
+ val taskId = intent.getIntExtra(EXTRA_TASK_ID, -1)
+ if (taskId != -1) {
+ handleTaskClick(context, taskId)
+ }
+ }
+ ACTION_REFRESH -> {
+ refreshWidget(context)
+ }
+ }
+ }
+
+ private fun updateAppWidget(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int
+ ) {
+ val widgetSize = getWidgetSize(appWidgetManager, appWidgetId)
+ val views = createRemoteViews(context, widgetSize)
+
+ // Set up the list adapter
+ val intent = Intent(context, TaskWidgetService::class.java)
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ views.setRemoteAdapter(R.id.widget_grid_view, intent)
+
+ // Set up click intent template
+ val taskClickIntent = Intent(context, TaskWidgetProvider::class.java)
+ taskClickIntent.action = ACTION_TASK_CLICK
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ 0,
+ taskClickIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setPendingIntentTemplate(R.id.widget_grid_view, pendingIntent)
+
+ // Set up refresh button
+ val refreshIntent = Intent(context, TaskWidgetProvider::class.java)
+ refreshIntent.action = ACTION_REFRESH
+ val refreshPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 1,
+ refreshIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_refresh_button, refreshPendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+
+ private fun createRemoteViews(context: Context, size: WidgetSize): RemoteViews {
+ val layoutId = when (size) {
+ WidgetSize.SMALL -> R.layout.widget_layout_small
+ WidgetSize.MEDIUM -> R.layout.widget_layout_medium
+ WidgetSize.LARGE -> R.layout.widget_layout_large
+ }
+ return RemoteViews(context.packageName, layoutId)
+ }
+
+ private fun getWidgetSize(
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int
+ ): WidgetSize {
+ val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
+ val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
+ val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
+
+ return when {
+ minWidth >= 250 && minHeight >= 110 -> WidgetSize.LARGE
+ minWidth >= 110 && minHeight >= 110 -> WidgetSize.MEDIUM
+ else -> WidgetSize.SMALL
+ }
+ }
+
+ private fun handleTaskClick(context: Context, taskId: Int) {
+ CoroutineScope(Dispatchers.IO).launch {
+ repository.scheduleTask(taskId)
+ refreshWidget(context)
+ }
+ }
+
+ private fun refreshWidget(context: Context) {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val thisWidget = ComponentName(context, TaskWidgetProvider::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
+
+ appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_grid_view)
+ onUpdate(context, appWidgetManager, appWidgetIds)
+ }
+
+ private fun scheduleNextUpdate(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val intent = Intent(context, TaskWidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ }
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ UPDATE_REQUEST_CODE,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ // Schedule update in 5 minutes
+ val updateInterval = 5 * 60 * 1000L // 5 minutes in milliseconds
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC,
+ System.currentTimeMillis() + updateInterval,
+ pendingIntent
+ )
+ }
+
+ private fun cancelNextUpdate(context: Context) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val intent = Intent(context, TaskWidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ }
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ UPDATE_REQUEST_CODE,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ alarmManager.cancel(pendingIntent)
+ }
+
+ companion object {
+ const val ACTION_TASK_CLICK = "com.tunpok.calendarwidget.TASK_CLICK"
+ const val ACTION_REFRESH = "com.tunpok.calendarwidget.REFRESH"
+ const val EXTRA_TASK_ID = "task_id"
+ private const val UPDATE_REQUEST_CODE = 100
+ }
+
+ enum class WidgetSize {
+ SMALL, MEDIUM, LARGE
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetService.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetService.kt
new file mode 100644
index 0000000..5df3e65
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetService.kt
@@ -0,0 +1,12 @@
+package com.tunpok.calendarwidget.ui.widget
+
+import android.content.Intent
+import android.widget.RemoteViewsService
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class TaskWidgetService : RemoteViewsService() {
+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
+ return TaskWidgetViewsFactory(applicationContext)
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetViewsFactory.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetViewsFactory.kt
new file mode 100644
index 0000000..5c0e2c1
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/TaskWidgetViewsFactory.kt
@@ -0,0 +1,77 @@
+package com.tunpok.calendarwidget.ui.widget
+
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import com.tunpok.calendarwidget.R
+import com.tunpok.calendarwidget.data.database.TaskDao
+import com.tunpok.calendarwidget.data.model.Task
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.android.EntryPointAccessors.fromApplication
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+class TaskWidgetViewsFactory(
+ private val context: Context
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private var tasks: List = emptyList()
+ private lateinit var dao: TaskDao
+
+ override fun onCreate() {
+ val hiltEntryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ WidgetEntryPoint::class.java
+ )
+ dao = hiltEntryPoint.taskDao()
+ }
+
+ override fun onDataSetChanged() {
+ tasks = runBlocking {
+ dao.getAllTasksOnce()
+ }
+ }
+
+ override fun onDestroy() {
+ tasks = emptyList()
+ }
+
+ override fun getCount(): Int = tasks.size
+
+ override fun getViewAt(position: Int): RemoteViews {
+ if (position >= tasks.size) {
+ return RemoteViews(context.packageName, R.layout.widget_item_layout)
+ }
+
+ val task = tasks[position]
+ val views = RemoteViews(context.packageName, R.layout.widget_item_layout)
+
+ // Set task name
+ views.setTextViewText(R.id.task_name, task.name)
+
+ // Set color bar
+ val colorResId = when (task.color.lowercase()) {
+ "green" -> R.color.task_green
+ "yellow" -> R.color.task_yellow
+ "red" -> R.color.task_red
+ else -> R.color.task_gray
+ }
+ views.setInt(R.id.color_bar, "setBackgroundResource", colorResId)
+
+ // Set up click intent
+ val fillInIntent = Intent()
+ fillInIntent.putExtra(TaskWidgetProvider.EXTRA_TASK_ID, task.id)
+ views.setOnClickFillInIntent(R.id.widget_item_container, fillInIntent)
+
+ return views
+ }
+
+ override fun getLoadingView(): RemoteViews? = null
+
+ override fun getViewTypeCount(): Int = 1
+
+ override fun getItemId(position: Int): Long = position.toLong()
+
+ override fun hasStableIds(): Boolean = true
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/WidgetEntryPoint.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/WidgetEntryPoint.kt
new file mode 100644
index 0000000..73e3fd1
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/ui/widget/WidgetEntryPoint.kt
@@ -0,0 +1,12 @@
+package com.tunpok.calendarwidget.ui.widget
+
+import com.tunpok.calendarwidget.data.database.TaskDao
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface WidgetEntryPoint {
+ fun taskDao(): TaskDao
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/java/com/tunpok/calendarwidget/utils/AppSettings.kt b/android-app/app/src/main/java/com/tunpok/calendarwidget/utils/AppSettings.kt
new file mode 100644
index 0000000..eb56114
--- /dev/null
+++ b/android-app/app/src/main/java/com/tunpok/calendarwidget/utils/AppSettings.kt
@@ -0,0 +1,92 @@
+package com.tunpok.calendarwidget.utils
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.*
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
+
+@Singleton
+class AppSettings @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private object PreferenceKeys {
+ val API_KEY = stringPreferencesKey("api_key")
+ val API_HOST = stringPreferencesKey("api_host")
+ val AUTO_REFRESH = booleanPreferencesKey("auto_refresh")
+ val REFRESH_INTERVAL = intPreferencesKey("refresh_interval")
+ val THEME_MODE = stringPreferencesKey("theme_mode")
+ }
+
+ suspend fun saveApiKey(apiKey: String) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceKeys.API_KEY] = apiKey
+ }
+ }
+
+ suspend fun getApiKey(): String {
+ return context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.API_KEY] ?: "change-me"
+ }
+ .collect { it }
+ }
+
+ suspend fun saveApiHost(apiHost: String) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceKeys.API_HOST] = apiHost
+ }
+ }
+
+ fun getApiHost(): Flow {
+ return context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.API_HOST] ?: "https://calendar-widget-api.tunpok.com"
+ }
+ }
+
+ suspend fun saveAutoRefresh(enabled: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceKeys.AUTO_REFRESH] = enabled
+ }
+ }
+
+ fun getAutoRefresh(): Flow {
+ return context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.AUTO_REFRESH] ?: true
+ }
+ }
+
+ suspend fun saveRefreshInterval(minutes: Int) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceKeys.REFRESH_INTERVAL] = minutes
+ }
+ }
+
+ fun getRefreshInterval(): Flow {
+ return context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.REFRESH_INTERVAL] ?: 5
+ }
+ }
+
+ suspend fun saveThemeMode(mode: String) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferenceKeys.THEME_MODE] = mode
+ }
+ }
+
+ fun getThemeMode(): Flow {
+ return context.dataStore.data
+ .map { preferences ->
+ preferences[PreferenceKeys.THEME_MODE] ?: "system"
+ }
+ }
+}
\ No newline at end of file
diff --git a/android-app/app/src/main/res/layout/widget_item_layout.xml b/android-app/app/src/main/res/layout/widget_item_layout.xml
new file mode 100644
index 0000000..3c814b4
--- /dev/null
+++ b/android-app/app/src/main/res/layout/widget_item_layout.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/layout/widget_layout_large.xml b/android-app/app/src/main/res/layout/widget_layout_large.xml
new file mode 100644
index 0000000..7a976b5
--- /dev/null
+++ b/android-app/app/src/main/res/layout/widget_layout_large.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/layout/widget_layout_medium.xml b/android-app/app/src/main/res/layout/widget_layout_medium.xml
new file mode 100644
index 0000000..7f0a5f1
--- /dev/null
+++ b/android-app/app/src/main/res/layout/widget_layout_medium.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/layout/widget_layout_small.xml b/android-app/app/src/main/res/layout/widget_layout_small.xml
new file mode 100644
index 0000000..417bd92
--- /dev/null
+++ b/android-app/app/src/main/res/layout/widget_layout_small.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..70fd7ee
--- /dev/null
+++ b/android-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,23 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
+
+ #FF667EEA
+ #FF764BA2
+ #FFF5F5F9
+ #FFF2F2F2
+ #FFFF4757
+
+
+ #FF00B894
+ #FFFDCB6E
+ #FFFF4757
+ #FF808080
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2af0762
--- /dev/null
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+ Calendar Widget
+ 任务管理
+ 任务展示
+ 设置
+ 添加任务
+ 编辑任务
+ 删除任务
+ 执行任务
+ 任务名称
+ 最少间隔天数
+ 确定
+ 取消
+ 保存
+ 暂无任务
+ 点击右下角添加第一个任务
+ 间隔: %d 天
+ 上次: %s
+ 任务状态小组件
+ 在桌面显示任务状态,点击快速执行
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f1da931
--- /dev/null
+++ b/android-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/xml/backup_rules.xml b/android-app/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..49dd156
--- /dev/null
+++ b/android-app/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/xml/data_extraction_rules.xml b/android-app/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..d9560c7
--- /dev/null
+++ b/android-app/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android-app/app/src/main/res/xml/task_widget_info.xml b/android-app/app/src/main/res/xml/task_widget_info.xml
new file mode 100644
index 0000000..cbb2ac2
--- /dev/null
+++ b/android-app/app/src/main/res/xml/task_widget_info.xml
@@ -0,0 +1,10 @@
+
+
\ No newline at end of file
diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts
new file mode 100644
index 0000000..0d91674
--- /dev/null
+++ b/android-app/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") version "8.2.0" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.20" apply false
+ id("com.google.dagger.hilt.android") version "2.48" apply false
+}
\ No newline at end of file
diff --git a/android-app/gradle.properties b/android-app/gradle.properties
new file mode 100644
index 0000000..1db15de
--- /dev/null
+++ b/android-app/gradle.properties
@@ -0,0 +1,27 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects.
+org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+android.useAndroidX=true
+
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..332218b
--- /dev/null
+++ b/android-app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts
new file mode 100644
index 0000000..ac805dc
--- /dev/null
+++ b/android-app/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "CalendarWidget"
+include(":app")
\ No newline at end of file