feat: implement android app with widget support
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:
Ching L 2025-11-17 18:28:22 +08:00
parent 0ceeeb8f17
commit c790cee472
41 changed files with 2667 additions and 0 deletions

396
android-app-requirements.md Normal file
View 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
View 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
View 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

View 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
View 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.** { *; }

View 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>

View File

@ -0,0 +1,7 @@
package com.tunpok.calendarwidget
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CalendarWidgetApplication : Application()

View File

@ -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()
}
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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("取消")
}
}
)
}

View File

@ -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) }
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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
)
)

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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" />

View 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
}

View 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

View 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

View 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")