feat: implement android app with widget support
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
- Created comprehensive Android app requirements document - Built complete Android project with Kotlin and Jetpack Compose - Implemented task management with control and display screens - Added Android widget with 5-minute auto-refresh capability - Integrated Room database for offline support - Set up MVVM architecture with Hilt dependency injection - Configured Retrofit for API communication - Added Material Design 3 theming and UI components
This commit is contained in:
parent
0ceeeb8f17
commit
c790cee472
396
android-app-requirements.md
Normal file
396
android-app-requirements.md
Normal file
@ -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
|
||||||
|
<!-- widget_layout.xml -->
|
||||||
|
<LinearLayout android:background="#f5f5f9"
|
||||||
|
android:padding="10dp">
|
||||||
|
<GridLayout android:columnCount="@integer/widget_columns"
|
||||||
|
android:rowCount="@integer/widget_rows">
|
||||||
|
<!-- 任务卡片 -->
|
||||||
|
<LinearLayout android:background="#f2f2f2"
|
||||||
|
android:padding="10dp"
|
||||||
|
android:gravity="center">
|
||||||
|
<TextView android:id="@+id/task_name"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@android:color/black"/>
|
||||||
|
<View android:id="@+id/color_bar"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_marginTop="8dp"/>
|
||||||
|
</LinearLayout>
|
||||||
|
</GridLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据管理
|
||||||
|
|
||||||
|
### API 接口层
|
||||||
|
```kotlin
|
||||||
|
interface TaskApiService {
|
||||||
|
@GET("tasks")
|
||||||
|
suspend fun getTasks(@Header("X-Api-Key") apiKey: String): List<Task>
|
||||||
|
|
||||||
|
@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<List<TaskEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTasks(tasks: List<TaskEntity>)
|
||||||
|
|
||||||
|
@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<TaskUiState> = _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
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建配置
|
||||||
|
|
||||||
|
### 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. **国际化**
|
||||||
|
- 支持多语言
|
||||||
|
- 适配不同地区日期格式
|
||||||
|
- 时区自动转换
|
||||||
15
android-app/.gitignore
vendored
Normal file
15
android-app/.gitignore
vendored
Normal file
@ -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
|
||||||
124
android-app/README.md
Normal file
124
android-app/README.md
Normal file
@ -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
|
||||||
115
android-app/app/build.gradle.kts
Normal file
115
android-app/app/build.gradle.kts
Normal file
@ -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")
|
||||||
|
}
|
||||||
34
android-app/app/proguard-rules.pro
vendored
Normal file
34
android-app/app/proguard-rules.pro
vendored
Normal file
@ -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.* <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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.** { *; }
|
||||||
56
android-app/app/src/main/AndroidManifest.xml
Normal file
56
android-app/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".CalendarWidgetApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.CalendarWidget"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.CalendarWidget">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".DisplayActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.CalendarWidget" />
|
||||||
|
|
||||||
|
<!-- Widget Provider -->
|
||||||
|
<receiver
|
||||||
|
android:name=".ui.widget.TaskWidgetProvider"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/task_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Widget Service -->
|
||||||
|
<service
|
||||||
|
android:name=".ui.widget.TaskWidgetService"
|
||||||
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.tunpok.calendarwidget
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class CalendarWidgetApplication : Application()
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Task>
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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<List<Task>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks ORDER BY priority")
|
||||||
|
suspend fun getAllTasksOnce(): List<Task>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM tasks WHERE id = :taskId")
|
||||||
|
suspend fun getTaskById(taskId: Int): Task?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertTasks(tasks: List<Task>)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<Int>
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class TaskColor {
|
||||||
|
GREEN,
|
||||||
|
YELLOW,
|
||||||
|
RED
|
||||||
|
}
|
||||||
@ -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<List<Task>> = 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<List<Task>> = dao.getAllTasks()
|
||||||
|
|
||||||
|
suspend fun createTask(name: String, minIntervalDays: Int): Result<Task> {
|
||||||
|
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<Task> {
|
||||||
|
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<Unit> {
|
||||||
|
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<Unit> {
|
||||||
|
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<Int>): Result<Unit> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<Task>,
|
||||||
|
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("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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<Task> = 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<TaskUiState> = _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<Int>) {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Task> = 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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<Preferences> 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<String> {
|
||||||
|
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<Boolean> {
|
||||||
|
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<Int> {
|
||||||
|
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<String> {
|
||||||
|
return context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[PreferenceKeys.THEME_MODE] ?: "system"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
android-app/app/src/main/res/layout/widget_item_layout.xml
Normal file
29
android-app/app/src/main/res/layout/widget_item_layout.xml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_item_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="65dp"
|
||||||
|
android:background="@color/surface"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="10dp"
|
||||||
|
android:gravity="center">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/task_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Task Name"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/black"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/color_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:background="@color/task_gray" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
42
android-app/app/src/main/res/layout/widget_layout_large.xml
Normal file
42
android-app/app/src/main/res/layout/widget_layout_large.xml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/black" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_refresh_button"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<GridView
|
||||||
|
android:id="@+id/widget_grid_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:numColumns="4"
|
||||||
|
android:verticalSpacing="10dp"
|
||||||
|
android:horizontalSpacing="10dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
42
android-app/app/src/main/res/layout/widget_layout_medium.xml
Normal file
42
android-app/app/src/main/res/layout/widget_layout_medium.xml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/black" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_refresh_button"
|
||||||
|
android:layout_width="28dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<GridView
|
||||||
|
android:id="@+id/widget_grid_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:numColumns="2"
|
||||||
|
android:verticalSpacing="8dp"
|
||||||
|
android:horizontalSpacing="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
42
android-app/app/src/main/res/layout/widget_layout_small.xml
Normal file
42
android-app/app/src/main/res/layout/widget_layout_small.xml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/black" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_refresh_button"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
|
android:contentDescription="Refresh" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<GridView
|
||||||
|
android:id="@+id/widget_grid_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:numColumns="1"
|
||||||
|
android:verticalSpacing="4dp"
|
||||||
|
android:horizontalSpacing="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
23
android-app/app/src/main/res/values/colors.xml
Normal file
23
android-app/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Custom colors -->
|
||||||
|
<color name="primary">#FF667EEA</color>
|
||||||
|
<color name="secondary">#FF764BA2</color>
|
||||||
|
<color name="background">#FFF5F5F9</color>
|
||||||
|
<color name="surface">#FFF2F2F2</color>
|
||||||
|
<color name="error">#FFFF4757</color>
|
||||||
|
|
||||||
|
<!-- Task status colors -->
|
||||||
|
<color name="task_green">#FF00B894</color>
|
||||||
|
<color name="task_yellow">#FFFDCB6E</color>
|
||||||
|
<color name="task_red">#FFFF4757</color>
|
||||||
|
<color name="task_gray">#FF808080</color>
|
||||||
|
</resources>
|
||||||
22
android-app/app/src/main/res/values/strings.xml
Normal file
22
android-app/app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Calendar Widget</string>
|
||||||
|
<string name="task_management">任务管理</string>
|
||||||
|
<string name="task_display">任务展示</string>
|
||||||
|
<string name="settings">设置</string>
|
||||||
|
<string name="add_task">添加任务</string>
|
||||||
|
<string name="edit_task">编辑任务</string>
|
||||||
|
<string name="delete_task">删除任务</string>
|
||||||
|
<string name="execute_task">执行任务</string>
|
||||||
|
<string name="task_name">任务名称</string>
|
||||||
|
<string name="min_interval_days">最少间隔天数</string>
|
||||||
|
<string name="confirm">确定</string>
|
||||||
|
<string name="cancel">取消</string>
|
||||||
|
<string name="save">保存</string>
|
||||||
|
<string name="no_tasks">暂无任务</string>
|
||||||
|
<string name="add_first_task">点击右下角添加第一个任务</string>
|
||||||
|
<string name="interval_format">间隔: %d 天</string>
|
||||||
|
<string name="last_execution_format">上次: %s</string>
|
||||||
|
<string name="widget_name">任务状态小组件</string>
|
||||||
|
<string name="widget_description">在桌面显示任务状态,点击快速执行</string>
|
||||||
|
</resources>
|
||||||
17
android-app/app/src/main/res/values/themes.xml
Normal file
17
android-app/app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.CalendarWidget" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/secondary</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
android-app/app/src/main/res/xml/backup_rules.xml
Normal file
5
android-app/app/src/main/res/xml/backup_rules.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<full-backup-content>
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
</full-backup-content>
|
||||||
11
android-app/app/src/main/res/xml/data_extraction_rules.xml
Normal file
11
android-app/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
10
android-app/app/src/main/res/xml/task_widget_info.xml
Normal file
10
android-app/app/src/main/res/xml/task_widget_info.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="110dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:updatePeriodMillis="300000"
|
||||||
|
android:previewImage="@android:drawable/ic_menu_agenda"
|
||||||
|
android:initialLayout="@layout/widget_layout_small"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:description="@string/widget_description" />
|
||||||
6
android-app/build.gradle.kts
Normal file
6
android-app/build.gradle.kts
Normal file
@ -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
|
||||||
|
}
|
||||||
27
android-app/gradle.properties
Normal file
27
android-app/gradle.properties
Normal file
@ -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
|
||||||
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -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
|
||||||
17
android-app/settings.gradle.kts
Normal file
17
android-app/settings.gradle.kts
Normal file
@ -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")
|
||||||
Loading…
x
Reference in New Issue
Block a user