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