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