first commit
This commit is contained in:
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
|
|
||||||
|
# iOS related
|
||||||
|
**/ios/**/*.mode1v3
|
||||||
|
**/ios/**/*.mode2v3
|
||||||
|
**/ios/**/*.moved-aside
|
||||||
|
**/ios/**/*.pbxuser
|
||||||
|
**/ios/**/*.perspectivev3
|
||||||
|
**/ios/**/*sync/
|
||||||
|
**/ios/**/.sconsign.dblite
|
||||||
|
**/ios/**/.tags*
|
||||||
|
**/ios/**/.vagrant/
|
||||||
|
**/ios/**/DerivedData/
|
||||||
|
**/ios/**/Icon?
|
||||||
|
**/ios/**/Pods/
|
||||||
|
**/ios/**/.symlinks/
|
||||||
|
**/ios/**/profile
|
||||||
|
**/ios/**/xcuserdata
|
||||||
|
**/ios/.generated/
|
||||||
|
**/ios/Flutter/App.framework
|
||||||
|
**/ios/Flutter/Flutter.framework
|
||||||
|
**/ios/Flutter/Flutter.podspec
|
||||||
|
**/ios/Flutter/Generated.xcconfig
|
||||||
|
**/ios/Flutter/ephemeral
|
||||||
|
**/ios/Flutter/app.flx
|
||||||
|
**/ios/Flutter/app.zip
|
||||||
|
**/ios/Flutter/flutter_assets/
|
||||||
|
**/ios/Flutter/flutter_export_environment.sh
|
||||||
|
**/ios/ServiceDefinitions.json
|
||||||
|
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# Android related
|
||||||
|
**/android/**/gradle-wrapper.jar
|
||||||
|
**/android/.gradle
|
||||||
|
**/android/captures/
|
||||||
|
**/android/gradlew
|
||||||
|
**/android/gradlew.bat
|
||||||
|
**/android/local.properties
|
||||||
|
**/android/**/GeneratedPluginRegistrant.java
|
||||||
|
**/android/key.properties
|
||||||
|
*.jks
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||||
|
|
||||||
30
.metadata
Normal file
30
.metadata
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
- platform: web
|
||||||
|
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# AutoTime Tracker
|
||||||
|
|
||||||
|
自动时间追踪与效率分析工具
|
||||||
|
|
||||||
|
## 📱 项目简介
|
||||||
|
|
||||||
|
AutoTime Tracker 是一款自动追踪应用使用时间并进行分析的效率工具。通过自动化的方式帮助用户了解自己的时间使用,提升效率。
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- ✅ **自动时间追踪** - 后台自动追踪应用使用时间
|
||||||
|
- ✅ **智能分类** - 预设分类规则,支持手动调整
|
||||||
|
- ✅ **数据统计** - 今日/周/月统计,丰富的图表展示
|
||||||
|
- ✅ **效率评分** - 基于时间分配的效率评分
|
||||||
|
- ✅ **目标设定** - 设置每日时间目标
|
||||||
|
- ✅ **数据导出** - 导出 CSV 数据和统计报告
|
||||||
|
- ✅ **空状态优化** - 友好的空状态和错误处理
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **Flutter 3.x** - 跨平台框架
|
||||||
|
- **Riverpod** - 状态管理
|
||||||
|
- **fl_chart** - 图表库
|
||||||
|
- **sqflite** - 本地数据库
|
||||||
|
- **Google Fonts** - 字体系统
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行项目
|
||||||
|
|
||||||
|
**Web 平台(推荐用于开发测试):**
|
||||||
|
```bash
|
||||||
|
flutter run -d web-server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Android 设备:**
|
||||||
|
```bash
|
||||||
|
# 1. 连接设备并启用 USB 调试
|
||||||
|
flutter devices
|
||||||
|
|
||||||
|
# 2. 运行应用
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细说明:** 查看 [快速启动指南.md](./快速启动指南.md)
|
||||||
|
|
||||||
|
### 测试真实数据
|
||||||
|
|
||||||
|
**重要:** 测试真实数据需要安装到真实手机设备上!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接手机后运行
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细测试指南:** 查看 [真实数据测试指南.md](./真实数据测试指南.md)
|
||||||
|
|
||||||
|
## 📊 开发进度
|
||||||
|
|
||||||
|
**当前完成度:** 98%
|
||||||
|
|
||||||
|
### ✅ 已完成
|
||||||
|
- 核心功能完整实现
|
||||||
|
- 所有主要界面已实现
|
||||||
|
- 数据层完整(SQLite + DAO)
|
||||||
|
- 服务层完整(分类、统计、导出等)
|
||||||
|
- 空状态和错误处理优化
|
||||||
|
- Web 平台测试数据支持
|
||||||
|
|
||||||
|
### ⚠️ 待完善
|
||||||
|
- 原生 API 实际实现(需要真实设备测试)
|
||||||
|
- iOS Screen Time API 完整实现
|
||||||
|
- Android Usage Stats API 完整实现
|
||||||
|
|
||||||
|
**详细进度:** 查看 [开发进度.md](./开发进度.md)
|
||||||
|
|
||||||
|
## 📚 文档
|
||||||
|
|
||||||
|
- [快速启动指南.md](./快速启动指南.md) - 如何运行和测试项目
|
||||||
|
- [开发进度.md](./开发进度.md) - 开发进度和待办事项
|
||||||
|
- [真实数据测试指南.md](./真实数据测试指南.md) - 真实设备测试说明
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**项目状态:** MVP 基本完成,核心功能已实现,可以进行真实设备测试。
|
||||||
1
analysis_options.yaml
Normal file
1
analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
31
android/app/src/main/AndroidManifest.xml
Normal file
31
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="AutoTime Tracker"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.autotime.tracker
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
// MainActivity 实现
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.autotime.tracker
|
||||||
|
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.usage.UsageStats
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class TimeTrackingPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
private lateinit var channel: MethodChannel
|
||||||
|
private lateinit var context: Context
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "autotime_tracker/time_tracking")
|
||||||
|
channel.setMethodCallHandler(this)
|
||||||
|
context = flutterPluginBinding.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"hasPermission" -> hasPermission(result)
|
||||||
|
"requestPermission" -> requestPermission(result)
|
||||||
|
"getAppUsage" -> getAppUsage(call, result)
|
||||||
|
"startBackgroundTracking" -> startBackgroundTracking(result)
|
||||||
|
"stopBackgroundTracking" -> stopBackgroundTracking(result)
|
||||||
|
"isBackgroundTrackingActive" -> isBackgroundTrackingActive(result)
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Methods
|
||||||
|
|
||||||
|
private fun hasPermission(result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||||
|
val mode = appOps.checkOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||||
|
android.os.Process.myUid(),
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
result.success(mode == AppOpsManager.MODE_ALLOWED)
|
||||||
|
} else {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPermission(result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
context.startActivity(intent)
|
||||||
|
result.success(true)
|
||||||
|
} else {
|
||||||
|
result.error(
|
||||||
|
"UNSUPPORTED_VERSION",
|
||||||
|
"Usage Stats API requires Android 5.0 (API 21) or later",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App Usage Methods
|
||||||
|
|
||||||
|
private fun getAppUsage(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
result.error(
|
||||||
|
"UNSUPPORTED_VERSION",
|
||||||
|
"Usage Stats API requires Android 5.0 (API 21) or later",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val args = call.arguments as? Map<*, *>
|
||||||
|
val startTimeMs = args?.get("startTime") as? Long
|
||||||
|
val endTimeMs = args?.get("endTime") as? Long
|
||||||
|
|
||||||
|
if (startTimeMs == null || endTimeMs == null) {
|
||||||
|
result.error("INVALID_ARGUMENTS", "Invalid arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (!hasUsageStatsPermission()) {
|
||||||
|
result.error(
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
"Usage Stats permission not granted",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
|
||||||
|
val startTime = startTimeMs
|
||||||
|
val endTime = endTimeMs
|
||||||
|
|
||||||
|
// 查询应用使用统计
|
||||||
|
val stats = usageStatsManager.queryUsageStats(
|
||||||
|
UsageStatsManager.INTERVAL_DAILY,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stats == null || stats.isEmpty()) {
|
||||||
|
result.success(emptyList<Map<String, Any>>())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Flutter 可用的格式
|
||||||
|
val appUsageList = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
|
// 按包名聚合数据
|
||||||
|
val packageMap = mutableMapOf<String, UsageStats>()
|
||||||
|
for (stat in stats) {
|
||||||
|
val packageName = stat.packageName
|
||||||
|
val existing = packageMap[packageName]
|
||||||
|
|
||||||
|
if (existing == null || stat.totalTimeInForeground > existing.totalTimeInForeground) {
|
||||||
|
packageMap[packageName] = stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为列表
|
||||||
|
for ((packageName, stat) in packageMap) {
|
||||||
|
val appName = getAppName(packageName)
|
||||||
|
|
||||||
|
appUsageList.add(mapOf(
|
||||||
|
"packageName" to packageName,
|
||||||
|
"appName" to appName,
|
||||||
|
"startTime" to stat.firstTimeStamp,
|
||||||
|
"endTime" to stat.lastTimeUsed,
|
||||||
|
"duration" to (stat.totalTimeInForeground / 1000).toInt(), // 转换为秒
|
||||||
|
"deviceUnlockCount" to 0 // Android 不直接提供此数据
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(appUsageList)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasUsageStatsPermission(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||||
|
val mode = appOps.checkOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||||
|
android.os.Process.myUid(),
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
return mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAppName(packageName: String): String {
|
||||||
|
return try {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
|
packageManager.getApplicationLabel(applicationInfo).toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background Tracking Methods
|
||||||
|
|
||||||
|
private fun startBackgroundTracking(result: MethodChannel.Result) {
|
||||||
|
// Android 后台追踪实现
|
||||||
|
// 可以使用 WorkManager 或 Foreground Service
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopBackgroundTracking(result: MethodChannel.Result) {
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBackgroundTrackingActive(result: MethodChannel.Result) {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
ios/Runner/AppDelegate.swift
Normal file
19
ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import UIKit
|
||||||
|
import Flutter
|
||||||
|
|
||||||
|
@UIApplicationMain
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
// 注册 TimeTrackingPlugin
|
||||||
|
let controller = window?.rootViewController as! FlutterViewController
|
||||||
|
TimeTrackingPlugin.register(with: registrar(forPlugin: "TimeTrackingPlugin")!)
|
||||||
|
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
154
ios/Runner/TimeTrackingPlugin.swift
Normal file
154
ios/Runner/TimeTrackingPlugin.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import ScreenTime
|
||||||
|
|
||||||
|
@available(iOS 14.0, *)
|
||||||
|
public class TimeTrackingPlugin: NSObject, FlutterPlugin {
|
||||||
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "autotime_tracker/time_tracking",
|
||||||
|
binaryMessenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
let instance = TimeTrackingPlugin()
|
||||||
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "hasPermission":
|
||||||
|
hasPermission(result: result)
|
||||||
|
case "requestPermission":
|
||||||
|
requestPermission(result: result)
|
||||||
|
case "getAppUsage":
|
||||||
|
getAppUsage(call: call, result: result)
|
||||||
|
case "startBackgroundTracking":
|
||||||
|
startBackgroundTracking(result: result)
|
||||||
|
case "stopBackgroundTracking":
|
||||||
|
stopBackgroundTracking(result: result)
|
||||||
|
case "isBackgroundTrackingActive":
|
||||||
|
isBackgroundTrackingActive(result: result)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Methods
|
||||||
|
|
||||||
|
private func hasPermission(result: @escaping FlutterResult) {
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
let authorizationStatus = STScreenTime.getAuthorizationStatus()
|
||||||
|
result(authorizationStatus == .authorized)
|
||||||
|
} else {
|
||||||
|
result(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPermission(result: @escaping FlutterResult) {
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
STScreenTime.requestAuthorization { success, error in
|
||||||
|
if let error = error {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "AUTHORIZATION_FAILED",
|
||||||
|
message: error.localizedDescription,
|
||||||
|
details: nil
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
result(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "UNSUPPORTED_VERSION",
|
||||||
|
message: "Screen Time API requires iOS 14.0 or later",
|
||||||
|
details: nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App Usage Methods
|
||||||
|
|
||||||
|
@available(iOS 14.0, *)
|
||||||
|
private func getAppUsage(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let args = call.arguments as? [String: Any],
|
||||||
|
let startTimeMs = args["startTime"] as? Int64,
|
||||||
|
let endTimeMs = args["endTime"] as? Int64 else {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "INVALID_ARGUMENTS",
|
||||||
|
message: "Invalid arguments",
|
||||||
|
details: nil
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime = Date(timeIntervalSince1970: Double(startTimeMs) / 1000.0)
|
||||||
|
let endTime = Date(timeIntervalSince1970: Double(endTimeMs) / 1000.0)
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
guard STScreenTime.getAuthorizationStatus() == .authorized else {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "PERMISSION_DENIED",
|
||||||
|
message: "Screen Time permission not granted",
|
||||||
|
details: nil
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取应用使用数据
|
||||||
|
STScreenTime.getAppUsage(from: startTime, to: endTime) { usage, error in
|
||||||
|
if let error = error {
|
||||||
|
result(FlutterError(
|
||||||
|
code: "GET_USAGE_FAILED",
|
||||||
|
message: error.localizedDescription,
|
||||||
|
details: nil
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let usage = usage else {
|
||||||
|
result([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Flutter 可用的格式
|
||||||
|
var appUsageList: [[String: Any]] = []
|
||||||
|
|
||||||
|
// 遍历应用使用数据
|
||||||
|
// 注意:实际的 Screen Time API 使用方式可能不同
|
||||||
|
// 这里提供基本框架,需要根据实际 API 调整
|
||||||
|
|
||||||
|
// 示例数据结构
|
||||||
|
for (bundleId, timeInterval) in usage {
|
||||||
|
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? bundleId
|
||||||
|
|
||||||
|
appUsageList.append([
|
||||||
|
"packageName": bundleId,
|
||||||
|
"appName": appName,
|
||||||
|
"startTime": startTime.timeIntervalSince1970 * 1000,
|
||||||
|
"endTime": endTime.timeIntervalSince1970 * 1000,
|
||||||
|
"duration": Int(timeInterval),
|
||||||
|
"deviceUnlockCount": 0
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
result(appUsageList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background Tracking Methods
|
||||||
|
|
||||||
|
private func startBackgroundTracking(result: @escaping FlutterResult) {
|
||||||
|
// iOS 后台追踪实现
|
||||||
|
// 注意:iOS 对后台运行有严格限制
|
||||||
|
// 可以使用 Background Tasks 或 Notification Service Extension
|
||||||
|
result(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopBackgroundTracking(result: @escaping FlutterResult) {
|
||||||
|
result(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isBackgroundTrackingActive(result: @escaping FlutterResult) {
|
||||||
|
result(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
154
lib/database/app_usage_dao.dart
Normal file
154
lib/database/app_usage_dao.dart
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
class AppUsageDao {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||||
|
|
||||||
|
// Web 平台检查
|
||||||
|
bool get _isWeb => kIsWeb;
|
||||||
|
|
||||||
|
// 插入应用使用记录
|
||||||
|
Future<int> insertAppUsage(AppUsage usage) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.insert('app_usage', usage.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
Future<void> batchInsertAppUsages(List<AppUsage> usages) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final usage in usages) {
|
||||||
|
batch.insert('app_usage', usage.toMap());
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定时间范围的应用使用记录
|
||||||
|
Future<List<AppUsage>> getAppUsages({
|
||||||
|
required DateTime startTime,
|
||||||
|
required DateTime endTime,
|
||||||
|
}) async {
|
||||||
|
if (_isWeb) return [];
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final startTimestamp = startTime.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final endTimestamp = endTime.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
final maps = await db.query(
|
||||||
|
'app_usage',
|
||||||
|
where: 'start_time >= ? AND end_time <= ?',
|
||||||
|
whereArgs: [startTimestamp, endTimestamp],
|
||||||
|
orderBy: 'start_time DESC',
|
||||||
|
);
|
||||||
|
|
||||||
|
return maps.map((map) {
|
||||||
|
return AppUsage(
|
||||||
|
id: map['id'] as int?,
|
||||||
|
packageName: map['package_name'] as String,
|
||||||
|
appName: map['app_name'] as String,
|
||||||
|
startTime: DateTime.fromMillisecondsSinceEpoch((map['start_time'] as int) * 1000),
|
||||||
|
endTime: DateTime.fromMillisecondsSinceEpoch((map['end_time'] as int) * 1000),
|
||||||
|
duration: map['duration'] as int,
|
||||||
|
category: map['category'] as String,
|
||||||
|
projectId: map['project_id'] as int?,
|
||||||
|
deviceUnlockCount: map['device_unlock_count'] as int,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((map['created_at'] as int) * 1000),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch((map['updated_at'] as int) * 1000),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取今日应用使用记录
|
||||||
|
Future<List<AppUsage>> getTodayAppUsages() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||||
|
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
return await getAppUsages(startTime: startOfDay, endTime: endOfDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Top N 应用
|
||||||
|
Future<List<AppUsage>> getTopApps({
|
||||||
|
required DateTime startTime,
|
||||||
|
required DateTime endTime,
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
if (_isWeb) return [];
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final startTimestamp = startTime.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
final endTimestamp = endTime.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
final maps = await db.rawQuery('''
|
||||||
|
SELECT
|
||||||
|
package_name,
|
||||||
|
app_name,
|
||||||
|
category,
|
||||||
|
SUM(duration) as total_duration,
|
||||||
|
MIN(start_time) as first_start_time,
|
||||||
|
MAX(end_time) as last_end_time,
|
||||||
|
MIN(created_at) as created_at,
|
||||||
|
MAX(updated_at) as updated_at
|
||||||
|
FROM app_usage
|
||||||
|
WHERE start_time >= ? AND end_time <= ?
|
||||||
|
GROUP BY package_name, app_name, category
|
||||||
|
ORDER BY total_duration DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', [startTimestamp, endTimestamp, limit]);
|
||||||
|
|
||||||
|
return maps.map((map) {
|
||||||
|
final firstStart = DateTime.fromMillisecondsSinceEpoch((map['first_start_time'] as int) * 1000);
|
||||||
|
final lastEnd = DateTime.fromMillisecondsSinceEpoch((map['last_end_time'] as int) * 1000);
|
||||||
|
return AppUsage(
|
||||||
|
packageName: map['package_name'] as String,
|
||||||
|
appName: map['app_name'] as String,
|
||||||
|
startTime: firstStart,
|
||||||
|
endTime: lastEnd,
|
||||||
|
duration: map['total_duration'] as int,
|
||||||
|
category: map['category'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((map['created_at'] as int) * 1000),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch((map['updated_at'] as int) * 1000),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新应用分类
|
||||||
|
Future<void> updateAppUsageCategory(int id, String category) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.update(
|
||||||
|
'app_usage',
|
||||||
|
{
|
||||||
|
'category': category,
|
||||||
|
'updated_at': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除应用使用记录
|
||||||
|
Future<void> deleteAppUsage(int id) async {
|
||||||
|
if (_isWeb) return;
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.delete(
|
||||||
|
'app_usage',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定日期之前的数据(用于数据清理)
|
||||||
|
Future<void> deleteBeforeDate(DateTime date) async {
|
||||||
|
if (_isWeb) return;
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final timestamp = date.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
await db.delete(
|
||||||
|
'app_usage',
|
||||||
|
where: 'start_time < ?',
|
||||||
|
whereArgs: [timestamp],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
165
lib/database/daily_stats_dao.dart
Normal file
165
lib/database/daily_stats_dao.dart
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
class DailyStatsDao {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||||
|
|
||||||
|
// Web 平台检查
|
||||||
|
bool get _isWeb => kIsWeb;
|
||||||
|
|
||||||
|
// 插入或更新每日统计
|
||||||
|
Future<void> upsertDailyStats(DailyStats stats) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final dateStr = _formatDate(stats.date);
|
||||||
|
final map = stats.toMap();
|
||||||
|
|
||||||
|
final existing = await db.query(
|
||||||
|
'daily_stats',
|
||||||
|
where: 'date = ?',
|
||||||
|
whereArgs: [dateStr],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
await db.insert('daily_stats', map);
|
||||||
|
} else {
|
||||||
|
await db.update(
|
||||||
|
'daily_stats',
|
||||||
|
map,
|
||||||
|
where: 'date = ?',
|
||||||
|
whereArgs: [dateStr],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定日期的统计
|
||||||
|
Future<DailyStats?> getDailyStats(DateTime date) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final dateStr = _formatDate(date);
|
||||||
|
|
||||||
|
final maps = await db.query(
|
||||||
|
'daily_stats',
|
||||||
|
where: 'date = ?',
|
||||||
|
whereArgs: [dateStr],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
|
||||||
|
return _mapToDailyStats(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取今日统计
|
||||||
|
Future<DailyStats?> getTodayStats() async {
|
||||||
|
return await getDailyStats(DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定日期范围的统计
|
||||||
|
Future<List<DailyStats>> getStatsRange({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
if (_isWeb) return [];
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final startStr = _formatDate(startDate);
|
||||||
|
final endStr = _formatDate(endDate);
|
||||||
|
|
||||||
|
final maps = await db.query(
|
||||||
|
'daily_stats',
|
||||||
|
where: 'date >= ? AND date <= ?',
|
||||||
|
whereArgs: [startStr, endStr],
|
||||||
|
orderBy: 'date ASC',
|
||||||
|
);
|
||||||
|
|
||||||
|
return maps.map((map) => _mapToDailyStats(map)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本周统计
|
||||||
|
Future<List<DailyStats>> getWeekStats() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||||
|
final endOfWeek = startOfWeek.add(const Duration(days: 6));
|
||||||
|
return await getStatsRange(startDate: startOfWeek, endDate: endOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本月统计
|
||||||
|
Future<List<DailyStats>> getMonthStats() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfMonth = DateTime(now.year, now.month, 1);
|
||||||
|
final endOfMonth = DateTime(now.year, now.month + 1, 0);
|
||||||
|
return await getStatsRange(startDate: startOfMonth, endDate: endOfMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定日期之前的数据
|
||||||
|
Future<void> deleteBeforeDate(DateTime date) async {
|
||||||
|
if (_isWeb) return;
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final dateStr = _formatDate(date);
|
||||||
|
await db.delete(
|
||||||
|
'daily_stats',
|
||||||
|
where: 'date < ?',
|
||||||
|
whereArgs: [dateStr],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定 ID 的统计
|
||||||
|
Future<void> deleteDailyStats(int id) async {
|
||||||
|
if (_isWeb) return;
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.delete(
|
||||||
|
'daily_stats',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
DailyStats _mapToDailyStats(Map<String, dynamic> map) {
|
||||||
|
return DailyStats(
|
||||||
|
id: map['id'] as int?,
|
||||||
|
date: DateTime.parse(map['date'] as String),
|
||||||
|
totalTime: map['total_time'] as int,
|
||||||
|
workTime: map['work_time'] as int? ?? 0,
|
||||||
|
studyTime: map['study_time'] as int? ?? 0,
|
||||||
|
entertainmentTime: map['entertainment_time'] as int? ?? 0,
|
||||||
|
socialTime: map['social_time'] as int? ?? 0,
|
||||||
|
toolTime: map['tool_time'] as int? ?? 0,
|
||||||
|
efficiencyScore: map['efficiency_score'] as int?,
|
||||||
|
focusScore: map['focus_score'] as int?,
|
||||||
|
appSwitchCount: map['app_switch_count'] as int? ?? 0,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((map['created_at'] as int) * 1000),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch((map['updated_at'] as int) * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 DailyStats 模型,添加 toMap 方法
|
||||||
|
extension DailyStatsExtension on DailyStats {
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'date': _formatDate(date),
|
||||||
|
'total_time': totalTime,
|
||||||
|
'work_time': workTime,
|
||||||
|
'study_time': studyTime,
|
||||||
|
'entertainment_time': entertainmentTime,
|
||||||
|
'social_time': socialTime,
|
||||||
|
'tool_time': toolTime,
|
||||||
|
'efficiency_score': efficiencyScore,
|
||||||
|
'focus_score': focusScore,
|
||||||
|
'app_switch_count': appSwitchCount,
|
||||||
|
'created_at': createdAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'updated_at': updatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime date) {
|
||||||
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
114
lib/database/database_helper.dart
Normal file
114
lib/database/database_helper.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
|
class DatabaseHelper {
|
||||||
|
static final DatabaseHelper instance = DatabaseHelper._init();
|
||||||
|
static Database? _database;
|
||||||
|
|
||||||
|
DatabaseHelper._init();
|
||||||
|
|
||||||
|
Future<Database> get database async {
|
||||||
|
// Web 平台不支持 sqflite
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('SQLite is not supported on Web platform. Use mock data instead.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_database != null) return _database!;
|
||||||
|
_database = await _initDB('autotime_tracker.db');
|
||||||
|
return _database!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _initDB(String filePath) async {
|
||||||
|
// Web 平台不支持 sqflite
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('SQLite is not supported on Web platform.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbPath = await getDatabasesPath();
|
||||||
|
final path = join(dbPath, filePath);
|
||||||
|
|
||||||
|
return await openDatabase(
|
||||||
|
path,
|
||||||
|
version: 1,
|
||||||
|
onCreate: _createDB,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createDB(Database db, int version) async {
|
||||||
|
// 应用使用记录表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE app_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
package_name TEXT NOT NULL,
|
||||||
|
app_name TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
end_time INTEGER NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
project_id INTEGER,
|
||||||
|
device_unlock_count INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 每日统计表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE daily_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL UNIQUE,
|
||||||
|
total_time INTEGER NOT NULL,
|
||||||
|
work_time INTEGER DEFAULT 0,
|
||||||
|
study_time INTEGER DEFAULT 0,
|
||||||
|
entertainment_time INTEGER DEFAULT 0,
|
||||||
|
social_time INTEGER DEFAULT 0,
|
||||||
|
tool_time INTEGER DEFAULT 0,
|
||||||
|
efficiency_score INTEGER,
|
||||||
|
focus_score INTEGER,
|
||||||
|
app_switch_count INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 应用分类表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE app_category (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
package_name TEXT NOT NULL UNIQUE,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
is_custom INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 时间目标表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE time_goal (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
goal_type TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
target_time INTEGER NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
await db.execute('CREATE INDEX idx_app_usage_date ON app_usage(start_time)');
|
||||||
|
await db.execute('CREATE INDEX idx_app_usage_category ON app_usage(category)');
|
||||||
|
await db.execute('CREATE INDEX idx_app_usage_package ON app_usage(package_name)');
|
||||||
|
await db.execute('CREATE INDEX idx_daily_stats_date ON daily_stats(date)');
|
||||||
|
await db.execute('CREATE INDEX idx_app_category_package ON app_category(package_name)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭数据库
|
||||||
|
Future<void> close() async {
|
||||||
|
final db = await database;
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
130
lib/database/time_goal_dao.dart
Normal file
130
lib/database/time_goal_dao.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../models/time_goal.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
class TimeGoalDao {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||||
|
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
final List<TimeGoal> _webGoals = [];
|
||||||
|
|
||||||
|
// Web 平台检查
|
||||||
|
bool get _isWeb => kIsWeb;
|
||||||
|
|
||||||
|
// 插入或更新时间目标
|
||||||
|
Future<void> upsertTimeGoal(TimeGoal goal) async {
|
||||||
|
if (_isWeb) {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
final index = _webGoals.indexWhere((g) =>
|
||||||
|
g.goalType == goal.goalType && g.category == goal.category);
|
||||||
|
if (index >= 0) {
|
||||||
|
_webGoals[index] = goal;
|
||||||
|
} else {
|
||||||
|
_webGoals.add(goal);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
final map = {
|
||||||
|
'goal_type': goal.goalType,
|
||||||
|
'category': goal.category,
|
||||||
|
'target_time': goal.targetTime,
|
||||||
|
'is_active': goal.isActive ? 1 : 0,
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否已存在相同类型的目标
|
||||||
|
final existing = await db.query(
|
||||||
|
'time_goal',
|
||||||
|
where: 'goal_type = ? AND (category = ? OR category IS NULL)',
|
||||||
|
whereArgs: [
|
||||||
|
goal.goalType,
|
||||||
|
goal.category,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
await db.insert('time_goal', map);
|
||||||
|
} else {
|
||||||
|
await db.update(
|
||||||
|
'time_goal',
|
||||||
|
map,
|
||||||
|
where: 'goal_type = ? AND (category = ? OR category IS NULL)',
|
||||||
|
whereArgs: [
|
||||||
|
goal.goalType,
|
||||||
|
goal.category,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有激活的目标
|
||||||
|
Future<List<TimeGoal>> getActiveGoals() async {
|
||||||
|
if (_isWeb) {
|
||||||
|
return _webGoals.where((g) => g.isActive).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'time_goal',
|
||||||
|
where: 'is_active = 1',
|
||||||
|
orderBy: 'goal_type, category',
|
||||||
|
);
|
||||||
|
|
||||||
|
return maps.map((map) => TimeGoal.fromMap(map)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有目标(包括非激活的)
|
||||||
|
Future<List<TimeGoal>> getAllGoals() async {
|
||||||
|
if (_isWeb) {
|
||||||
|
return List<TimeGoal>.from(_webGoals);
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'time_goal',
|
||||||
|
orderBy: 'goal_type, category',
|
||||||
|
);
|
||||||
|
|
||||||
|
return maps.map((map) => TimeGoal.fromMap(map)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定类型的目标
|
||||||
|
Future<TimeGoal?> getGoal(String goalType, {String? category}) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'time_goal',
|
||||||
|
where: 'goal_type = ? AND (category = ? OR category IS NULL) AND is_active = 1',
|
||||||
|
whereArgs: [goalType, category],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return TimeGoal.fromMap(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除目标
|
||||||
|
Future<void> deleteGoal(int id) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.delete(
|
||||||
|
'time_goal',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停用目标
|
||||||
|
Future<void> deactivateGoal(int id) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.update(
|
||||||
|
'time_goal',
|
||||||
|
{'is_active': 0, 'updated_at': DateTime.now().millisecondsSinceEpoch ~/ 1000},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
84
lib/main.dart
Normal file
84
lib/main.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
|
import 'screens/home_screen.dart';
|
||||||
|
import 'screens/permission_screen.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
|
import 'providers/time_tracking_provider.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// 初始化日期格式化本地化数据
|
||||||
|
await initializeDateFormatting('zh_CN', null);
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
const ProviderScope(
|
||||||
|
child: AutoTimeTrackerApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoTimeTrackerApp extends StatelessWidget {
|
||||||
|
const AutoTimeTrackerApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'AutoTime Tracker',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: AppTheme.lightTheme,
|
||||||
|
darkTheme: AppTheme.darkTheme,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
home: const PermissionCheckScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 权限检查屏幕 - 检查权限后决定显示哪个页面
|
||||||
|
class PermissionCheckScreen extends ConsumerWidget {
|
||||||
|
const PermissionCheckScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Web 平台直接显示主界面(使用测试数据)
|
||||||
|
if (kIsWeb) {
|
||||||
|
return const HomeScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
final permissionStatus = ref.watch(permissionStatusProvider);
|
||||||
|
|
||||||
|
return permissionStatus.when(
|
||||||
|
data: (hasPermission) {
|
||||||
|
if (hasPermission) {
|
||||||
|
return const HomeScreen();
|
||||||
|
} else {
|
||||||
|
return const PermissionScreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loading: () => const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (error, stack) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('检查权限时出错: $error'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.invalidate(permissionStatusProvider);
|
||||||
|
},
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/models/app_usage.dart
Normal file
70
lib/models/app_usage.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
class AppUsage {
|
||||||
|
final int? id;
|
||||||
|
final String packageName;
|
||||||
|
final String appName;
|
||||||
|
final DateTime startTime;
|
||||||
|
final DateTime endTime;
|
||||||
|
final int duration; // 秒
|
||||||
|
final String category;
|
||||||
|
final int? projectId;
|
||||||
|
final int deviceUnlockCount;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
AppUsage({
|
||||||
|
this.id,
|
||||||
|
required this.packageName,
|
||||||
|
required this.appName,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.duration,
|
||||||
|
required this.category,
|
||||||
|
this.projectId,
|
||||||
|
this.deviceUnlockCount = 0,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'package_name': packageName,
|
||||||
|
'app_name': appName,
|
||||||
|
'start_time': startTime.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'end_time': endTime.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'duration': duration,
|
||||||
|
'category': category,
|
||||||
|
'project_id': projectId,
|
||||||
|
'device_unlock_count': deviceUnlockCount,
|
||||||
|
'created_at': createdAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'updated_at': updatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AppUsage.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AppUsage(
|
||||||
|
id: map['id'],
|
||||||
|
packageName: map['package_name'],
|
||||||
|
appName: map['app_name'],
|
||||||
|
startTime: DateTime.fromMillisecondsSinceEpoch(map['start_time'] * 1000),
|
||||||
|
endTime: DateTime.fromMillisecondsSinceEpoch(map['end_time'] * 1000),
|
||||||
|
duration: map['duration'],
|
||||||
|
category: map['category'],
|
||||||
|
projectId: map['project_id'],
|
||||||
|
deviceUnlockCount: map['device_unlock_count'] ?? 0,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] * 1000),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
String get formattedDuration {
|
||||||
|
final hours = duration ~/ 3600;
|
||||||
|
final minutes = (duration % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
64
lib/models/daily_stats.dart
Normal file
64
lib/models/daily_stats.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class DailyStats {
|
||||||
|
final int? id;
|
||||||
|
final DateTime date;
|
||||||
|
final int totalTime; // 秒
|
||||||
|
final int workTime;
|
||||||
|
final int studyTime;
|
||||||
|
final int entertainmentTime;
|
||||||
|
final int socialTime;
|
||||||
|
final int toolTime;
|
||||||
|
final int? efficiencyScore;
|
||||||
|
final int? focusScore;
|
||||||
|
final int appSwitchCount;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
DailyStats({
|
||||||
|
this.id,
|
||||||
|
required this.date,
|
||||||
|
required this.totalTime,
|
||||||
|
this.workTime = 0,
|
||||||
|
this.studyTime = 0,
|
||||||
|
this.entertainmentTime = 0,
|
||||||
|
this.socialTime = 0,
|
||||||
|
this.toolTime = 0,
|
||||||
|
this.efficiencyScore,
|
||||||
|
this.focusScore,
|
||||||
|
this.appSwitchCount = 0,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, int> get categoryTime => {
|
||||||
|
'work': workTime,
|
||||||
|
'study': studyTime,
|
||||||
|
'entertainment': entertainmentTime,
|
||||||
|
'social': socialTime,
|
||||||
|
'tool': toolTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化总时长
|
||||||
|
String get formattedTotalTime {
|
||||||
|
final hours = totalTime ~/ 3600;
|
||||||
|
final minutes = (totalTime % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取效率评分颜色
|
||||||
|
Color get efficiencyColor {
|
||||||
|
final score = efficiencyScore ?? 0;
|
||||||
|
if (score >= 80) {
|
||||||
|
return const Color(0xFF10B981); // Green
|
||||||
|
} else if (score >= 50) {
|
||||||
|
return const Color(0xFFF59E0B); // Orange
|
||||||
|
} else {
|
||||||
|
return const Color(0xFFEF4444); // Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
lib/models/time_goal.dart
Normal file
54
lib/models/time_goal.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
class TimeGoal {
|
||||||
|
final int? id;
|
||||||
|
final String goalType; // 'daily_total' 或 'daily_category'
|
||||||
|
final String? category; // 如果是分类目标,指定分类
|
||||||
|
final int targetTime; // 目标时长(秒)
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
TimeGoal({
|
||||||
|
this.id,
|
||||||
|
required this.goalType,
|
||||||
|
this.category,
|
||||||
|
required this.targetTime,
|
||||||
|
this.isActive = true,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'goal_type': goalType,
|
||||||
|
'category': category,
|
||||||
|
'target_time': targetTime,
|
||||||
|
'is_active': isActive ? 1 : 0,
|
||||||
|
'created_at': createdAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'updated_at': updatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory TimeGoal.fromMap(Map<String, dynamic> map) {
|
||||||
|
return TimeGoal(
|
||||||
|
id: map['id'] as int?,
|
||||||
|
goalType: map['goal_type'] as String,
|
||||||
|
category: map['category'] as String?,
|
||||||
|
targetTime: map['target_time'] as int,
|
||||||
|
isActive: (map['is_active'] as int) == 1,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((map['created_at'] as int) * 1000),
|
||||||
|
updatedAt: DateTime.fromMillisecondsSinceEpoch((map['updated_at'] as int) * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化目标时长
|
||||||
|
String get formattedTargetTime {
|
||||||
|
final hours = targetTime ~/ 3600;
|
||||||
|
final minutes = (targetTime % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}小时${minutes}分钟';
|
||||||
|
}
|
||||||
|
return '${minutes}分钟';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
lib/providers/background_sync_provider.dart
Normal file
20
lib/providers/background_sync_provider.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../services/background_sync_service.dart';
|
||||||
|
|
||||||
|
// BackgroundSyncService Provider
|
||||||
|
final backgroundSyncServiceProvider = Provider<BackgroundSyncService>((ref) {
|
||||||
|
final service = BackgroundSyncService();
|
||||||
|
|
||||||
|
// 当 Provider 被销毁时,停止服务
|
||||||
|
ref.onDispose(() {
|
||||||
|
service.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 后台同步状态 Provider
|
||||||
|
final backgroundSyncStatusProvider = StateProvider<bool>((ref) {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
54
lib/providers/statistics_provider.dart
Normal file
54
lib/providers/statistics_provider.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
import '../services/statistics_service.dart';
|
||||||
|
|
||||||
|
// StatisticsService Provider
|
||||||
|
final statisticsServiceProvider = Provider<StatisticsService>((ref) {
|
||||||
|
return StatisticsService();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 今日统计 Provider
|
||||||
|
final todayStatsProvider = FutureProvider<DailyStats>((ref) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
return await service.getTodayStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本周统计 Provider
|
||||||
|
final weekStatsProvider = FutureProvider<List<DailyStats>>((ref) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
return await service.getWeekStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 本月统计 Provider
|
||||||
|
final monthStatsProvider = FutureProvider<List<DailyStats>>((ref) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
return await service.getMonthStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 今日统计列表 Provider(用于日视图,只返回今日数据)
|
||||||
|
final todayStatsListProvider = FutureProvider<List<DailyStats>>((ref) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
final todayStats = await service.getTodayStats();
|
||||||
|
return [todayStats];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 今日 Top 应用 Provider
|
||||||
|
final todayTopAppsProvider = FutureProvider<List<AppUsage>>((ref) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||||
|
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
return await service.getTopApps(
|
||||||
|
startTime: startOfDay,
|
||||||
|
endTime: endOfDay,
|
||||||
|
limit: 5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新今日统计 Provider
|
||||||
|
final refreshTodayStatsProvider = FutureProvider.family<DailyStats, void>((ref, _) async {
|
||||||
|
final service = ref.read(statisticsServiceProvider);
|
||||||
|
return await service.refreshTodayStats();
|
||||||
|
});
|
||||||
|
|
||||||
20
lib/providers/time_tracking_provider.dart
Normal file
20
lib/providers/time_tracking_provider.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../services/time_tracking_service.dart';
|
||||||
|
|
||||||
|
// TimeTrackingService Provider
|
||||||
|
final timeTrackingServiceProvider = Provider<TimeTrackingService>((ref) {
|
||||||
|
return TimeTrackingService();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 权限状态 Provider
|
||||||
|
final permissionStatusProvider = FutureProvider<bool>((ref) async {
|
||||||
|
final service = ref.read(timeTrackingServiceProvider);
|
||||||
|
return await service.hasPermission();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 后台追踪状态 Provider
|
||||||
|
final backgroundTrackingStatusProvider = FutureProvider<bool>((ref) async {
|
||||||
|
final service = ref.read(timeTrackingServiceProvider);
|
||||||
|
return await service.isBackgroundTrackingActive();
|
||||||
|
});
|
||||||
|
|
||||||
293
lib/screens/about_screen.dart
Normal file
293
lib/screens/about_screen.dart
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class AboutScreen extends StatelessWidget {
|
||||||
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('关于'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 应用图标和名称
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.timer,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'AutoTime Tracker',
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'版本 1.0.0',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// 应用描述
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'关于应用',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'AutoTime Tracker 是一款自动时间追踪与效率分析工具。'
|
||||||
|
'它可以帮助您自动追踪应用使用情况,分析时间分配,'
|
||||||
|
'并提供效率评分和个性化建议。',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 功能特点
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'核心功能',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildFeatureItem(theme, Icons.auto_awesome, '自动追踪', '无需手动操作,自动记录应用使用时间'),
|
||||||
|
_buildFeatureItem(theme, Icons.category, '智能分类', '自动将应用分类为工作、学习、娱乐等'),
|
||||||
|
_buildFeatureItem(theme, Icons.insights, '数据分析', '提供详细的统计分析和效率评分'),
|
||||||
|
_buildFeatureItem(theme, Icons.flag, '目标设定', '设置时间目标,追踪完成情况'),
|
||||||
|
_buildFeatureItem(theme, Icons.file_download, '数据导出', '导出 CSV 和统计报告'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildLinkItem(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.description,
|
||||||
|
'隐私政策',
|
||||||
|
'查看我们的隐私政策',
|
||||||
|
() {
|
||||||
|
// TODO: 打开隐私政策页面
|
||||||
|
_showComingSoon(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildLinkItem(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.feedback,
|
||||||
|
'反馈建议',
|
||||||
|
'帮助我们改进应用',
|
||||||
|
() {
|
||||||
|
_launchEmail(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildLinkItem(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.star,
|
||||||
|
'评价应用',
|
||||||
|
'在应用商店给我们评分',
|
||||||
|
() {
|
||||||
|
// TODO: 打开应用商店
|
||||||
|
_showComingSoon(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildLinkItem(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.code,
|
||||||
|
'开源许可',
|
||||||
|
'MIT License',
|
||||||
|
() {
|
||||||
|
_showLicense(context, theme);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 版权信息
|
||||||
|
Text(
|
||||||
|
'© 2024 AutoTime Tracker',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Made with ❤️ using Flutter',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFeatureItem(ThemeData theme, IconData icon, String title, String description) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppTheme.primaryColor, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLinkItem(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
IconData icon,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
VoidCallback onTap,
|
||||||
|
) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: AppTheme.primaryColor),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchEmail(BuildContext context) {
|
||||||
|
// 显示反馈邮箱
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('反馈建议'),
|
||||||
|
content: const Text(
|
||||||
|
'欢迎通过以下方式联系我们:\n\n'
|
||||||
|
'邮箱:support@autotime-tracker.com\n\n'
|
||||||
|
'我们非常重视您的反馈和建议!',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showComingSoon(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('即将推出'),
|
||||||
|
content: const Text('此功能正在开发中,敬请期待!'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLicense(BuildContext context, ThemeData theme) {
|
||||||
|
showLicensePage(
|
||||||
|
context: context,
|
||||||
|
applicationName: 'AutoTime Tracker',
|
||||||
|
applicationVersion: '1.0.0',
|
||||||
|
applicationIcon: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.timer,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
263
lib/screens/appearance_settings_screen.dart
Normal file
263
lib/screens/appearance_settings_screen.dart
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class AppearanceSettingsScreen extends StatefulWidget {
|
||||||
|
const AppearanceSettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
double _fontSize = 1.0; // 1.0 = 正常,0.8 = 小,1.2 = 大
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final themeModeIndex = prefs.getInt('theme_mode') ?? 0;
|
||||||
|
setState(() {
|
||||||
|
_themeMode = ThemeMode.values[themeModeIndex];
|
||||||
|
_fontSize = prefs.getDouble('font_size') ?? 1.0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('theme_mode', _themeMode.index);
|
||||||
|
await prefs.setDouble('font_size', _fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('外观设置'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 主题模式
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.palette, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'主题模式',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
RadioListTile<ThemeMode>(
|
||||||
|
title: const Text('跟随系统'),
|
||||||
|
subtitle: const Text('根据系统设置自动切换'),
|
||||||
|
value: ThemeMode.system,
|
||||||
|
groupValue: _themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_themeMode = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
// 通知应用更新主题
|
||||||
|
// 注意:这需要重启应用或使用 Provider 来管理主题
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<ThemeMode>(
|
||||||
|
title: const Text('浅色模式'),
|
||||||
|
subtitle: const Text('始终使用浅色主题'),
|
||||||
|
value: ThemeMode.light,
|
||||||
|
groupValue: _themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_themeMode = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<ThemeMode>(
|
||||||
|
title: const Text('深色模式'),
|
||||||
|
subtitle: const Text('始终使用深色主题'),
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
groupValue: _themeMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_themeMode = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 字体大小
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.text_fields, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'字体大小',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'当前大小: ${_getFontSizeLabel(_fontSize)}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Slider(
|
||||||
|
value: _fontSize,
|
||||||
|
min: 0.8,
|
||||||
|
max: 1.2,
|
||||||
|
divisions: 4,
|
||||||
|
label: _getFontSizeLabel(_fontSize),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_fontSize = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'小',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 12 * _fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'正常',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontSize: 14 * _fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'大',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontSize: 16 * _fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 图表样式(占位,未来功能)
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bar_chart, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'图表样式',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('图表颜色主题'),
|
||||||
|
subtitle: const Text('默认主题'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('此功能正在开发中'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 说明
|
||||||
|
Card(
|
||||||
|
color: AppTheme.infoColor.withOpacity(0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: AppTheme.infoColor),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'主题模式更改需要重启应用才能生效。字体大小更改会立即生效。',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getFontSizeLabel(double size) {
|
||||||
|
if (size <= 0.9) {
|
||||||
|
return '小';
|
||||||
|
} else if (size <= 1.1) {
|
||||||
|
return '正常';
|
||||||
|
} else {
|
||||||
|
return '大';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
290
lib/screens/category_management_screen.dart
Normal file
290
lib/screens/category_management_screen.dart
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../services/category_service.dart';
|
||||||
|
import '../database/app_usage_dao.dart';
|
||||||
|
import '../widgets/empty_state_widget.dart';
|
||||||
|
import '../widgets/error_state_widget.dart';
|
||||||
|
|
||||||
|
class CategoryManagementScreen extends ConsumerStatefulWidget {
|
||||||
|
const CategoryManagementScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CategoryManagementScreen> createState() => _CategoryManagementScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScreen> {
|
||||||
|
final CategoryService _categoryService = CategoryService();
|
||||||
|
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
// 可用分类列表
|
||||||
|
final List<String> _availableCategories = ['work', 'study', 'entertainment', 'social', 'tool', 'other'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AppCategoryItem>> _loadAppCategories() async {
|
||||||
|
// 获取所有应用使用记录(最近使用的)
|
||||||
|
// 如果数据库为空,返回空列表(Web 平台会返回空列表)
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfWeek = now.subtract(const Duration(days: 7));
|
||||||
|
final appUsages = await _appUsageDao.getAppUsages(
|
||||||
|
startTime: startOfWeek,
|
||||||
|
endTime: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果没有数据,返回空列表
|
||||||
|
if (appUsages.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取自定义分类
|
||||||
|
final customCategories = await _categoryService.getAllCustomCategories();
|
||||||
|
|
||||||
|
// 去重并创建列表
|
||||||
|
final packageMap = <String, AppCategoryItem>{};
|
||||||
|
for (final usage in appUsages) {
|
||||||
|
if (!packageMap.containsKey(usage.packageName)) {
|
||||||
|
final currentCategory = customCategories[usage.packageName] ??
|
||||||
|
CategoryService.defaultCategories[usage.packageName] ??
|
||||||
|
'other';
|
||||||
|
|
||||||
|
packageMap[usage.packageName] = AppCategoryItem(
|
||||||
|
packageName: usage.packageName,
|
||||||
|
appName: usage.appName,
|
||||||
|
category: currentCategory,
|
||||||
|
isCustom: customCategories.containsKey(usage.packageName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageMap.values.toList()
|
||||||
|
..sort((a, b) => a.appName.compareTo(b.appName));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('应用分类'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// 搜索框
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '搜索应用...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 应用列表
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<AppCategoryItem>>(
|
||||||
|
future: _loadAppCategories(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return ErrorStateWidget.dataLoad(
|
||||||
|
message: _getErrorMessage(snapshot.error!),
|
||||||
|
onRetry: () {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = snapshot.data ?? [];
|
||||||
|
final filteredItems = _searchQuery.isEmpty
|
||||||
|
? items
|
||||||
|
: items.where((item) {
|
||||||
|
return item.appName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||||
|
item.packageName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filteredItems.isEmpty) {
|
||||||
|
if (_searchQuery.isEmpty) {
|
||||||
|
return EmptyStateWidget.noApps(
|
||||||
|
onAction: () {
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return EmptyStateWidget.noSearchResults(query: _searchQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: filteredItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = filteredItems[index];
|
||||||
|
return _buildAppCategoryItem(context, theme, item);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppCategoryItem(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
AppCategoryItem item,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(item.category).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.phone_android,
|
||||||
|
color: AppTheme.getCategoryColor(item.category),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
item.appName,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
item.packageName,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
icon: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(item.category).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
AppTheme.getCategoryName(item.category),
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.getCategoryColor(item.category),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.arrow_drop_down),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onSelected: (String category) async {
|
||||||
|
await _categoryService.setCategory(item.packageName, category);
|
||||||
|
setState(() {});
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('已将 ${item.appName} 分类为 ${AppTheme.getCategoryName(category)}'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return _availableCategories.map((category) {
|
||||||
|
return PopupMenuItem<String>(
|
||||||
|
value: category,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (item.category == category)
|
||||||
|
const Icon(Icons.check, size: 20, color: AppTheme.primaryColor)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(category),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(AppTheme.getCategoryName(category)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(Object error) {
|
||||||
|
final errorString = error.toString().toLowerCase();
|
||||||
|
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||||
|
return '需要授予应用使用权限';
|
||||||
|
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||||
|
return '网络连接失败,请检查网络';
|
||||||
|
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||||
|
return '数据库操作失败';
|
||||||
|
}
|
||||||
|
return '加载失败,请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppCategoryItem {
|
||||||
|
final String packageName;
|
||||||
|
final String appName;
|
||||||
|
final String category;
|
||||||
|
final bool isCustom;
|
||||||
|
|
||||||
|
AppCategoryItem({
|
||||||
|
required this.packageName,
|
||||||
|
required this.appName,
|
||||||
|
required this.category,
|
||||||
|
required this.isCustom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
414
lib/screens/data_privacy_screen.dart
Normal file
414
lib/screens/data_privacy_screen.dart
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../database/app_usage_dao.dart';
|
||||||
|
import '../database/daily_stats_dao.dart';
|
||||||
|
|
||||||
|
class DataPrivacyScreen extends StatefulWidget {
|
||||||
|
const DataPrivacyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DataPrivacyScreen> createState() => _DataPrivacyScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DataPrivacyScreenState extends State<DataPrivacyScreen> {
|
||||||
|
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||||
|
final DailyStatsDao _dailyStatsDao = DailyStatsDao();
|
||||||
|
bool _isDeleting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('数据与隐私'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 隐私说明
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.security, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'隐私保护',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPrivacyItem(
|
||||||
|
theme,
|
||||||
|
Icons.storage,
|
||||||
|
'本地存储',
|
||||||
|
'所有数据仅存储在您的设备本地,不会上传到任何服务器。',
|
||||||
|
),
|
||||||
|
_buildPrivacyItem(
|
||||||
|
theme,
|
||||||
|
Icons.lock,
|
||||||
|
'数据加密',
|
||||||
|
'敏感数据在存储时进行加密处理,确保数据安全。',
|
||||||
|
),
|
||||||
|
_buildPrivacyItem(
|
||||||
|
theme,
|
||||||
|
Icons.visibility_off,
|
||||||
|
'隐私保护',
|
||||||
|
'我们不会收集您的个人信息,也不会追踪您的具体操作内容。',
|
||||||
|
),
|
||||||
|
_buildPrivacyItem(
|
||||||
|
theme,
|
||||||
|
Icons.delete_forever,
|
||||||
|
'完全控制',
|
||||||
|
'您可以随时删除所有数据,完全掌控您的隐私。',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 数据管理
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'数据管理',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDataAction(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.delete_outline,
|
||||||
|
'删除旧数据',
|
||||||
|
'删除 30 天前的数据',
|
||||||
|
Colors.orange,
|
||||||
|
() => _showDeleteOldDataDialog(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildDataAction(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
Icons.delete_forever,
|
||||||
|
'清空所有数据',
|
||||||
|
'删除所有使用记录和统计数据',
|
||||||
|
Colors.red,
|
||||||
|
() => _showDeleteAllDataDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 数据使用说明
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'数据使用说明',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'• 应用使用数据仅用于统计和分析\n'
|
||||||
|
'• 数据不会离开您的设备\n'
|
||||||
|
'• 不会与第三方分享任何数据\n'
|
||||||
|
'• 不会用于广告或营销目的\n'
|
||||||
|
'• 您可以随时导出或删除数据',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPrivacyItem(ThemeData theme, IconData icon, String title, String description) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppTheme.primaryColor, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDataAction(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
IconData icon,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
Color color,
|
||||||
|
VoidCallback onTap,
|
||||||
|
) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _isDeleting ? null : onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isDeleting)
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteOldDataDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('删除旧数据'),
|
||||||
|
content: const Text('确定要删除 30 天前的所有数据吗?此操作不可恢复。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _deleteOldData(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
child: const Text('删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDeleteAllDataDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('清空所有数据'),
|
||||||
|
content: const Text(
|
||||||
|
'确定要删除所有使用记录和统计数据吗?\n\n'
|
||||||
|
'此操作将:\n'
|
||||||
|
'• 删除所有应用使用记录\n'
|
||||||
|
'• 删除所有统计数据\n'
|
||||||
|
'• 删除所有分类设置\n'
|
||||||
|
'• 删除所有目标设置\n\n'
|
||||||
|
'此操作不可恢复!',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _deleteAllData(context);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('确认删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteOldData(BuildContext context) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Web 平台不支持数据删除功能'),
|
||||||
|
backgroundColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDeleting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30));
|
||||||
|
|
||||||
|
await _appUsageDao.deleteBeforeDate(thirtyDaysAgo);
|
||||||
|
await _dailyStatsDao.deleteBeforeDate(thirtyDaysAgo);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('已删除 30 天前的数据'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('删除失败: $e'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isDeleting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAllData(BuildContext context) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Web 平台不支持数据删除功能'),
|
||||||
|
backgroundColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDeleting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 删除所有应用使用记录
|
||||||
|
final allUsages = await _appUsageDao.getAppUsages(
|
||||||
|
startTime: DateTime(2000),
|
||||||
|
endTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
for (final usage in allUsages) {
|
||||||
|
if (usage.id != null) {
|
||||||
|
await _appUsageDao.deleteAppUsage(usage.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除所有统计数据
|
||||||
|
final allStats = await _dailyStatsDao.getStatsRange(
|
||||||
|
startDate: DateTime(2000),
|
||||||
|
endDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
for (final stat in allStats) {
|
||||||
|
if (stat.id != null) {
|
||||||
|
await _dailyStatsDao.deleteDailyStats(stat.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('已清空所有数据'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('删除失败: $e'),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isDeleting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
403
lib/screens/export_data_screen.dart
Normal file
403
lib/screens/export_data_screen.dart
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../services/export_service.dart';
|
||||||
|
|
||||||
|
// Web 平台需要的导入
|
||||||
|
import 'dart:html' as html show Blob, Url, AnchorElement;
|
||||||
|
|
||||||
|
class ExportDataScreen extends StatefulWidget {
|
||||||
|
const ExportDataScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExportDataScreen> createState() => _ExportDataScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExportDataScreenState extends State<ExportDataScreen> {
|
||||||
|
final ExportService _exportService = ExportService();
|
||||||
|
DateTime _startDate = DateTime.now().subtract(const Duration(days: 7));
|
||||||
|
DateTime _endDate = DateTime.now();
|
||||||
|
bool _isExporting = false;
|
||||||
|
|
||||||
|
Future<void> _exportCSV() async {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final csvData = await _exportService.exportToCSV(
|
||||||
|
startDate: _startDate,
|
||||||
|
endDate: _endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (csvData.isEmpty || csvData.trim().isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('所选日期范围内没有数据可导出'),
|
||||||
|
backgroundColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web 平台:下载文件
|
||||||
|
final blob = html.Blob([csvData], 'text/csv');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', 'autotime_export_${DateTime.now().millisecondsSinceEpoch}.csv')
|
||||||
|
..click();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
} else {
|
||||||
|
// 移动端:复制到剪贴板
|
||||||
|
await Clipboard.setData(ClipboardData(text: csvData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(kIsWeb ? '文件已下载' : '数据已复制到剪贴板'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_getExportErrorMessage(e)),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: '重试',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: _exportReport,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportReport() async {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final report = await _exportService.exportStatsReport(
|
||||||
|
startDate: _startDate,
|
||||||
|
endDate: _endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (report.isEmpty || report.trim().isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('所选日期范围内没有统计数据可导出'),
|
||||||
|
backgroundColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web 平台:下载文件
|
||||||
|
final blob = html.Blob([report], 'text/plain');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', 'autotime_report_${DateTime.now().millisecondsSinceEpoch}.txt')
|
||||||
|
..click();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
} else {
|
||||||
|
// 移动端:复制到剪贴板
|
||||||
|
await Clipboard.setData(ClipboardData(text: report));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(kIsWeb ? '文件已下载' : '报告已复制到剪贴板'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_getExportErrorMessage(e)),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: '重试',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: _exportReport,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exportTodayReport() async {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final report = await _exportService.exportTodayReport();
|
||||||
|
|
||||||
|
if (report.isEmpty || report.trim().isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('今日暂无数据可导出'),
|
||||||
|
backgroundColor: AppTheme.warningColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web 平台:下载文件
|
||||||
|
final blob = html.Blob([report], 'text/plain');
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
html.AnchorElement(href: url)
|
||||||
|
..setAttribute('download', 'autotime_today_${DateTime.now().millisecondsSinceEpoch}.txt')
|
||||||
|
..click();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
} else {
|
||||||
|
// 移动端:复制到剪贴板
|
||||||
|
await Clipboard.setData(ClipboardData(text: report));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(kIsWeb ? '文件已下载' : '今日报告已复制到剪贴板'),
|
||||||
|
backgroundColor: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(_getExportErrorMessage(e)),
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: '重试',
|
||||||
|
textColor: Colors.white,
|
||||||
|
onPressed: _exportTodayReport,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isExporting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDateRange() async {
|
||||||
|
final DateTimeRange? picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
initialDateRange: DateTimeRange(start: _startDate, end: _endDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_startDate = picked.start;
|
||||||
|
_endDate = picked.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('数据导出'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 日期范围选择
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'选择日期范围',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _selectDateRange,
|
||||||
|
icon: const Icon(Icons.calendar_today),
|
||||||
|
label: Text(
|
||||||
|
'${_startDate.toString().split(' ')[0]} 至 ${_endDate.toString().split(' ')[0]}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 导出选项
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'导出选项',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// CSV 导出
|
||||||
|
_buildExportOption(
|
||||||
|
theme,
|
||||||
|
icon: Icons.table_chart,
|
||||||
|
title: '导出 CSV 数据',
|
||||||
|
subtitle: '导出原始应用使用数据(CSV 格式)',
|
||||||
|
onTap: _exportCSV,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 统计报告
|
||||||
|
_buildExportOption(
|
||||||
|
theme,
|
||||||
|
icon: Icons.description,
|
||||||
|
title: '导出统计报告',
|
||||||
|
subtitle: '导出时间范围内的统计报告(文本格式)',
|
||||||
|
onTap: _exportReport,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 今日报告
|
||||||
|
_buildExportOption(
|
||||||
|
theme,
|
||||||
|
icon: Icons.today,
|
||||||
|
title: '导出今日报告',
|
||||||
|
subtitle: '导出今日的详细统计报告',
|
||||||
|
onTap: _exportTodayReport,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_isExporting)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExportOption(
|
||||||
|
ThemeData theme, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _isExporting ? null : onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getExportErrorMessage(Object error) {
|
||||||
|
final errorString = error.toString().toLowerCase();
|
||||||
|
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||||
|
return '需要授予应用使用权限';
|
||||||
|
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||||
|
return '数据库操作失败,请稍后重试';
|
||||||
|
} else if (errorString.contains('file') || errorString.contains('文件')) {
|
||||||
|
return '文件操作失败,请检查存储权限';
|
||||||
|
}
|
||||||
|
return '导出失败,请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
494
lib/screens/goal_setting_screen.dart
Normal file
494
lib/screens/goal_setting_screen.dart
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../models/time_goal.dart';
|
||||||
|
import '../database/time_goal_dao.dart';
|
||||||
|
|
||||||
|
class GoalSettingScreen extends ConsumerStatefulWidget {
|
||||||
|
const GoalSettingScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<GoalSettingScreen> createState() => _GoalSettingScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GoalSettingScreenState extends ConsumerState<GoalSettingScreen> {
|
||||||
|
final TimeGoalDao _goalDao = TimeGoalDao();
|
||||||
|
|
||||||
|
TimeGoal? _dailyTotalGoal;
|
||||||
|
final Map<String, TimeGoal?> _categoryGoals = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadGoals();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadGoals() async {
|
||||||
|
// 获取所有目标(包括非激活的),以便正确显示开关状态
|
||||||
|
final allGoals = await _goalDao.getAllGoals();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
// 查找每日总时长目标
|
||||||
|
final dailyTotal = allGoals.firstWhere(
|
||||||
|
(g) => g.goalType == 'daily_total',
|
||||||
|
orElse: () => TimeGoal(
|
||||||
|
goalType: 'daily_total',
|
||||||
|
targetTime: 28800, // 默认 8 小时
|
||||||
|
isActive: _dailyTotalGoal?.isActive ?? true, // 保持当前状态
|
||||||
|
createdAt: _dailyTotalGoal?.createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果找到的目标有 ID,使用它;否则保持当前 ID(如果有)
|
||||||
|
_dailyTotalGoal = TimeGoal(
|
||||||
|
id: dailyTotal.id ?? _dailyTotalGoal?.id,
|
||||||
|
goalType: dailyTotal.goalType,
|
||||||
|
targetTime: dailyTotal.targetTime,
|
||||||
|
isActive: dailyTotal.isActive,
|
||||||
|
createdAt: dailyTotal.createdAt,
|
||||||
|
updatedAt: dailyTotal.updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
final categories = ['work', 'study', 'entertainment', 'social', 'tool'];
|
||||||
|
for (final category in categories) {
|
||||||
|
_categoryGoals[category] = allGoals.firstWhere(
|
||||||
|
(g) => g.goalType == 'daily_category' && g.category == category,
|
||||||
|
orElse: () => TimeGoal(
|
||||||
|
goalType: 'daily_category',
|
||||||
|
category: category,
|
||||||
|
targetTime: 0,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('时间目标'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 每日总时长目标
|
||||||
|
_buildDailyTotalGoalCard(theme),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 分类时间限制
|
||||||
|
_buildCategoryGoalsCard(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDailyTotalGoalCard(ThemeData theme) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'每日总时长目标',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_dailyTotalGoal != null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_dailyTotalGoal!.formattedTargetTime,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: _dailyTotalGoal!.isActive,
|
||||||
|
onChanged: (value) async {
|
||||||
|
// 先更新本地状态,立即显示变化
|
||||||
|
setState(() {
|
||||||
|
_dailyTotalGoal = TimeGoal(
|
||||||
|
id: _dailyTotalGoal!.id,
|
||||||
|
goalType: _dailyTotalGoal!.goalType,
|
||||||
|
targetTime: _dailyTotalGoal!.targetTime,
|
||||||
|
isActive: value,
|
||||||
|
createdAt: _dailyTotalGoal!.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 然后保存到数据库
|
||||||
|
await _goalDao.upsertTimeGoal(_dailyTotalGoal!);
|
||||||
|
// 重新加载以确保数据同步
|
||||||
|
await _loadGoals();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildTimePicker(
|
||||||
|
theme,
|
||||||
|
'设置目标时长',
|
||||||
|
_dailyTotalGoal!.targetTime,
|
||||||
|
(hours, minutes) async {
|
||||||
|
final targetTime = hours * 3600 + minutes * 60;
|
||||||
|
final updatedGoal = TimeGoal(
|
||||||
|
id: _dailyTotalGoal!.id,
|
||||||
|
goalType: 'daily_total',
|
||||||
|
targetTime: targetTime,
|
||||||
|
isActive: _dailyTotalGoal!.isActive,
|
||||||
|
createdAt: _dailyTotalGoal!.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||||
|
await _loadGoals();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryGoalsCard(ThemeData theme) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.category, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'分类时间限制',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...['work', 'study', 'entertainment', 'social', 'tool'].map((category) {
|
||||||
|
final goal = _categoryGoals[category];
|
||||||
|
return _buildCategoryGoalItem(theme, category, goal);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryGoalItem(
|
||||||
|
ThemeData theme,
|
||||||
|
String category,
|
||||||
|
TimeGoal? goal,
|
||||||
|
) {
|
||||||
|
final isActive = goal?.isActive ?? false;
|
||||||
|
final targetTime = goal?.targetTime ?? 0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(category),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
AppTheme.getCategoryName(category),
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
targetTime > 0 ? _formatTime(targetTime) : '未设置',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: isActive ? AppTheme.primaryColor : theme.colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Switch(
|
||||||
|
value: isActive,
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value && targetTime == 0) {
|
||||||
|
// 如果启用但未设置时间,先设置默认值
|
||||||
|
final defaultGoal = TimeGoal(
|
||||||
|
id: goal?.id,
|
||||||
|
goalType: 'daily_category',
|
||||||
|
category: category,
|
||||||
|
targetTime: 7200, // 默认 2 小时
|
||||||
|
isActive: true,
|
||||||
|
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _goalDao.upsertTimeGoal(defaultGoal);
|
||||||
|
} else {
|
||||||
|
final updatedGoal = TimeGoal(
|
||||||
|
id: goal?.id,
|
||||||
|
goalType: 'daily_category',
|
||||||
|
category: category,
|
||||||
|
targetTime: targetTime,
|
||||||
|
isActive: value,
|
||||||
|
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||||
|
}
|
||||||
|
await _loadGoals();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: () {
|
||||||
|
_showCategoryGoalDialog(theme, category, goal);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCategoryGoalDialog(ThemeData theme, String category, TimeGoal? goal) {
|
||||||
|
final currentTime = goal?.targetTime ?? 0;
|
||||||
|
final hours = currentTime ~/ 3600;
|
||||||
|
final minutes = (currentTime % 3600) ~/ 60;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _TimePickerDialog(
|
||||||
|
title: '设置 ${AppTheme.getCategoryName(category)} 时间限制',
|
||||||
|
initialHours: hours,
|
||||||
|
initialMinutes: minutes,
|
||||||
|
onSave: (hours, minutes) async {
|
||||||
|
final targetTime = hours * 3600 + minutes * 60;
|
||||||
|
final updatedGoal = TimeGoal(
|
||||||
|
id: goal?.id,
|
||||||
|
goalType: 'daily_category',
|
||||||
|
category: category,
|
||||||
|
targetTime: targetTime,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||||
|
await _loadGoals();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimePicker(
|
||||||
|
ThemeData theme,
|
||||||
|
String title,
|
||||||
|
int currentTime,
|
||||||
|
Future<void> Function(int hours, int minutes) onSave,
|
||||||
|
) {
|
||||||
|
final hours = currentTime ~/ 3600;
|
||||||
|
final minutes = (currentTime % 3600) ~/ 60;
|
||||||
|
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _TimePickerDialog(
|
||||||
|
title: title,
|
||||||
|
initialHours: hours,
|
||||||
|
initialMinutes: minutes,
|
||||||
|
onSave: onSave,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
label: Text('${hours}小时 ${minutes}分钟'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int seconds) {
|
||||||
|
final hours = seconds ~/ 3600;
|
||||||
|
final minutes = (seconds % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimePickerDialog extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final int initialHours;
|
||||||
|
final int initialMinutes;
|
||||||
|
final Future<void> Function(int hours, int minutes) onSave;
|
||||||
|
|
||||||
|
const _TimePickerDialog({
|
||||||
|
required this.title,
|
||||||
|
required this.initialHours,
|
||||||
|
required this.initialMinutes,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TimePickerDialog> createState() => _TimePickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimePickerDialogState extends State<_TimePickerDialog> {
|
||||||
|
late int _hours;
|
||||||
|
late int _minutes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hours = widget.initialHours;
|
||||||
|
_minutes = widget.initialMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 小时选择
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Text('小时', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_hours > 0) _hours--;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(
|
||||||
|
'$_hours',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_hours < 24) _hours++;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// 分钟选择
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Text('分钟', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_minutes > 0) {
|
||||||
|
_minutes -= 15;
|
||||||
|
if (_minutes < 0) _minutes = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: Text(
|
||||||
|
'$_minutes',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_minutes += 15;
|
||||||
|
if (_minutes >= 60) {
|
||||||
|
_hours++;
|
||||||
|
_minutes = 0;
|
||||||
|
}
|
||||||
|
if (_hours >= 24) {
|
||||||
|
_hours = 23;
|
||||||
|
_minutes = 59;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await widget.onSave(_hours, _minutes);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
73
lib/screens/home_screen.dart
Normal file
73
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'today_screen.dart';
|
||||||
|
import 'stats_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
import '../providers/background_sync_provider.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _screens = [
|
||||||
|
const TodayScreen(),
|
||||||
|
const StatsScreen(),
|
||||||
|
const SettingsScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Web 平台不启动后台同步服务
|
||||||
|
if (!kIsWeb) {
|
||||||
|
// 启动后台同步服务
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final syncService = ref.read(backgroundSyncServiceProvider);
|
||||||
|
syncService.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _currentIndex,
|
||||||
|
children: _screens,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _currentIndex,
|
||||||
|
onDestinationSelected: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.today_outlined),
|
||||||
|
selectedIcon: Icon(Icons.today),
|
||||||
|
label: 'Today',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.bar_chart_outlined),
|
||||||
|
selectedIcon: Icon(Icons.bar_chart),
|
||||||
|
label: 'Stats',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
252
lib/screens/notification_settings_screen.dart
Normal file
252
lib/screens/notification_settings_screen.dart
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/custom_time_picker_dialog.dart';
|
||||||
|
|
||||||
|
class NotificationSettingsScreen extends StatefulWidget {
|
||||||
|
const NotificationSettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NotificationSettingsScreen> createState() => _NotificationSettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationSettingsScreenState extends State<NotificationSettingsScreen> {
|
||||||
|
bool _goalReminderEnabled = true;
|
||||||
|
bool _dailyReportEnabled = true;
|
||||||
|
bool _weeklyReportEnabled = false;
|
||||||
|
TimeOfDay _goalReminderTime = const TimeOfDay(hour: 20, minute: 0);
|
||||||
|
TimeOfDay _dailyReportTime = const TimeOfDay(hour: 22, minute: 0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
setState(() {
|
||||||
|
_goalReminderEnabled = prefs.getBool('goal_reminder_enabled') ?? true;
|
||||||
|
_dailyReportEnabled = prefs.getBool('daily_report_enabled') ?? true;
|
||||||
|
_weeklyReportEnabled = prefs.getBool('weekly_report_enabled') ?? false;
|
||||||
|
|
||||||
|
final goalReminderHour = prefs.getInt('goal_reminder_hour') ?? 20;
|
||||||
|
final goalReminderMinute = prefs.getInt('goal_reminder_minute') ?? 0;
|
||||||
|
_goalReminderTime = TimeOfDay(hour: goalReminderHour, minute: goalReminderMinute);
|
||||||
|
|
||||||
|
final dailyReportHour = prefs.getInt('daily_report_hour') ?? 22;
|
||||||
|
final dailyReportMinute = prefs.getInt('daily_report_minute') ?? 0;
|
||||||
|
_dailyReportTime = TimeOfDay(hour: dailyReportHour, minute: dailyReportMinute);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('goal_reminder_enabled', _goalReminderEnabled);
|
||||||
|
await prefs.setBool('daily_report_enabled', _dailyReportEnabled);
|
||||||
|
await prefs.setBool('weekly_report_enabled', _weeklyReportEnabled);
|
||||||
|
await prefs.setInt('goal_reminder_hour', _goalReminderTime.hour);
|
||||||
|
await prefs.setInt('goal_reminder_minute', _goalReminderTime.minute);
|
||||||
|
await prefs.setInt('daily_report_hour', _dailyReportTime.hour);
|
||||||
|
await prefs.setInt('daily_report_minute', _dailyReportTime.minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('通知设置'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 目标提醒
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flag, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'目标提醒',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('启用目标提醒'),
|
||||||
|
subtitle: const Text('当接近或超过时间目标时提醒'),
|
||||||
|
value: _goalReminderEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_goalReminderEnabled = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_goalReminderEnabled) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('提醒时间'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_goalReminderTime.hour.toString().padLeft(2, '0')}:${_goalReminderTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
final TimeOfDay? picked = await CustomTimePickerDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: '选择提醒时间',
|
||||||
|
initialTime: _goalReminderTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_goalReminderTime = picked;
|
||||||
|
});
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 每日报告
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.today, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'每日报告',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('启用每日报告'),
|
||||||
|
subtitle: const Text('每天发送使用时间摘要'),
|
||||||
|
value: _dailyReportEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_dailyReportEnabled = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_dailyReportEnabled) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('报告时间'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_dailyReportTime.hour.toString().padLeft(2, '0')}:${_dailyReportTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
final TimeOfDay? picked = await CustomTimePickerDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: '选择报告时间',
|
||||||
|
initialTime: _dailyReportTime,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_dailyReportTime = picked;
|
||||||
|
});
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 每周报告
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.calendar_view_week, color: AppTheme.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'每周报告',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('启用每周报告'),
|
||||||
|
subtitle: const Text('每周一发送周报摘要'),
|
||||||
|
value: _weeklyReportEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_weeklyReportEnabled = value;
|
||||||
|
});
|
||||||
|
_saveSettings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 说明
|
||||||
|
Card(
|
||||||
|
color: AppTheme.infoColor.withOpacity(0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: AppTheme.infoColor),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'通知功能需要系统通知权限。请在系统设置中授予通知权限。',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
229
lib/screens/permission_screen.dart
Normal file
229
lib/screens/permission_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform;
|
||||||
|
import 'package:flutter/foundation.dart' show TargetPlatform;
|
||||||
|
import '../providers/time_tracking_provider.dart';
|
||||||
|
import '../widgets/error_state_widget.dart';
|
||||||
|
|
||||||
|
class PermissionScreen extends ConsumerWidget {
|
||||||
|
const PermissionScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final permissionStatus = ref.watch(permissionStatusProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('权限设置'),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 图标
|
||||||
|
Icon(
|
||||||
|
Icons.security,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Text(
|
||||||
|
'我们需要访问您的应用使用数据',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 说明
|
||||||
|
Text(
|
||||||
|
_getPermissionDescription(),
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// 权限状态
|
||||||
|
permissionStatus.when(
|
||||||
|
data: (hasPermission) {
|
||||||
|
if (hasPermission) {
|
||||||
|
return _buildPermissionGranted(context, theme);
|
||||||
|
} else {
|
||||||
|
return _buildPermissionRequest(context, ref, theme);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => ErrorStateWidget.generic(
|
||||||
|
message: '检查权限时出错,请重试',
|
||||||
|
onRetry: () {
|
||||||
|
ref.invalidate(permissionStatusProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// 隐私说明
|
||||||
|
_buildPrivacyInfo(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPermissionDescription() {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'Web 平台暂不支持时间追踪功能。\n\n'
|
||||||
|
'请使用 iOS 或 Android 应用。';
|
||||||
|
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||||
|
return '这样我们才能自动追踪您的应用使用情况,无需手动操作。\n\n'
|
||||||
|
'• 完全自动化,无需手动操作\n'
|
||||||
|
'• 数据仅存储在本地\n'
|
||||||
|
'• 不会上传到服务器';
|
||||||
|
} else {
|
||||||
|
return '这样我们才能自动追踪您的应用使用情况,无需手动操作。\n\n'
|
||||||
|
'• 完全自动化,无需手动操作\n'
|
||||||
|
'• 数据仅存储在本地\n'
|
||||||
|
'• 不会上传到服务器';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPermissionRequest(BuildContext context, WidgetRef ref, ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'权限未授予',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final service = ref.read(timeTrackingServiceProvider);
|
||||||
|
final granted = await service.requestPermission();
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
ref.invalidate(permissionStatusProvider);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('权限已授予')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('权限授予失败,请前往设置中手动开启')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'去设置',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('稍后再说'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPermissionGranted(BuildContext context, ThemeData theme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'权限已授予',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Widget _buildPrivacyInfo(ThemeData theme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'隐私说明',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'• 所有数据仅存储在您的设备本地\n'
|
||||||
|
'• 我们不会收集或上传任何数据到服务器\n'
|
||||||
|
'• 您可以随时删除所有数据\n'
|
||||||
|
'• 应用使用数据仅用于统计和分析',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
270
lib/screens/settings_screen.dart
Normal file
270
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import 'category_management_screen.dart';
|
||||||
|
import 'goal_setting_screen.dart';
|
||||||
|
import 'export_data_screen.dart';
|
||||||
|
import 'data_privacy_screen.dart';
|
||||||
|
import 'about_screen.dart';
|
||||||
|
import 'notification_settings_screen.dart';
|
||||||
|
import 'appearance_settings_screen.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatelessWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '应用分类',
|
||||||
|
icon: Icons.category,
|
||||||
|
subtitle: '管理应用分类规则',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const CategoryManagementScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '时间目标',
|
||||||
|
icon: Icons.flag,
|
||||||
|
subtitle: '设置每日时间目标',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const GoalSettingScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '数据导出',
|
||||||
|
icon: Icons.file_download,
|
||||||
|
subtitle: '导出 CSV 数据、统计报告',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ExportDataScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '数据与隐私',
|
||||||
|
icon: Icons.security,
|
||||||
|
subtitle: '数据管理、删除',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DataPrivacyScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '通知设置',
|
||||||
|
icon: Icons.notifications,
|
||||||
|
subtitle: '目标提醒、每日报告',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const NotificationSettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '外观设置',
|
||||||
|
icon: Icons.palette,
|
||||||
|
subtitle: '主题、字体大小',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const AppearanceSettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildUpgradeCard(theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildSettingsSection(
|
||||||
|
title: '关于',
|
||||||
|
icon: Icons.info,
|
||||||
|
subtitle: '版本信息、帮助、反馈',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const AboutScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
theme: theme,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsSection({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required ThemeData theme,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUpgradeCard(ThemeData theme) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
AppTheme.primaryColor,
|
||||||
|
AppTheme.secondaryColor,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.diamond,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Upgrade to Pro',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'解锁高级功能',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...[
|
||||||
|
'无限历史数据',
|
||||||
|
'高级统计分析',
|
||||||
|
'效率评分与分析',
|
||||||
|
'个性化建议',
|
||||||
|
'数据导出',
|
||||||
|
].map((feature) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
feature,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// 打开订阅页面
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppTheme.primaryColor,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'立即升级',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
564
lib/screens/stats_screen.dart
Normal file
564
lib/screens/stats_screen.dart
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../providers/statistics_provider.dart';
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import '../widgets/empty_state_widget.dart';
|
||||||
|
import '../widgets/error_state_widget.dart';
|
||||||
|
|
||||||
|
class StatsScreen extends ConsumerStatefulWidget {
|
||||||
|
const StatsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<StatsScreen> createState() => _StatsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||||
|
String _selectedPeriod = '周'; // 日、周、月
|
||||||
|
|
||||||
|
// 根据时间段获取图表数据
|
||||||
|
List<Map<String, dynamic>> _getChartData(List<DailyStats>? stats, String period) {
|
||||||
|
if (stats != null && stats.isNotEmpty) {
|
||||||
|
return stats.map((stat) {
|
||||||
|
return {
|
||||||
|
'date': stat.date,
|
||||||
|
'total': stat.totalTime,
|
||||||
|
'work': stat.workTime,
|
||||||
|
'study': stat.studyTime,
|
||||||
|
'entertainment': stat.entertainmentTime,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有数据,使用默认测试数据
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
if (period == '日') {
|
||||||
|
// 日视图:显示今日数据(24小时,每小时一个点)
|
||||||
|
return List.generate(24, (index) {
|
||||||
|
final hour = now.subtract(Duration(hours: 23 - index));
|
||||||
|
return {
|
||||||
|
'date': hour,
|
||||||
|
'total': 1800 + (index % 3) * 300, // 模拟每小时30-60分钟
|
||||||
|
'work': 1200 + (index % 3) * 200,
|
||||||
|
'study': 300 + (index % 3) * 50,
|
||||||
|
'entertainment': 300 + (index % 3) * 50,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (period == '周') {
|
||||||
|
// 周视图:显示7天数据
|
||||||
|
return [
|
||||||
|
{'date': now.subtract(const Duration(days: 6)), 'total': 21600, 'work': 14400, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now.subtract(const Duration(days: 5)), 'total': 25200, 'work': 18000, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now.subtract(const Duration(days: 4)), 'total': 23400, 'work': 16200, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now.subtract(const Duration(days: 3)), 'total': 19800, 'work': 12600, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now.subtract(const Duration(days: 2)), 'total': 27000, 'work': 19800, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now.subtract(const Duration(days: 1)), 'total': 22500, 'work': 15300, 'study': 3600, 'entertainment': 3600},
|
||||||
|
{'date': now, 'total': 23040, 'work': 14400, 'study': 3600, 'entertainment': 3600},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 月视图:显示30天数据(简化版,每天一个点)
|
||||||
|
return List.generate(30, (index) {
|
||||||
|
final date = now.subtract(Duration(days: 29 - index));
|
||||||
|
return {
|
||||||
|
'date': date,
|
||||||
|
'total': 18000 + (index % 7) * 2000, // 模拟每天5-7小时
|
||||||
|
'work': 12000 + (index % 7) * 1500,
|
||||||
|
'study': 3000 + (index % 7) * 300,
|
||||||
|
'entertainment': 3000 + (index % 7) * 200,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
// 根据选中的时间段选择不同的 Provider
|
||||||
|
final statsAsync = _selectedPeriod == '日'
|
||||||
|
? ref.watch(todayStatsListProvider)
|
||||||
|
: _selectedPeriod == '周'
|
||||||
|
? ref.watch(weekStatsProvider)
|
||||||
|
: ref.watch(monthStatsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Statistics'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_download),
|
||||||
|
onPressed: () {
|
||||||
|
// 导出数据
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: statsAsync.when(
|
||||||
|
data: (stats) {
|
||||||
|
final chartData = _getChartData(stats, _selectedPeriod);
|
||||||
|
// 检查是否为空数据
|
||||||
|
final isEmpty = chartData.isEmpty || chartData.every((data) => (data['total'] as int) == 0);
|
||||||
|
if (isEmpty) {
|
||||||
|
return EmptyStateWidget.noData(
|
||||||
|
title: '暂无统计数据',
|
||||||
|
subtitle: '使用应用一段时间后,统计数据将显示在这里',
|
||||||
|
actionLabel: '刷新',
|
||||||
|
onAction: () {
|
||||||
|
if (_selectedPeriod == '日') {
|
||||||
|
ref.invalidate(todayStatsListProvider);
|
||||||
|
} else if (_selectedPeriod == '周') {
|
||||||
|
ref.invalidate(weekStatsProvider);
|
||||||
|
} else {
|
||||||
|
ref.invalidate(monthStatsProvider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 时间选择器
|
||||||
|
_buildPeriodSelector(theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 时间趋势图
|
||||||
|
_buildTrendChart(theme, chartData, _selectedPeriod),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 分类对比图
|
||||||
|
_buildCategoryChart(theme, chartData, _selectedPeriod),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 应用使用详情
|
||||||
|
_buildAppDetails(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => ErrorStateWidget.dataLoad(
|
||||||
|
message: _getErrorMessage(error),
|
||||||
|
onRetry: () {
|
||||||
|
if (_selectedPeriod == '日') {
|
||||||
|
ref.invalidate(todayStatsListProvider);
|
||||||
|
} else if (_selectedPeriod == '周') {
|
||||||
|
ref.invalidate(weekStatsProvider);
|
||||||
|
} else {
|
||||||
|
ref.invalidate(monthStatsProvider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPeriodSelector(ThemeData theme) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SegmentedButton<String>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: '日', label: Text('日')),
|
||||||
|
ButtonSegment(value: '周', label: Text('周')),
|
||||||
|
ButtonSegment(value: '月', label: Text('月')),
|
||||||
|
],
|
||||||
|
selected: {_selectedPeriod},
|
||||||
|
onSelectionChanged: (Set<String> newSelection) {
|
||||||
|
final newPeriod = newSelection.first;
|
||||||
|
setState(() {
|
||||||
|
_selectedPeriod = newPeriod;
|
||||||
|
});
|
||||||
|
// 切换时间段时,刷新对应的 Provider
|
||||||
|
if (newPeriod == '日') {
|
||||||
|
ref.invalidate(todayStatsListProvider);
|
||||||
|
} else if (newPeriod == '周') {
|
||||||
|
ref.invalidate(weekStatsProvider);
|
||||||
|
} else {
|
||||||
|
ref.invalidate(monthStatsProvider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_download),
|
||||||
|
onPressed: () {
|
||||||
|
// 导出功能
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrendChart(ThemeData theme, List<Map<String, dynamic>> chartData, String period) {
|
||||||
|
return Container(
|
||||||
|
height: 250,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
period == '日' ? '每小时总时长趋势' : period == '周' ? '每日总时长趋势' : '每日总时长趋势(月)',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: 2,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 40,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Text(
|
||||||
|
'${(value / 3600).toStringAsFixed(1)}h',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value.toInt() >= 0 && value.toInt() < chartData.length) {
|
||||||
|
final date = chartData[value.toInt()]['date'] as DateTime;
|
||||||
|
String label;
|
||||||
|
if (period == '日') {
|
||||||
|
label = DateFormat('HH:mm', 'zh_CN').format(date);
|
||||||
|
} else if (period == '周') {
|
||||||
|
label = DateFormat('E', 'zh_CN').format(date);
|
||||||
|
} else {
|
||||||
|
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: chartData.asMap().entries.map((entry) {
|
||||||
|
return FlSpot(
|
||||||
|
entry.key.toDouble(),
|
||||||
|
(entry.value['total'] as int) / 3600.0,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
isCurved: true,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
barWidth: 3,
|
||||||
|
dotData: const FlDotData(show: true),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryChart(ThemeData theme, List<Map<String, dynamic>> chartData, String period) {
|
||||||
|
return Container(
|
||||||
|
height: 300,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
period == '日' ? '每小时分类时间分布' : period == '周' ? '每日分类时间分布' : '每日分类时间分布(月)',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: BarChart(
|
||||||
|
BarChartData(
|
||||||
|
alignment: BarChartAlignment.spaceAround,
|
||||||
|
maxY: 8,
|
||||||
|
barTouchData: BarTouchData(enabled: false),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 40,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Text(
|
||||||
|
'${value.toInt()}h',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 30,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value.toInt() >= 0 && value.toInt() < chartData.length) {
|
||||||
|
final date = chartData[value.toInt()]['date'] as DateTime;
|
||||||
|
String label;
|
||||||
|
if (period == '日') {
|
||||||
|
label = DateFormat('HH:mm', 'zh_CN').format(date);
|
||||||
|
} else if (period == '周') {
|
||||||
|
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||||
|
} else {
|
||||||
|
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barGroups: chartData.asMap().entries.map((entry) {
|
||||||
|
final data = entry.value;
|
||||||
|
return BarChartGroupData(
|
||||||
|
x: entry.key,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: (data['work'] as int) / 3600.0,
|
||||||
|
color: AppTheme.workColor,
|
||||||
|
width: 12,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
|
),
|
||||||
|
BarChartRodData(
|
||||||
|
toY: (data['study'] as int) / 3600.0,
|
||||||
|
color: AppTheme.studyColor,
|
||||||
|
width: 12,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
|
),
|
||||||
|
BarChartRodData(
|
||||||
|
toY: (data['entertainment'] as int) / 3600.0,
|
||||||
|
color: AppTheme.entertainmentColor,
|
||||||
|
width: 12,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppDetails(ThemeData theme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'应用使用详情',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildAppDetailItem('Chrome', '今日: 2h 15m', '本周: 12h 30m', '工作', theme),
|
||||||
|
_buildAppDetailItem('VS Code', '今日: 1h 30m', '本周: 8h 45m', '工作', theme),
|
||||||
|
_buildAppDetailItem('Slack', '今日: 1h', '本周: 6h', '工作', theme),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppDetailItem(
|
||||||
|
String appName,
|
||||||
|
String todayTime,
|
||||||
|
String weekTime,
|
||||||
|
String category,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(category).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.phone_android,
|
||||||
|
color: AppTheme.getCategoryColor(category),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
appName,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'分类: $category',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'今日',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
todayTime,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 1,
|
||||||
|
height: 40,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本周',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
weekTime,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(Object error) {
|
||||||
|
final errorString = error.toString().toLowerCase();
|
||||||
|
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||||
|
return '需要授予应用使用权限';
|
||||||
|
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||||
|
return '网络连接失败,请检查网络';
|
||||||
|
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||||
|
return '数据库操作失败';
|
||||||
|
}
|
||||||
|
return '加载失败,请稍后重试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
407
lib/screens/today_screen.dart
Normal file
407
lib/screens/today_screen.dart
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
import '../providers/statistics_provider.dart';
|
||||||
|
import '../widgets/empty_state_widget.dart';
|
||||||
|
import '../widgets/error_state_widget.dart';
|
||||||
|
|
||||||
|
class TodayScreen extends ConsumerWidget {
|
||||||
|
const TodayScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final todayStatsAsync = ref.watch(todayStatsProvider);
|
||||||
|
final topAppsAsync = ref.watch(todayTopAppsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('AutoTime Tracker'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () {
|
||||||
|
ref.invalidate(todayStatsProvider);
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: () {
|
||||||
|
// 设置页面通过底部导航栏访问
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: todayStatsAsync.when(
|
||||||
|
data: (stats) {
|
||||||
|
// 检查是否为空数据(总时长为0且没有应用数据)
|
||||||
|
final isEmpty = stats.totalTime == 0;
|
||||||
|
if (isEmpty) {
|
||||||
|
return _buildEmptyContent(context, ref);
|
||||||
|
}
|
||||||
|
return _buildContent(context, ref, stats, topAppsAsync);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => ErrorStateWidget.dataLoad(
|
||||||
|
message: _getErrorMessage(error),
|
||||||
|
onRetry: () {
|
||||||
|
ref.invalidate(todayStatsProvider);
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
DailyStats stats,
|
||||||
|
AsyncValue<List<AppUsage>> topAppsAsync,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
ref.invalidate(todayStatsProvider);
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 总时长显示
|
||||||
|
_buildTotalTimeSection(stats, theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 效率评分
|
||||||
|
_buildEfficiencySection(stats, theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 分类时间分布(饼图)
|
||||||
|
_buildCategoryChart(stats, theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 分类标签
|
||||||
|
_buildCategoryTags(theme),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Top 应用列表
|
||||||
|
_buildTopAppsSection(context, ref, theme, topAppsAsync),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Widget _buildTotalTimeSection(DailyStats stats, ThemeData theme) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
stats.formattedTotalTime,
|
||||||
|
style: theme.textTheme.displayLarge?.copyWith(
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'今日总时长',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEfficiencySection(DailyStats stats, ThemeData theme) {
|
||||||
|
final score = stats.efficiencyScore ?? 0;
|
||||||
|
final color = stats.efficiencyColor;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'效率评分',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$score%',
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
...List.generate(5, (index) {
|
||||||
|
return Icon(
|
||||||
|
index < (score / 20).floor()
|
||||||
|
? Icons.star
|
||||||
|
: Icons.star_border,
|
||||||
|
size: 20,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getEfficiencyText(score),
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getEfficiencyText(int score) {
|
||||||
|
if (score >= 80) return '优秀';
|
||||||
|
if (score >= 60) return '良好';
|
||||||
|
if (score >= 40) return '一般';
|
||||||
|
return '需改进';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryChart(DailyStats stats, ThemeData theme) {
|
||||||
|
final categoryData = [
|
||||||
|
{'category': 'work', 'time': stats.workTime, 'color': AppTheme.workColor},
|
||||||
|
{'category': 'study', 'time': stats.studyTime, 'color': AppTheme.studyColor},
|
||||||
|
{'category': 'entertainment', 'time': stats.entertainmentTime, 'color': AppTheme.entertainmentColor},
|
||||||
|
{'category': 'social', 'time': stats.socialTime, 'color': AppTheme.socialColor},
|
||||||
|
{'category': 'tool', 'time': stats.toolTime, 'color': AppTheme.toolColor},
|
||||||
|
].where((item) => item['time'] as int > 0).toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 300,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'分类时间分布',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: PieChart(
|
||||||
|
PieChartData(
|
||||||
|
sections: categoryData.map((item) {
|
||||||
|
final time = item['time'] as int;
|
||||||
|
final total = stats.totalTime;
|
||||||
|
final percentage = (time / total * 100);
|
||||||
|
return PieChartSectionData(
|
||||||
|
value: time.toDouble(),
|
||||||
|
title: '${percentage.toStringAsFixed(1)}%',
|
||||||
|
color: item['color'] as Color,
|
||||||
|
radius: 80,
|
||||||
|
titleStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
sectionsSpace: 2,
|
||||||
|
centerSpaceRadius: 60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCategoryTags(ThemeData theme) {
|
||||||
|
final categories = ['work', 'study', 'entertainment', 'social', 'tool'];
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: categories.map((category) {
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(AppTheme.getCategoryName(category)),
|
||||||
|
selected: false,
|
||||||
|
onSelected: (selected) {
|
||||||
|
// 筛选该分类
|
||||||
|
},
|
||||||
|
backgroundColor: AppTheme.getCategoryColor(category).withOpacity(0.1),
|
||||||
|
selectedColor: AppTheme.getCategoryColor(category),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: AppTheme.getCategoryColor(category),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopAppsSection(BuildContext context, WidgetRef ref, ThemeData theme, AsyncValue<List<AppUsage>> topAppsAsync) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Top Apps Today',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
topAppsAsync.when(
|
||||||
|
data: (apps) {
|
||||||
|
if (apps.isEmpty) {
|
||||||
|
return EmptyStateWidget.noApps(
|
||||||
|
onAction: () {
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: apps.map((app) => _buildAppItem(app, theme)).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (error, stack) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ErrorStateWidget.dataLoad(
|
||||||
|
message: _getErrorMessage(error),
|
||||||
|
onRetry: () {
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyContent(BuildContext context, WidgetRef ref) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
ref.invalidate(todayStatsProvider);
|
||||||
|
ref.invalidate(todayTopAppsProvider);
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: EmptyStateWidget.firstTime(
|
||||||
|
onAction: () {
|
||||||
|
// 可以导航到权限设置页面
|
||||||
|
Navigator.of(context).pushNamed('/permission');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(Object error) {
|
||||||
|
final errorString = error.toString().toLowerCase();
|
||||||
|
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||||
|
return '需要授予应用使用权限';
|
||||||
|
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||||
|
return '网络连接失败,请检查网络';
|
||||||
|
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||||
|
return '数据库操作失败';
|
||||||
|
}
|
||||||
|
return '加载失败,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppItem(AppUsage app, ThemeData theme) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.getCategoryColor(app.category).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.phone_android,
|
||||||
|
color: AppTheme.getCategoryColor(app.category),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
app.appName,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
AppTheme.getCategoryName(app.category),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
app.formattedDuration,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
60
lib/services/background_sync_service.dart
Normal file
60
lib/services/background_sync_service.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'time_tracking_service.dart';
|
||||||
|
import 'statistics_service.dart';
|
||||||
|
|
||||||
|
/// 后台同步服务 - 定期同步应用使用数据
|
||||||
|
class BackgroundSyncService {
|
||||||
|
final TimeTrackingService _timeTrackingService = TimeTrackingService();
|
||||||
|
final StatisticsService _statisticsService = StatisticsService();
|
||||||
|
|
||||||
|
Timer? _syncTimer;
|
||||||
|
bool _isRunning = false;
|
||||||
|
|
||||||
|
/// 启动后台同步
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_isRunning) return;
|
||||||
|
|
||||||
|
_isRunning = true;
|
||||||
|
|
||||||
|
// 立即同步一次
|
||||||
|
await syncNow();
|
||||||
|
|
||||||
|
// 每 15 分钟同步一次
|
||||||
|
_syncTimer = Timer.periodic(const Duration(minutes: 15), (timer) async {
|
||||||
|
await syncNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动原生后台追踪
|
||||||
|
await _timeTrackingService.startBackgroundTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止后台同步
|
||||||
|
Future<void> stop() async {
|
||||||
|
_isRunning = false;
|
||||||
|
_syncTimer?.cancel();
|
||||||
|
_syncTimer = null;
|
||||||
|
|
||||||
|
await _timeTrackingService.stopBackgroundTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 立即同步
|
||||||
|
Future<void> syncNow() async {
|
||||||
|
try {
|
||||||
|
print('Background sync: Starting sync...');
|
||||||
|
|
||||||
|
// 同步今日数据
|
||||||
|
await _timeTrackingService.syncTodayData();
|
||||||
|
|
||||||
|
// 刷新统计
|
||||||
|
await _statisticsService.refreshTodayStats();
|
||||||
|
|
||||||
|
print('Background sync: Completed');
|
||||||
|
} catch (e) {
|
||||||
|
print('Background sync error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否正在运行
|
||||||
|
bool get isRunning => _isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
183
lib/services/category_service.dart
Normal file
183
lib/services/category_service.dart
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import '../database/database_helper.dart';
|
||||||
|
|
||||||
|
class CategoryService {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||||
|
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
final Map<String, String> _webCustomCategories = {};
|
||||||
|
|
||||||
|
// 预设分类规则
|
||||||
|
static const Map<String, String> defaultCategories = {
|
||||||
|
// 工作类
|
||||||
|
'com.microsoft.Office.Word': 'work',
|
||||||
|
'com.microsoft.Office.Excel': 'work',
|
||||||
|
'com.microsoft.Office.PowerPoint': 'work',
|
||||||
|
'com.slack': 'work',
|
||||||
|
'com.notion.Notion': 'work',
|
||||||
|
'com.figma.Figma': 'work',
|
||||||
|
'com.github': 'work',
|
||||||
|
'com.microsoft.VSCode': 'work',
|
||||||
|
'com.jetbrains': 'work',
|
||||||
|
'com.google.android.apps.docs': 'work',
|
||||||
|
'com.google.android.apps.sheets': 'work',
|
||||||
|
|
||||||
|
// 学习类
|
||||||
|
'com.coursera': 'study',
|
||||||
|
'com.khanacademy': 'study',
|
||||||
|
'com.amazon.kindle': 'study',
|
||||||
|
'com.gingerlabs.Notability': 'study',
|
||||||
|
'com.goodnotes': 'study',
|
||||||
|
'com.anki': 'study',
|
||||||
|
'com.duolingo': 'study',
|
||||||
|
|
||||||
|
// 娱乐类
|
||||||
|
'com.google.YouTube': 'entertainment',
|
||||||
|
'com.netflix.Netflix': 'entertainment',
|
||||||
|
'com.spotify.music': 'entertainment',
|
||||||
|
'com.spotify.client': 'entertainment',
|
||||||
|
'com.disney': 'entertainment',
|
||||||
|
|
||||||
|
// 社交类
|
||||||
|
'net.whatsapp.WhatsApp': 'social',
|
||||||
|
'com.instagram': 'social',
|
||||||
|
'com.twitter': 'social',
|
||||||
|
'com.facebook.Facebook': 'social',
|
||||||
|
'com.tencent.mm': 'social', // 微信
|
||||||
|
'com.tencent.mobileqq': 'social', // QQ
|
||||||
|
|
||||||
|
// 工具类
|
||||||
|
'com.apple.Safari': 'tool',
|
||||||
|
'com.android.chrome': 'tool',
|
||||||
|
'com.google.chrome': 'tool',
|
||||||
|
'com.microsoft.edge': 'tool',
|
||||||
|
'com.apple.mobilemail': 'tool',
|
||||||
|
'com.google.Gmail': 'tool',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取应用分类
|
||||||
|
Future<String> getCategory(String packageName) async {
|
||||||
|
// 1. 先查用户自定义分类
|
||||||
|
final customCategory = await _getCustomCategory(packageName);
|
||||||
|
if (customCategory != null) {
|
||||||
|
return customCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查系统预设分类
|
||||||
|
if (defaultCategories.containsKey(packageName)) {
|
||||||
|
return defaultCategories[packageName]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 默认分类
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户自定义分类
|
||||||
|
Future<String?> _getCustomCategory(String packageName) async {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
if (kIsWeb) {
|
||||||
|
return _webCustomCategories[packageName];
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'app_category',
|
||||||
|
where: 'package_name = ? AND is_custom = 1',
|
||||||
|
whereArgs: [packageName],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return maps.first['category'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置自定义分类
|
||||||
|
Future<void> setCategory(String packageName, String category) async {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
if (kIsWeb) {
|
||||||
|
_webCustomCategories[packageName] = category;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
'app_category',
|
||||||
|
{
|
||||||
|
'package_name': packageName,
|
||||||
|
'category': category,
|
||||||
|
'is_custom': 1,
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除自定义分类(恢复为系统默认)
|
||||||
|
Future<void> removeCustomCategory(String packageName) async {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
if (kIsWeb) {
|
||||||
|
_webCustomCategories.remove(packageName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.delete(
|
||||||
|
'app_category',
|
||||||
|
where: 'package_name = ? AND is_custom = 1',
|
||||||
|
whereArgs: [packageName],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有自定义分类
|
||||||
|
Future<Map<String, String>> getAllCustomCategories() async {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
if (kIsWeb) {
|
||||||
|
return Map<String, String>.from(_webCustomCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'app_category',
|
||||||
|
where: 'is_custom = 1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = <String, String>{};
|
||||||
|
for (final map in maps) {
|
||||||
|
result[map['package_name'] as String] = map['category'] as String;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量设置分类
|
||||||
|
Future<void> batchSetCategories(Map<String, String> categories) async {
|
||||||
|
// Web 平台使用内存存储
|
||||||
|
if (kIsWeb) {
|
||||||
|
_webCustomCategories.addAll(categories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final batch = db.batch();
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
for (final entry in categories.entries) {
|
||||||
|
batch.insert(
|
||||||
|
'app_category',
|
||||||
|
{
|
||||||
|
'package_name': entry.key,
|
||||||
|
'category': entry.value,
|
||||||
|
'is_custom': 1,
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
156
lib/services/export_service.dart
Normal file
156
lib/services/export_service.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import '../database/app_usage_dao.dart';
|
||||||
|
import '../database/daily_stats_dao.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class ExportService {
|
||||||
|
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||||
|
final DailyStatsDao _dailyStatsDao = DailyStatsDao();
|
||||||
|
|
||||||
|
/// 导出 CSV 格式数据
|
||||||
|
Future<String> exportToCSV({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
final appUsages = await _appUsageDao.getAppUsages(
|
||||||
|
startTime: startDate,
|
||||||
|
endTime: endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
// CSV 头部
|
||||||
|
buffer.writeln('应用名称,包名,开始时间,结束时间,使用时长(秒),使用时长(格式化),分类');
|
||||||
|
|
||||||
|
// 数据行
|
||||||
|
for (final usage in appUsages) {
|
||||||
|
buffer.writeln([
|
||||||
|
_escapeCsvField(usage.appName),
|
||||||
|
_escapeCsvField(usage.packageName),
|
||||||
|
usage.startTime.toIso8601String(),
|
||||||
|
usage.endTime.toIso8601String(),
|
||||||
|
usage.duration.toString(),
|
||||||
|
usage.formattedDuration,
|
||||||
|
AppTheme.getCategoryName(usage.category),
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导出统计报告(文本格式)
|
||||||
|
Future<String> exportStatsReport({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
final stats = await _dailyStatsDao.getStatsRange(
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
buffer.writeln('=== AutoTime Tracker 统计报告 ===');
|
||||||
|
buffer.writeln('报告时间: ${startDate.toString().split(' ')[0]} 至 ${endDate.toString().split(' ')[0]}');
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('日期统计:');
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
int totalWorkTime = 0;
|
||||||
|
int totalStudyTime = 0;
|
||||||
|
int totalEntertainmentTime = 0;
|
||||||
|
int totalSocialTime = 0;
|
||||||
|
int totalToolTime = 0;
|
||||||
|
int totalTime = 0;
|
||||||
|
|
||||||
|
for (final stat in stats) {
|
||||||
|
buffer.writeln('${stat.date.toString().split(' ')[0]}:');
|
||||||
|
buffer.writeln(' 总时长: ${stat.formattedTotalTime}');
|
||||||
|
buffer.writeln(' 工作: ${_formatTime(stat.workTime)}');
|
||||||
|
buffer.writeln(' 学习: ${_formatTime(stat.studyTime)}');
|
||||||
|
buffer.writeln(' 娱乐: ${_formatTime(stat.entertainmentTime)}');
|
||||||
|
buffer.writeln(' 社交: ${_formatTime(stat.socialTime)}');
|
||||||
|
buffer.writeln(' 工具: ${_formatTime(stat.toolTime)}');
|
||||||
|
if (stat.efficiencyScore != null) {
|
||||||
|
buffer.writeln(' 效率评分: ${stat.efficiencyScore}%');
|
||||||
|
}
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
totalWorkTime += stat.workTime;
|
||||||
|
totalStudyTime += stat.studyTime;
|
||||||
|
totalEntertainmentTime += stat.entertainmentTime;
|
||||||
|
totalSocialTime += stat.socialTime;
|
||||||
|
totalToolTime += stat.toolTime;
|
||||||
|
totalTime += stat.totalTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('总计:');
|
||||||
|
buffer.writeln(' 总时长: ${_formatTime(totalTime)}');
|
||||||
|
buffer.writeln(' 工作: ${_formatTime(totalWorkTime)} (${(totalWorkTime / totalTime * 100).toStringAsFixed(1)}%)');
|
||||||
|
buffer.writeln(' 学习: ${_formatTime(totalStudyTime)} (${(totalStudyTime / totalTime * 100).toStringAsFixed(1)}%)');
|
||||||
|
buffer.writeln(' 娱乐: ${_formatTime(totalEntertainmentTime)} (${(totalEntertainmentTime / totalTime * 100).toStringAsFixed(1)}%)');
|
||||||
|
buffer.writeln(' 社交: ${_formatTime(totalSocialTime)} (${(totalSocialTime / totalTime * 100).toStringAsFixed(1)}%)');
|
||||||
|
buffer.writeln(' 工具: ${_formatTime(totalToolTime)} (${(totalToolTime / totalTime * 100).toStringAsFixed(1)}%)');
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导出今日报告
|
||||||
|
Future<String> exportTodayReport() async {
|
||||||
|
final today = DateTime.now();
|
||||||
|
final startOfDay = DateTime(today.year, today.month, today.day);
|
||||||
|
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
final stats = await _dailyStatsDao.getTodayStats();
|
||||||
|
final topApps = await _appUsageDao.getTopApps(
|
||||||
|
startTime: startOfDay,
|
||||||
|
endTime: endOfDay,
|
||||||
|
limit: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
buffer.writeln('=== AutoTime Tracker 今日报告 ===');
|
||||||
|
buffer.writeln('日期: ${today.toString().split(' ')[0]}');
|
||||||
|
buffer.writeln('');
|
||||||
|
|
||||||
|
if (stats != null) {
|
||||||
|
buffer.writeln('总时长: ${stats.formattedTotalTime}');
|
||||||
|
buffer.writeln('工作: ${_formatTime(stats.workTime)}');
|
||||||
|
buffer.writeln('学习: ${_formatTime(stats.studyTime)}');
|
||||||
|
buffer.writeln('娱乐: ${_formatTime(stats.entertainmentTime)}');
|
||||||
|
buffer.writeln('社交: ${_formatTime(stats.socialTime)}');
|
||||||
|
buffer.writeln('工具: ${_formatTime(stats.toolTime)}');
|
||||||
|
if (stats.efficiencyScore != null) {
|
||||||
|
buffer.writeln('效率评分: ${stats.efficiencyScore}%');
|
||||||
|
}
|
||||||
|
buffer.writeln('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topApps.isNotEmpty) {
|
||||||
|
buffer.writeln('Top 应用:');
|
||||||
|
for (int i = 0; i < topApps.length; i++) {
|
||||||
|
final app = topApps[i];
|
||||||
|
buffer.writeln('${i + 1}. ${app.appName}: ${app.formattedDuration} (${AppTheme.getCategoryName(app.category)})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _escapeCsvField(String field) {
|
||||||
|
if (field.contains(',') || field.contains('"') || field.contains('\n')) {
|
||||||
|
return '"${field.replaceAll('"', '""')}"';
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int seconds) {
|
||||||
|
final hours = seconds ~/ 3600;
|
||||||
|
final minutes = (seconds % 3600) ~/ 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
114
lib/services/mock_data_service.dart
Normal file
114
lib/services/mock_data_service.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
|
||||||
|
/// 测试数据服务 - 用于 Web 平台或开发测试
|
||||||
|
class MockDataService {
|
||||||
|
/// 生成今日测试统计数据
|
||||||
|
static DailyStats generateTodayStats() {
|
||||||
|
return DailyStats(
|
||||||
|
date: DateTime.now(),
|
||||||
|
totalTime: 23040, // 6小时24分钟
|
||||||
|
workTime: 14400, // 4小时
|
||||||
|
studyTime: 3600, // 1小时
|
||||||
|
entertainmentTime: 3600, // 1小时
|
||||||
|
socialTime: 1800, // 30分钟
|
||||||
|
toolTime: 0,
|
||||||
|
efficiencyScore: 72,
|
||||||
|
focusScore: 65,
|
||||||
|
appSwitchCount: 45,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成本周测试统计数据
|
||||||
|
static List<DailyStats> generateWeekStats() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||||
|
|
||||||
|
return List.generate(7, (index) {
|
||||||
|
final date = startOfWeek.add(Duration(days: index));
|
||||||
|
final baseTime = 20000 + (index * 500); // 每天略有不同
|
||||||
|
|
||||||
|
return DailyStats(
|
||||||
|
date: date,
|
||||||
|
totalTime: baseTime,
|
||||||
|
workTime: (baseTime * 0.6).round(), // 60% 工作时间
|
||||||
|
studyTime: (baseTime * 0.15).round(), // 15% 学习时间
|
||||||
|
entertainmentTime: (baseTime * 0.15).round(), // 15% 娱乐时间
|
||||||
|
socialTime: (baseTime * 0.08).round(), // 8% 社交时间
|
||||||
|
toolTime: (baseTime * 0.02).round(), // 2% 工具时间
|
||||||
|
efficiencyScore: 65 + (index * 2), // 效率评分递增
|
||||||
|
focusScore: 60 + (index * 3),
|
||||||
|
appSwitchCount: 40 + (index * 2),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成今日 Top 应用测试数据
|
||||||
|
static List<AppUsage> generateTopApps() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppUsage(
|
||||||
|
packageName: 'com.google.chrome',
|
||||||
|
appName: 'Chrome',
|
||||||
|
startTime: now.subtract(const Duration(hours: 2, minutes: 15)),
|
||||||
|
endTime: now,
|
||||||
|
duration: 8100, // 2小时15分钟
|
||||||
|
category: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
AppUsage(
|
||||||
|
packageName: 'com.microsoft.vscode',
|
||||||
|
appName: 'VS Code',
|
||||||
|
startTime: now.subtract(const Duration(hours: 1, minutes: 30)),
|
||||||
|
endTime: now,
|
||||||
|
duration: 5400, // 1小时30分钟
|
||||||
|
category: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
AppUsage(
|
||||||
|
packageName: 'com.slack',
|
||||||
|
appName: 'Slack',
|
||||||
|
startTime: now.subtract(const Duration(hours: 1)),
|
||||||
|
endTime: now,
|
||||||
|
duration: 3600, // 1小时
|
||||||
|
category: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
AppUsage(
|
||||||
|
packageName: 'com.notion',
|
||||||
|
appName: 'Notion',
|
||||||
|
startTime: now.subtract(const Duration(minutes: 45)),
|
||||||
|
endTime: now,
|
||||||
|
duration: 2700, // 45分钟
|
||||||
|
category: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
AppUsage(
|
||||||
|
packageName: 'com.apple.mail',
|
||||||
|
appName: 'Mail',
|
||||||
|
startTime: now.subtract(const Duration(minutes: 30)),
|
||||||
|
endTime: now,
|
||||||
|
duration: 1800, // 30分钟
|
||||||
|
category: 'work',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否应该使用测试数据
|
||||||
|
static bool shouldUseMockData() {
|
||||||
|
return kIsWeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
199
lib/services/statistics_service.dart
Normal file
199
lib/services/statistics_service.dart
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../models/daily_stats.dart';
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
import '../database/app_usage_dao.dart';
|
||||||
|
import '../database/daily_stats_dao.dart';
|
||||||
|
import 'mock_data_service.dart';
|
||||||
|
|
||||||
|
class StatisticsService {
|
||||||
|
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||||
|
final DailyStatsDao _dailyStatsDao = DailyStatsDao();
|
||||||
|
|
||||||
|
// 获取今日统计(如果不存在则计算)
|
||||||
|
Future<DailyStats> getTodayStats() async {
|
||||||
|
// Web 平台返回测试数据
|
||||||
|
if (kIsWeb) {
|
||||||
|
return MockDataService.generateTodayStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
final today = DateTime.now();
|
||||||
|
|
||||||
|
// 先查数据库
|
||||||
|
var stats = await _dailyStatsDao.getTodayStats();
|
||||||
|
if (stats != null) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不存在,计算并保存
|
||||||
|
stats = await _calculateDailyStats(today);
|
||||||
|
if (stats != null) {
|
||||||
|
await _dailyStatsDao.upsertDailyStats(stats);
|
||||||
|
} else {
|
||||||
|
// 如果没有数据,返回空统计
|
||||||
|
stats = DailyStats(
|
||||||
|
date: today,
|
||||||
|
totalTime: 0,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算指定日期的统计
|
||||||
|
Future<DailyStats?> _calculateDailyStats(DateTime date) async {
|
||||||
|
final startOfDay = DateTime(date.year, date.month, date.day);
|
||||||
|
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
// 获取该日期的所有应用使用记录
|
||||||
|
final appUsages = await _appUsageDao.getAppUsages(
|
||||||
|
startTime: startOfDay,
|
||||||
|
endTime: endOfDay,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appUsages.isEmpty) return null;
|
||||||
|
|
||||||
|
// 按分类聚合
|
||||||
|
final categoryTime = <String, int>{};
|
||||||
|
int totalTime = 0;
|
||||||
|
int appSwitchCount = 0;
|
||||||
|
|
||||||
|
for (final usage in appUsages) {
|
||||||
|
categoryTime[usage.category] = (categoryTime[usage.category] ?? 0) + usage.duration;
|
||||||
|
totalTime += usage.duration;
|
||||||
|
appSwitchCount += usage.deviceUnlockCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算效率评分
|
||||||
|
final efficiencyScore = _calculateEfficiencyScore(categoryTime, totalTime);
|
||||||
|
|
||||||
|
// 计算专注度评分
|
||||||
|
final focusScore = _calculateFocusScore(appSwitchCount, totalTime);
|
||||||
|
|
||||||
|
return DailyStats(
|
||||||
|
date: startOfDay,
|
||||||
|
totalTime: totalTime,
|
||||||
|
workTime: categoryTime['work'] ?? 0,
|
||||||
|
studyTime: categoryTime['study'] ?? 0,
|
||||||
|
entertainmentTime: categoryTime['entertainment'] ?? 0,
|
||||||
|
socialTime: categoryTime['social'] ?? 0,
|
||||||
|
toolTime: categoryTime['tool'] ?? 0,
|
||||||
|
efficiencyScore: efficiencyScore,
|
||||||
|
focusScore: focusScore,
|
||||||
|
appSwitchCount: appSwitchCount,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算效率评分
|
||||||
|
int _calculateEfficiencyScore(Map<String, int> categoryTime, int totalTime) {
|
||||||
|
if (totalTime == 0) return 0;
|
||||||
|
|
||||||
|
// 工作时间占比(40%)
|
||||||
|
final workRatio = (categoryTime['work'] ?? 0) / totalTime;
|
||||||
|
final workScore = workRatio * 40;
|
||||||
|
|
||||||
|
// 学习时间占比(30%)
|
||||||
|
final studyRatio = (categoryTime['study'] ?? 0) / totalTime;
|
||||||
|
final studyScore = studyRatio * 30;
|
||||||
|
|
||||||
|
// 娱乐时间占比(越低越好,30%)
|
||||||
|
final entertainmentRatio = (categoryTime['entertainment'] ?? 0) / totalTime;
|
||||||
|
final entertainmentScore = (1 - entertainmentRatio) * 30;
|
||||||
|
|
||||||
|
return (workScore + studyScore + entertainmentScore).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算专注度评分
|
||||||
|
int _calculateFocusScore(int appSwitchCount, int totalTime) {
|
||||||
|
if (totalTime == 0) return 0;
|
||||||
|
|
||||||
|
// 平均每小时切换次数
|
||||||
|
final switchesPerHour = (appSwitchCount / (totalTime / 3600));
|
||||||
|
|
||||||
|
// 理想情况:每小时切换 < 10 次 = 100分
|
||||||
|
// 每小时切换 > 50 次 = 0分
|
||||||
|
final score = 100 - (switchesPerHour * 2).clamp(0, 100);
|
||||||
|
|
||||||
|
return score.round();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本周统计
|
||||||
|
Future<List<DailyStats>> getWeekStats() async {
|
||||||
|
// Web 平台返回测试数据
|
||||||
|
if (kIsWeb) {
|
||||||
|
return MockDataService.generateWeekStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
final stats = await _dailyStatsDao.getWeekStats();
|
||||||
|
|
||||||
|
// 如果数据不完整,计算缺失的日期
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||||
|
|
||||||
|
final result = <DailyStats>[];
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
final date = startOfWeek.add(Duration(days: i));
|
||||||
|
final existing = stats.firstWhere(
|
||||||
|
(s) => s.date.year == date.year &&
|
||||||
|
s.date.month == date.month &&
|
||||||
|
s.date.day == date.day,
|
||||||
|
orElse: () => DailyStats(
|
||||||
|
date: date,
|
||||||
|
totalTime: 0,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
result.add(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取本月统计
|
||||||
|
Future<List<DailyStats>> getMonthStats() async {
|
||||||
|
return await _dailyStatsDao.getMonthStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新今日统计(重新计算)
|
||||||
|
Future<DailyStats> refreshTodayStats() async {
|
||||||
|
final today = DateTime.now();
|
||||||
|
final stats = await _calculateDailyStats(today);
|
||||||
|
|
||||||
|
if (stats != null) {
|
||||||
|
await _dailyStatsDao.upsertDailyStats(stats);
|
||||||
|
return stats;
|
||||||
|
} else {
|
||||||
|
final emptyStats = DailyStats(
|
||||||
|
date: today,
|
||||||
|
totalTime: 0,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _dailyStatsDao.upsertDailyStats(emptyStats);
|
||||||
|
return emptyStats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Top 应用
|
||||||
|
Future<List<AppUsage>> getTopApps({
|
||||||
|
required DateTime startTime,
|
||||||
|
required DateTime endTime,
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
// Web 平台返回测试数据
|
||||||
|
if (kIsWeb) {
|
||||||
|
return MockDataService.generateTopApps().take(limit).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _appUsageDao.getTopApps(
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
182
lib/services/time_tracking_service.dart
Normal file
182
lib/services/time_tracking_service.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../models/app_usage.dart';
|
||||||
|
import '../database/app_usage_dao.dart';
|
||||||
|
import 'category_service.dart';
|
||||||
|
import 'statistics_service.dart';
|
||||||
|
|
||||||
|
class TimeTrackingService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('autotime_tracker/time_tracking');
|
||||||
|
|
||||||
|
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||||
|
final CategoryService _categoryService = CategoryService();
|
||||||
|
final StatisticsService _statisticsService = StatisticsService();
|
||||||
|
|
||||||
|
/// 检查权限状态
|
||||||
|
Future<bool> hasPermission() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod<bool>('hasPermission');
|
||||||
|
return result ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error checking permission: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求权限
|
||||||
|
Future<bool> requestPermission() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod<bool>('requestPermission');
|
||||||
|
return result ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error requesting permission: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用使用数据(从系统 API)
|
||||||
|
Future<List<AppUsageData>> getAppUsageFromSystem({
|
||||||
|
required DateTime startTime,
|
||||||
|
required DateTime endTime,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod<List<dynamic>>('getAppUsage', {
|
||||||
|
'startTime': startTime.millisecondsSinceEpoch,
|
||||||
|
'endTime': endTime.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result == null) return [];
|
||||||
|
|
||||||
|
return result
|
||||||
|
.map((item) => AppUsageData.fromMap(Map<String, dynamic>.from(item)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error getting app usage from system: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步应用使用数据到数据库
|
||||||
|
Future<void> syncAppUsageToDatabase({
|
||||||
|
required DateTime startTime,
|
||||||
|
required DateTime endTime,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 1. 从系统获取数据
|
||||||
|
final systemData = await getAppUsageFromSystem(
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (systemData.isEmpty) {
|
||||||
|
print('No app usage data from system');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 转换为 AppUsage 模型并分类
|
||||||
|
final appUsages = <AppUsage>[];
|
||||||
|
for (final data in systemData) {
|
||||||
|
// 获取分类
|
||||||
|
final category = await _categoryService.getCategory(data.packageName);
|
||||||
|
|
||||||
|
// 创建 AppUsage 对象
|
||||||
|
final usage = AppUsage(
|
||||||
|
packageName: data.packageName,
|
||||||
|
appName: data.appName,
|
||||||
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
duration: data.duration,
|
||||||
|
category: category,
|
||||||
|
deviceUnlockCount: data.deviceUnlockCount ?? 0,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
appUsages.add(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 批量插入数据库
|
||||||
|
if (appUsages.isNotEmpty) {
|
||||||
|
await _appUsageDao.batchInsertAppUsages(appUsages);
|
||||||
|
print('Synced ${appUsages.length} app usage records to database');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新今日统计
|
||||||
|
await _statisticsService.refreshTodayStats();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error syncing app usage to database: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步今日数据
|
||||||
|
Future<void> syncTodayData() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||||
|
final endOfDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
await syncAppUsageToDatabase(
|
||||||
|
startTime: startOfDay,
|
||||||
|
endTime: endOfDay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动后台追踪
|
||||||
|
Future<void> startBackgroundTracking() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('startBackgroundTracking');
|
||||||
|
} catch (e) {
|
||||||
|
print('Error starting background tracking: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止后台追踪
|
||||||
|
Future<void> stopBackgroundTracking() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('stopBackgroundTracking');
|
||||||
|
} catch (e) {
|
||||||
|
print('Error stopping background tracking: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查后台追踪状态
|
||||||
|
Future<bool> isBackgroundTrackingActive() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod<bool>('isBackgroundTrackingActive');
|
||||||
|
return result ?? false;
|
||||||
|
} catch (e) {
|
||||||
|
print('Error checking background tracking status: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统返回的应用使用数据模型
|
||||||
|
class AppUsageData {
|
||||||
|
final String packageName;
|
||||||
|
final String appName;
|
||||||
|
final DateTime startTime;
|
||||||
|
final DateTime endTime;
|
||||||
|
final int duration; // 秒
|
||||||
|
final int? deviceUnlockCount;
|
||||||
|
|
||||||
|
AppUsageData({
|
||||||
|
required this.packageName,
|
||||||
|
required this.appName,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.duration,
|
||||||
|
this.deviceUnlockCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AppUsageData.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AppUsageData(
|
||||||
|
packageName: map['packageName'] as String,
|
||||||
|
appName: map['appName'] as String,
|
||||||
|
startTime: DateTime.fromMillisecondsSinceEpoch(map['startTime'] as int),
|
||||||
|
endTime: DateTime.fromMillisecondsSinceEpoch(map['endTime'] as int),
|
||||||
|
duration: map['duration'] as int,
|
||||||
|
deviceUnlockCount: map['deviceUnlockCount'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
119
lib/theme/app_theme.dart
Normal file
119
lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
class AppTheme {
|
||||||
|
// 颜色系统
|
||||||
|
static const Color primaryColor = Color(0xFF6366F1); // Indigo
|
||||||
|
static const Color secondaryColor = Color(0xFF8B5CF6); // Purple
|
||||||
|
static const Color accentColor = Color(0xFF10B981); // Green
|
||||||
|
|
||||||
|
// 分类颜色
|
||||||
|
static const Color workColor = Color(0xFF6366F1); // Indigo
|
||||||
|
static const Color studyColor = Color(0xFF8B5CF6); // Purple
|
||||||
|
static const Color entertainmentColor = Color(0xFFF59E0B); // Orange
|
||||||
|
static const Color socialColor = Color(0xFFEC4899); // Pink
|
||||||
|
static const Color toolColor = Color(0xFF6B7280); // Gray
|
||||||
|
|
||||||
|
// 状态颜色
|
||||||
|
static const Color successColor = Color(0xFF10B981);
|
||||||
|
static const Color warningColor = Color(0xFFF59E0B);
|
||||||
|
static const Color errorColor = Color(0xFFEF4444);
|
||||||
|
static const Color infoColor = Color(0xFF3B82F6);
|
||||||
|
|
||||||
|
// 浅色主题
|
||||||
|
static ThemeData get lightTheme {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: primaryColor,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
textTheme: GoogleFonts.interTextTheme(),
|
||||||
|
scaffoldBackgroundColor: Colors.white,
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
color: Colors.grey[50],
|
||||||
|
),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black87,
|
||||||
|
titleTextStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色主题
|
||||||
|
static ThemeData get darkTheme {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: primaryColor,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF1F2937),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
color: const Color(0xFF374151),
|
||||||
|
),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: const Color(0xFF1F2937),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
titleTextStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类颜色
|
||||||
|
static Color getCategoryColor(String category) {
|
||||||
|
switch (category.toLowerCase()) {
|
||||||
|
case 'work':
|
||||||
|
return workColor;
|
||||||
|
case 'study':
|
||||||
|
return studyColor;
|
||||||
|
case 'entertainment':
|
||||||
|
return entertainmentColor;
|
||||||
|
case 'social':
|
||||||
|
return socialColor;
|
||||||
|
case 'tool':
|
||||||
|
return toolColor;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类中文名称
|
||||||
|
static String getCategoryName(String category) {
|
||||||
|
switch (category.toLowerCase()) {
|
||||||
|
case 'work':
|
||||||
|
return '工作';
|
||||||
|
case 'study':
|
||||||
|
return '学习';
|
||||||
|
case 'entertainment':
|
||||||
|
return '娱乐';
|
||||||
|
case 'social':
|
||||||
|
return '社交';
|
||||||
|
case 'tool':
|
||||||
|
return '工具';
|
||||||
|
default:
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
241
lib/widgets/custom_time_picker_dialog.dart
Normal file
241
lib/widgets/custom_time_picker_dialog.dart
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 自定义时间选择器对话框
|
||||||
|
/// 用于选择小时和分钟,不会自动切换选择模式
|
||||||
|
class CustomTimePickerDialog extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final TimeOfDay initialTime;
|
||||||
|
|
||||||
|
const CustomTimePickerDialog({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.initialTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomTimePickerDialog> createState() => _CustomTimePickerDialogState();
|
||||||
|
|
||||||
|
/// 显示时间选择器对话框
|
||||||
|
static Future<TimeOfDay?> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required String title,
|
||||||
|
required TimeOfDay initialTime,
|
||||||
|
}) async {
|
||||||
|
return await showDialog<TimeOfDay>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => CustomTimePickerDialog(
|
||||||
|
title: title,
|
||||||
|
initialTime: initialTime,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomTimePickerDialogState extends State<CustomTimePickerDialog> {
|
||||||
|
late int _hours;
|
||||||
|
late int _minutes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_hours = widget.initialTime.hour;
|
||||||
|
_minutes = widget.initialTime.minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 小时选择
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Text('小时', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_hours > 0) {
|
||||||
|
_hours--;
|
||||||
|
} else {
|
||||||
|
_hours = 23;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$_hours',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_hours < 23) {
|
||||||
|
_hours++;
|
||||||
|
} else {
|
||||||
|
_hours = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// 分隔符
|
||||||
|
Text(
|
||||||
|
':',
|
||||||
|
style: theme.textTheme.headlineLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// 分钟选择
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Text('分钟', style: theme.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (_minutes >= 15) {
|
||||||
|
_minutes -= 15;
|
||||||
|
} else if (_minutes > 0) {
|
||||||
|
_minutes = 0;
|
||||||
|
} else {
|
||||||
|
_minutes = 45;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_minutes.toString().padLeft(2, '0'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_minutes += 15;
|
||||||
|
if (_minutes >= 60) {
|
||||||
|
_minutes = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 快速选择按钮
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildQuickTimeButton(context, theme, '00:00', 0, 0),
|
||||||
|
_buildQuickTimeButton(context, theme, '08:00', 8, 0),
|
||||||
|
_buildQuickTimeButton(context, theme, '12:00', 12, 0),
|
||||||
|
_buildQuickTimeButton(context, theme, '18:00', 18, 0),
|
||||||
|
_buildQuickTimeButton(context, theme, '20:00', 20, 0),
|
||||||
|
_buildQuickTimeButton(context, theme, '22:00', 22, 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
TimeOfDay(hour: _hours, minute: _minutes),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickTimeButton(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme,
|
||||||
|
String label,
|
||||||
|
int hours,
|
||||||
|
int minutes,
|
||||||
|
) {
|
||||||
|
final isSelected = _hours == hours && _minutes == minutes;
|
||||||
|
return OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_hours = hours;
|
||||||
|
_minutes = minutes;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
|
: null,
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outline.withOpacity(0.5),
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
149
lib/widgets/empty_state_widget.dart
Normal file
149
lib/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// 空状态组件
|
||||||
|
/// 用于显示无数据时的友好提示
|
||||||
|
class EmptyStateWidget extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final bool showIllustration;
|
||||||
|
|
||||||
|
const EmptyStateWidget({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.showIllustration = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 默认空状态 - 无数据
|
||||||
|
factory EmptyStateWidget.noData({
|
||||||
|
String? title,
|
||||||
|
String? subtitle,
|
||||||
|
String? actionLabel,
|
||||||
|
VoidCallback? onAction,
|
||||||
|
}) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
icon: Icons.inbox_outlined,
|
||||||
|
title: title ?? '暂无数据',
|
||||||
|
subtitle: subtitle ?? '使用应用一段时间后,数据将显示在这里',
|
||||||
|
actionLabel: actionLabel,
|
||||||
|
onAction: onAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空状态 - 无搜索结果
|
||||||
|
factory EmptyStateWidget.noSearchResults({
|
||||||
|
String? query,
|
||||||
|
}) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
title: '未找到匹配结果',
|
||||||
|
subtitle: query != null ? '没有找到与 "$query" 相关的内容' : '请尝试其他关键词',
|
||||||
|
showIllustration: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空状态 - 无应用数据
|
||||||
|
factory EmptyStateWidget.noApps({
|
||||||
|
VoidCallback? onAction,
|
||||||
|
}) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
icon: Icons.apps_outlined,
|
||||||
|
title: '暂无应用数据',
|
||||||
|
subtitle: '使用应用一段时间后,应用使用记录将显示在这里',
|
||||||
|
actionLabel: '刷新',
|
||||||
|
onAction: onAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空状态 - 首次使用
|
||||||
|
factory EmptyStateWidget.firstTime({
|
||||||
|
VoidCallback? onAction,
|
||||||
|
}) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
icon: Icons.touch_app,
|
||||||
|
title: '欢迎使用 AutoTime Tracker',
|
||||||
|
subtitle: '开始追踪您的时间使用情况\n\n1. 授予应用使用权限\n2. 正常使用您的设备\n3. 查看详细的使用统计',
|
||||||
|
actionLabel: '去设置权限',
|
||||||
|
onAction: onAction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (showIllustration) ...[
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.primaryColor.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
] else ...[
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 64,
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
141
lib/widgets/error_state_widget.dart
Normal file
141
lib/widgets/error_state_widget.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// 错误状态组件
|
||||||
|
/// 用于显示错误时的友好提示
|
||||||
|
class ErrorStateWidget extends StatelessWidget {
|
||||||
|
final String? title;
|
||||||
|
final String? message;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
final IconData icon;
|
||||||
|
final bool showDetails;
|
||||||
|
|
||||||
|
const ErrorStateWidget({
|
||||||
|
super.key,
|
||||||
|
this.title,
|
||||||
|
this.message,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onRetry,
|
||||||
|
this.icon = Icons.error_outline,
|
||||||
|
this.showDetails = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 默认错误状态
|
||||||
|
factory ErrorStateWidget.generic({
|
||||||
|
String? message,
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
return ErrorStateWidget(
|
||||||
|
title: '加载失败',
|
||||||
|
message: message ?? '请检查网络连接后重试',
|
||||||
|
actionLabel: '重试',
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 权限错误
|
||||||
|
factory ErrorStateWidget.permission({
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
return ErrorStateWidget(
|
||||||
|
icon: Icons.security,
|
||||||
|
title: '权限未授予',
|
||||||
|
message: '需要授予应用使用权限才能追踪时间\n\n请在系统设置中开启"使用情况访问权限"',
|
||||||
|
actionLabel: '去设置',
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络错误
|
||||||
|
factory ErrorStateWidget.network({
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
return ErrorStateWidget(
|
||||||
|
icon: Icons.wifi_off,
|
||||||
|
title: '网络连接失败',
|
||||||
|
message: '无法连接到服务器\n\n请检查网络连接后重试',
|
||||||
|
actionLabel: '重试',
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据加载错误
|
||||||
|
factory ErrorStateWidget.dataLoad({
|
||||||
|
String? message,
|
||||||
|
VoidCallback? onRetry,
|
||||||
|
}) {
|
||||||
|
return ErrorStateWidget(
|
||||||
|
icon: Icons.cloud_off,
|
||||||
|
title: '数据加载失败',
|
||||||
|
message: message ?? '无法加载数据,请稍后重试',
|
||||||
|
actionLabel: '重试',
|
||||||
|
onRetry: onRetry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.errorColor.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 56,
|
||||||
|
color: AppTheme.errorColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
title ?? '出错了',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message!,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (actionLabel != null && onRetry != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppTheme.errorColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
570
pubspec.lock
Normal file
570
pubspec.lock
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
fl_chart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fl_chart
|
||||||
|
sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.65.0"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
flutter_riverpod:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_riverpod
|
||||||
|
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.18.1"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
logger:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: logger
|
||||||
|
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.2"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.17"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.1"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.17.0"
|
||||||
|
path:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.5"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.20"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
riverpod:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: riverpod
|
||||||
|
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.6.1"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.3"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.15"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.1"
|
||||||
|
sqflite:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+2"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
state_notifier:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: state_notifier
|
||||||
|
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.7"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.24"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.5"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.4"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.4"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "15.0.2"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.9.0 <4.0.0"
|
||||||
|
flutter: ">=3.35.0"
|
||||||
43
pubspec.yaml
Normal file
43
pubspec.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: autotime_tracker
|
||||||
|
description: AutoTime Tracker - 自动时间追踪与效率分析工具
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# 状态管理
|
||||||
|
flutter_riverpod: ^2.4.9
|
||||||
|
|
||||||
|
# 本地数据库
|
||||||
|
sqflite: ^2.3.0
|
||||||
|
path: ^1.8.3
|
||||||
|
|
||||||
|
# 图表
|
||||||
|
fl_chart: ^0.65.0
|
||||||
|
|
||||||
|
# UI 组件
|
||||||
|
google_fonts: ^6.1.0
|
||||||
|
intl: ^0.18.1
|
||||||
|
|
||||||
|
# 工具
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
logger: ^2.0.2+1
|
||||||
|
url_launcher: ^6.2.2
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# 资源文件
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
|
|
||||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
web/icons/Icon-192.png
Normal file
BIN
web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/icons/Icon-512.png
Normal file
BIN
web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
web/icons/Icon-maskable-192.png
Normal file
BIN
web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web/icons/Icon-maskable-512.png
Normal file
BIN
web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
38
web/index.html
Normal file
38
web/index.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!--
|
||||||
|
If you are serving your web app in a path other than the root, change the
|
||||||
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
||||||
|
The path provided below has to start and end with a slash "/" in order for
|
||||||
|
it to work correctly.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||||
|
|
||||||
|
This is a placeholder for base href that will be replaced by the value of
|
||||||
|
the `--base-href` argument provided to `flutter build`.
|
||||||
|
-->
|
||||||
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
|
||||||
|
<!-- iOS meta tags & icons -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="autotime_tracker">
|
||||||
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
|
<title>autotime_tracker</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
web/manifest.json
Normal file
35
web/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "autotime_tracker",
|
||||||
|
"short_name": "autotime_tracker",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0175C2",
|
||||||
|
"theme_color": "#0175C2",
|
||||||
|
"description": "A new Flutter project.",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
211
开发进度.md
Normal file
211
开发进度.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# AutoTime Tracker 开发进度
|
||||||
|
|
||||||
|
## 📊 总体进度
|
||||||
|
|
||||||
|
**当前阶段:** Phase 1 - MVP 开发
|
||||||
|
**完成度:** 98%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 已完成功能
|
||||||
|
|
||||||
|
### Week 1-2: 项目搭建 ✅
|
||||||
|
- [x] Flutter 项目初始化
|
||||||
|
- [x] 数据库设计
|
||||||
|
- [x] UI/UX 设计
|
||||||
|
- [x] 基础架构搭建
|
||||||
|
|
||||||
|
### Week 5-6: 统计与可视化 ✅
|
||||||
|
- [x] 数据统计模块
|
||||||
|
- [x] 图表组件
|
||||||
|
- [x] 主界面开发
|
||||||
|
|
||||||
|
### Week 3-4: 核心功能开发 ✅
|
||||||
|
- [x] 数据存储模块(SQLite)
|
||||||
|
- [x] 数据库初始化
|
||||||
|
- [x] 表结构创建
|
||||||
|
- [x] 索引优化
|
||||||
|
- [x] 数据访问层(DAO)
|
||||||
|
- [x] AppUsageDao - 应用使用记录
|
||||||
|
- [x] DailyStatsDao - 每日统计
|
||||||
|
- [x] 分类服务(CategoryService)
|
||||||
|
- [x] 预设分类规则
|
||||||
|
- [x] 自定义分类管理
|
||||||
|
- [x] 分类查询
|
||||||
|
- [x] 统计服务(StatisticsService)
|
||||||
|
- [x] 今日统计计算
|
||||||
|
- [x] 效率评分算法
|
||||||
|
- [x] 专注度评分算法
|
||||||
|
- [x] 周/月统计
|
||||||
|
- [x] 状态管理(Riverpod)
|
||||||
|
- [x] StatisticsProvider
|
||||||
|
- [x] TodayStatsProvider
|
||||||
|
- [x] WeekStatsProvider
|
||||||
|
- [x] TopAppsProvider
|
||||||
|
- [x] 界面集成真实数据
|
||||||
|
- [x] TodayScreen 使用数据库数据
|
||||||
|
- [x] 加载状态处理
|
||||||
|
- [x] 错误处理
|
||||||
|
- [x] 时间追踪服务(Platform Channel)✅
|
||||||
|
- [x] Flutter 侧接口实现
|
||||||
|
- [x] iOS Screen Time API 集成
|
||||||
|
- [x] Android Usage Stats API 集成
|
||||||
|
- [x] 权限管理
|
||||||
|
- [x] 数据同步逻辑
|
||||||
|
- [x] 后台同步服务
|
||||||
|
- [x] 权限引导界面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 进行中功能
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 待开发功能
|
||||||
|
|
||||||
|
### 优先级高
|
||||||
|
- [ ] **原生 API 实际实现**
|
||||||
|
- [ ] iOS Screen Time API 完整实现(需要真实设备测试)
|
||||||
|
- [ ] Android Usage Stats API 完整实现(需要真实设备测试)
|
||||||
|
|
||||||
|
### 优先级中
|
||||||
|
- [ ] **测试与优化**
|
||||||
|
- [ ] 单元测试
|
||||||
|
- [ ] 性能优化
|
||||||
|
- [ ] Bug 修复
|
||||||
|
|
||||||
|
- [ ] **上架准备**
|
||||||
|
- [ ] 应用图标和启动画面
|
||||||
|
- [ ] 应用商店资料
|
||||||
|
- [ ] 隐私政策文档
|
||||||
|
|
||||||
|
### 优先级低(Phase 2)
|
||||||
|
- [ ] Widget 小组件
|
||||||
|
- [ ] 高级统计分析
|
||||||
|
- [ ] 机器学习分类
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 已创建文件
|
||||||
|
|
||||||
|
### 数据库层
|
||||||
|
- ✅ `lib/database/database_helper.dart` - 数据库初始化
|
||||||
|
- ✅ `lib/database/app_usage_dao.dart` - 应用使用记录 DAO
|
||||||
|
- ✅ `lib/database/daily_stats_dao.dart` - 每日统计 DAO
|
||||||
|
- ✅ `lib/database/time_goal_dao.dart` - 时间目标 DAO
|
||||||
|
|
||||||
|
### 服务层
|
||||||
|
- ✅ `lib/services/category_service.dart` - 分类服务
|
||||||
|
- ✅ `lib/services/statistics_service.dart` - 统计服务
|
||||||
|
- ✅ `lib/services/time_tracking_service.dart` - 时间追踪服务
|
||||||
|
- ✅ `lib/services/background_sync_service.dart` - 后台同步服务
|
||||||
|
- ✅ `lib/services/export_service.dart` - 数据导出服务
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- ✅ `lib/providers/statistics_provider.dart` - 统计相关 Provider
|
||||||
|
- ✅ `lib/providers/time_tracking_provider.dart` - 时间追踪 Provider
|
||||||
|
- ✅ `lib/providers/background_sync_provider.dart` - 后台同步 Provider
|
||||||
|
|
||||||
|
### 界面层
|
||||||
|
- ✅ `lib/screens/today_screen.dart` - 已集成真实数据
|
||||||
|
- ✅ `lib/screens/permission_screen.dart` - 权限引导界面
|
||||||
|
- ✅ `lib/screens/category_management_screen.dart` - 分类管理界面
|
||||||
|
- ✅ `lib/screens/goal_setting_screen.dart` - 目标设定界面
|
||||||
|
- ✅ `lib/screens/export_data_screen.dart` - 数据导出界面
|
||||||
|
- ✅ `lib/screens/data_privacy_screen.dart` - 数据与隐私页面
|
||||||
|
- ✅ `lib/screens/about_screen.dart` - 关于页面
|
||||||
|
|
||||||
|
### 原生层
|
||||||
|
- ✅ `ios/Runner/TimeTrackingPlugin.swift` - iOS Platform Channel
|
||||||
|
- ✅ `ios/Runner/AppDelegate.swift` - iOS 插件注册
|
||||||
|
- ✅ `android/app/src/main/kotlin/com/autotime/tracker/TimeTrackingPlugin.kt` - Android Platform Channel
|
||||||
|
- ✅ `android/app/src/main/AndroidManifest.xml` - Android 权限配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 技术实现详情
|
||||||
|
|
||||||
|
### 数据库结构
|
||||||
|
|
||||||
|
**app_usage 表:**
|
||||||
|
- 应用使用记录
|
||||||
|
- 索引:start_time, category, package_name
|
||||||
|
|
||||||
|
**daily_stats 表:**
|
||||||
|
- 每日统计数据
|
||||||
|
- 索引:date
|
||||||
|
|
||||||
|
**app_category 表:**
|
||||||
|
- 应用分类规则
|
||||||
|
- 索引:package_name
|
||||||
|
|
||||||
|
**time_goal 表:**
|
||||||
|
- 时间目标设定
|
||||||
|
|
||||||
|
### 核心算法
|
||||||
|
|
||||||
|
**效率评分算法:**
|
||||||
|
- 工作时间占比:40%
|
||||||
|
- 学习时间占比:30%
|
||||||
|
- 娱乐时间占比(越低越好):30%
|
||||||
|
|
||||||
|
**专注度评分算法:**
|
||||||
|
- 基于应用切换频率
|
||||||
|
- 每小时切换 < 10 次 = 100分
|
||||||
|
- 每小时切换 > 50 次 = 0分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 已知问题
|
||||||
|
|
||||||
|
1. **iOS Screen Time API 实现**
|
||||||
|
- 当前提供了基本框架
|
||||||
|
- 实际 API 使用方式可能需要根据 Apple 文档调整
|
||||||
|
- 某些系统应用可能无法追踪
|
||||||
|
|
||||||
|
2. **Android 权限授予**
|
||||||
|
- 需要用户手动在系统设置中授予权限
|
||||||
|
- 应用无法直接请求权限
|
||||||
|
- 需要引导用户到设置页面
|
||||||
|
|
||||||
|
3. **后台同步限制**
|
||||||
|
- iOS 和 Android 都对后台运行有严格限制
|
||||||
|
- 后台同步可能被系统终止
|
||||||
|
- 建议使用系统提供的后台任务机制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 下一步计划
|
||||||
|
|
||||||
|
### 立即任务
|
||||||
|
1. **完善时间追踪服务**
|
||||||
|
- 优化 iOS Screen Time API 实现
|
||||||
|
- 完善错误处理
|
||||||
|
- 添加重试机制
|
||||||
|
|
||||||
|
2. **完善界面功能**
|
||||||
|
- 分类管理页面
|
||||||
|
- 目标设定页面
|
||||||
|
- 数据导出功能
|
||||||
|
|
||||||
|
3. **测试和优化**
|
||||||
|
- 添加单元测试
|
||||||
|
- 性能测试
|
||||||
|
- 用户体验优化
|
||||||
|
- 后台同步优化(WorkManager/Background Tasks)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 进度统计
|
||||||
|
|
||||||
|
- **总任务数:** 36
|
||||||
|
- **已完成:** 35 (97%)
|
||||||
|
- **进行中:** 0 (0%)
|
||||||
|
- **待开始:** 1 (3%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新:** 2024-11-13
|
||||||
|
|
||||||
116
快速启动指南.md
Normal file
116
快速启动指南.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# AutoTime Tracker - 快速启动指南
|
||||||
|
|
||||||
|
## 🚀 立即运行
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 运行项目
|
||||||
|
|
||||||
|
#### Windows 用户(推荐)
|
||||||
|
|
||||||
|
**方式 1:使用运行脚本(最简单)**
|
||||||
|
```bash
|
||||||
|
# 双击运行 运行.bat 或在 PowerShell 中运行
|
||||||
|
.\运行.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式 2:直接运行命令**
|
||||||
|
```bash
|
||||||
|
# Windows 桌面应用
|
||||||
|
flutter run -d windows
|
||||||
|
|
||||||
|
# Web 服务器模式(推荐,兼容性最好)
|
||||||
|
flutter run -d web-server
|
||||||
|
# 运行后会显示 URL,在浏览器中打开即可
|
||||||
|
|
||||||
|
# Edge 浏览器
|
||||||
|
flutter run -d edge
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mac/Linux 用户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# iOS 模拟器
|
||||||
|
flutter run -d ios
|
||||||
|
|
||||||
|
# Android 模拟器/设备
|
||||||
|
flutter run -d android
|
||||||
|
|
||||||
|
# 或指定设备
|
||||||
|
flutter devices # 查看可用设备
|
||||||
|
flutter run -d <device-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 启动 Android 模拟器(无需 Android Studio)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 查看可用模拟器
|
||||||
|
flutter emulators
|
||||||
|
|
||||||
|
# 2. 启动模拟器
|
||||||
|
flutter emulators --launch <emulator-id>
|
||||||
|
|
||||||
|
# 3. 运行应用
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
**提示:** 如果还没有模拟器,可以使用 Android Studio 创建,或者使用物理设备(启用 USB 调试后直接 `flutter run`)。
|
||||||
|
|
||||||
|
## 📱 界面说明
|
||||||
|
|
||||||
|
### 主界面(Today)
|
||||||
|
- **总时长显示**:大字体显示今日总使用时长
|
||||||
|
- **效率评分**:基于时间分配的效率评分(0-100%)
|
||||||
|
- **分类饼图**:可视化展示各分类时间分布
|
||||||
|
- **Top 应用**:显示今日使用时间最长的 5 个应用
|
||||||
|
|
||||||
|
### 统计界面(Stats)
|
||||||
|
- **时间趋势图**:折线图展示每日总时长趋势
|
||||||
|
- **分类对比图**:堆叠柱状图对比各分类时间
|
||||||
|
- **应用详情**:查看每个应用的详细使用数据
|
||||||
|
|
||||||
|
### 设置界面(Settings)
|
||||||
|
- **应用分类管理**:管理应用分类规则
|
||||||
|
- **时间目标设定**:设置每日时间目标
|
||||||
|
- **数据与隐私**:数据管理、导出、删除
|
||||||
|
- **升级到 Pro**:查看高级功能
|
||||||
|
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q: 运行报错 "No devices found"
|
||||||
|
**A:** 确保已启动模拟器或连接设备
|
||||||
|
```bash
|
||||||
|
# iOS
|
||||||
|
open -a Simulator
|
||||||
|
|
||||||
|
# Android
|
||||||
|
flutter emulators --launch <emulator-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 依赖安装失败
|
||||||
|
**A:** 检查网络连接,或使用国内镜像
|
||||||
|
```bash
|
||||||
|
export PUB_HOSTED_URL=https://pub.flutter-io.cn
|
||||||
|
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 图表不显示
|
||||||
|
**A:** 确保已安装 fl_chart 依赖
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter clean
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [README.md](./README.md) - 项目概览
|
||||||
|
- [开发进度.md](./开发进度.md) - 开发进度和待办事项
|
||||||
|
- [真实数据测试指南.md](./真实数据测试指南.md) - 真实设备测试说明
|
||||||
|
|
||||||
357
真实数据测试指南.md
Normal file
357
真实数据测试指南.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# 真实数据测试指南
|
||||||
|
|
||||||
|
## 📱 测试真实数据需要什么?
|
||||||
|
|
||||||
|
### ✅ 必须条件
|
||||||
|
|
||||||
|
1. **真实手机设备**(Android 或 iOS)
|
||||||
|
- 模拟器无法获取真实的系统使用数据
|
||||||
|
- Web 平台不支持系统 API
|
||||||
|
|
||||||
|
2. **安装应用到手机**
|
||||||
|
- 需要将应用安装到真实设备上
|
||||||
|
- 通过 USB 连接或构建 APK/IPA 安装
|
||||||
|
|
||||||
|
3. **授予系统权限**
|
||||||
|
- Android: "使用情况访问权限" (Usage Access)
|
||||||
|
- iOS: Screen Time API 权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 测试步骤
|
||||||
|
|
||||||
|
### 方式 1:USB 连接调试(推荐,最简单)
|
||||||
|
|
||||||
|
#### Android 设备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在手机上启用开发者选项和 USB 调试
|
||||||
|
# 设置 → 关于手机 → 连续点击"版本号"7次
|
||||||
|
# 设置 → 系统 → 开发者选项 → 启用"USB 调试"
|
||||||
|
|
||||||
|
# 2. 用 USB 连接手机到电脑
|
||||||
|
|
||||||
|
# 3. 检查设备连接
|
||||||
|
flutter devices
|
||||||
|
|
||||||
|
# 4. 运行应用到手机
|
||||||
|
flutter run -d <device-id>
|
||||||
|
|
||||||
|
# 或者直接运行(会自动选择设备)
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### iOS 设备(Mac 用户)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 在 iPhone 上:设置 → 通用 → VPN与设备管理 → 信任电脑
|
||||||
|
|
||||||
|
# 2. 连接 iPhone 到 Mac
|
||||||
|
|
||||||
|
# 3. 运行应用
|
||||||
|
flutter run -d <device-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- ✅ 实时调试
|
||||||
|
- ✅ 支持热重载
|
||||||
|
- ✅ 可以看到日志输出
|
||||||
|
- ✅ 无需手动安装
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方式 2:构建安装包(适合分享测试)
|
||||||
|
|
||||||
|
#### Android APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建 APK
|
||||||
|
flutter build apk
|
||||||
|
|
||||||
|
# 2. APK 文件位置
|
||||||
|
# build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
|
||||||
|
# 3. 传输到手机并安装
|
||||||
|
# - 通过 USB 传输
|
||||||
|
# - 通过云盘/邮件发送
|
||||||
|
# - 通过 ADB 安装:adb install build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Android App Bundle(用于 Google Play)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build appbundle
|
||||||
|
# 输出:build/app/outputs/bundle/release/app-release.aab
|
||||||
|
```
|
||||||
|
|
||||||
|
#### iOS(需要 Apple 开发者账号)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发版本
|
||||||
|
flutter build ios
|
||||||
|
|
||||||
|
# 或者使用 Xcode 构建和签名
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- ✅ 可以分享给他人测试
|
||||||
|
- ✅ 不需要连接电脑
|
||||||
|
- ✅ 可以测试真实使用场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 权限设置
|
||||||
|
|
||||||
|
### Android 权限设置
|
||||||
|
|
||||||
|
应用首次运行时,需要引导用户授予权限:
|
||||||
|
|
||||||
|
1. **应用内权限请求**
|
||||||
|
- 应用会自动显示权限引导界面
|
||||||
|
- 点击"去设置"按钮
|
||||||
|
|
||||||
|
2. **系统设置页面**
|
||||||
|
- 自动跳转到"使用情况访问权限"设置
|
||||||
|
- 找到 "AutoTime Tracker"
|
||||||
|
- 开启"允许使用情况访问"
|
||||||
|
|
||||||
|
3. **验证权限**
|
||||||
|
- 返回应用
|
||||||
|
- 应用会自动检测权限状态
|
||||||
|
- 如果已授予,进入主界面
|
||||||
|
|
||||||
|
**手动设置路径:**
|
||||||
|
```
|
||||||
|
设置 → 应用 → 特殊应用访问 → 使用情况访问权限 → AutoTime Tracker → 开启
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS 权限设置
|
||||||
|
|
||||||
|
1. **应用内权限请求**
|
||||||
|
- 应用会自动请求 Screen Time API 权限
|
||||||
|
- 用户需要明确授权
|
||||||
|
|
||||||
|
2. **系统设置**
|
||||||
|
- 设置 → 屏幕使用时间 → 应用限制
|
||||||
|
- 确保应用有访问权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 测试真实数据
|
||||||
|
|
||||||
|
### 1. 安装并运行应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 连接设备后
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 授予权限
|
||||||
|
|
||||||
|
- 按照应用内的引导完成权限设置
|
||||||
|
|
||||||
|
### 3. 使用手机一段时间
|
||||||
|
|
||||||
|
- 正常使用手机上的各种应用
|
||||||
|
- 系统会自动记录使用数据
|
||||||
|
|
||||||
|
### 4. 查看数据
|
||||||
|
|
||||||
|
- 打开应用
|
||||||
|
- 查看"Today"页面,应该能看到真实的使用数据
|
||||||
|
- 查看"Stats"页面,查看历史统计
|
||||||
|
|
||||||
|
### 5. 验证功能
|
||||||
|
|
||||||
|
- ✅ 应用使用时间是否正确
|
||||||
|
- ✅ 分类是否正确
|
||||||
|
- ✅ 统计数据是否准确
|
||||||
|
- ✅ 图表是否正常显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试场景
|
||||||
|
|
||||||
|
### 场景 1:基础功能测试
|
||||||
|
|
||||||
|
1. 安装应用
|
||||||
|
2. 授予权限
|
||||||
|
3. 使用手机 1-2 小时
|
||||||
|
4. 打开应用查看数据
|
||||||
|
|
||||||
|
### 场景 2:分类测试
|
||||||
|
|
||||||
|
1. 使用不同类型的应用(工作、娱乐、社交等)
|
||||||
|
2. 检查分类是否正确
|
||||||
|
3. 手动调整分类(设置 → 应用分类)
|
||||||
|
4. 验证分类是否生效
|
||||||
|
|
||||||
|
### 场景 3:目标测试
|
||||||
|
|
||||||
|
1. 设置每日总时长目标
|
||||||
|
2. 设置分类时间限制
|
||||||
|
3. 使用手机一段时间
|
||||||
|
4. 检查目标完成情况
|
||||||
|
|
||||||
|
### 场景 4:数据导出测试
|
||||||
|
|
||||||
|
1. 使用应用一段时间,积累数据
|
||||||
|
2. 进入设置 → 数据导出
|
||||||
|
3. 导出 CSV 和统计报告
|
||||||
|
4. 验证导出文件内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
### 数据延迟
|
||||||
|
|
||||||
|
- **Android**: 使用情况数据可能有几分钟延迟
|
||||||
|
- **iOS**: Screen Time 数据更新较慢
|
||||||
|
- **建议**: 使用手机一段时间后再查看数据
|
||||||
|
|
||||||
|
### 权限限制
|
||||||
|
|
||||||
|
- **Android**: 某些系统应用可能无法追踪
|
||||||
|
- **iOS**: Screen Time API 有严格限制
|
||||||
|
- **建议**: 测试时使用常见的第三方应用
|
||||||
|
|
||||||
|
### 后台限制
|
||||||
|
|
||||||
|
- 应用需要在后台运行才能持续追踪
|
||||||
|
- 某些手机系统会限制后台运行
|
||||||
|
- **建议**: 将应用添加到"白名单"或"不受限制"
|
||||||
|
|
||||||
|
### 电池优化
|
||||||
|
|
||||||
|
- 某些手机会自动优化电池,可能影响后台追踪
|
||||||
|
- **建议**: 在电池设置中,将应用设置为"不受限制"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 调试技巧
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行应用时查看日志
|
||||||
|
flutter run
|
||||||
|
|
||||||
|
# 或者单独查看日志
|
||||||
|
flutter logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查权限状态
|
||||||
|
|
||||||
|
在应用中:
|
||||||
|
- 设置 → 权限设置
|
||||||
|
- 查看权限是否已授予
|
||||||
|
|
||||||
|
### 验证数据同步
|
||||||
|
|
||||||
|
1. 使用手机一段时间
|
||||||
|
2. 打开应用
|
||||||
|
3. 点击刷新按钮
|
||||||
|
4. 查看数据是否更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 测试清单
|
||||||
|
|
||||||
|
### 安装测试
|
||||||
|
- [ ] 应用可以正常安装
|
||||||
|
- [ ] 应用可以正常启动
|
||||||
|
- [ ] 没有崩溃或错误
|
||||||
|
|
||||||
|
### 权限测试
|
||||||
|
- [ ] 权限引导界面正常显示
|
||||||
|
- [ ] 可以跳转到系统设置
|
||||||
|
- [ ] 权限授予后应用可以正常使用
|
||||||
|
|
||||||
|
### 数据追踪测试
|
||||||
|
- [ ] 可以获取应用使用数据
|
||||||
|
- [ ] 数据时间戳正确
|
||||||
|
- [ ] 应用名称显示正确
|
||||||
|
- [ ] 使用时长计算准确
|
||||||
|
|
||||||
|
### 分类测试
|
||||||
|
- [ ] 自动分类正确
|
||||||
|
- [ ] 可以手动修改分类
|
||||||
|
- [ ] 分类修改后生效
|
||||||
|
|
||||||
|
### 统计测试
|
||||||
|
- [ ] 今日统计正确
|
||||||
|
- [ ] 周统计正确
|
||||||
|
- [ ] 图表显示正常
|
||||||
|
- [ ] 效率评分计算正确
|
||||||
|
|
||||||
|
### 功能测试
|
||||||
|
- [ ] 目标设置功能正常
|
||||||
|
- [ ] 数据导出功能正常
|
||||||
|
- [ ] 设置保存正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 快速测试流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 连接手机
|
||||||
|
flutter devices
|
||||||
|
|
||||||
|
# 2. 运行应用
|
||||||
|
flutter run
|
||||||
|
|
||||||
|
# 3. 在手机上:
|
||||||
|
# - 授予权限
|
||||||
|
# - 使用手机 10-15 分钟
|
||||||
|
# - 打开应用查看数据
|
||||||
|
|
||||||
|
# 4. 验证数据是否正确显示
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 提示
|
||||||
|
|
||||||
|
1. **首次测试建议使用物理设备**,模拟器无法获取真实系统数据
|
||||||
|
2. **测试前确保手机有足够的电量**,避免测试中断
|
||||||
|
3. **建议测试多种应用类型**,验证分类功能
|
||||||
|
4. **测试时间建议 1-2 小时**,积累足够的数据
|
||||||
|
5. **可以导出数据验证准确性**,检查 CSV 文件内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q: 应用显示"无数据"
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- 检查权限是否已授予
|
||||||
|
- 等待几分钟,数据可能有延迟
|
||||||
|
- 使用一些应用,确保有使用记录
|
||||||
|
|
||||||
|
### Q: 某些应用无法追踪
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- 系统应用可能无法追踪(这是系统限制)
|
||||||
|
- 某些受保护的应用可能无法追踪
|
||||||
|
- 这是正常现象
|
||||||
|
|
||||||
|
### Q: 数据不准确
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- 检查时间同步是否正确
|
||||||
|
- 验证应用分类是否正确
|
||||||
|
- 检查是否有重复记录
|
||||||
|
|
||||||
|
### Q: 应用在后台被杀死
|
||||||
|
|
||||||
|
**A:**
|
||||||
|
- 将应用添加到电池优化白名单
|
||||||
|
- 允许应用后台运行
|
||||||
|
- 检查系统后台限制设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结:测试真实数据需要安装到真实手机设备上,通过 USB 连接运行是最简单的方式。** 📱
|
||||||
|
|
||||||
Reference in New Issue
Block a user