From 6b321890c00f6d5ce13969853a7c8be9ae714b54 Mon Sep 17 00:00:00 2001 From: ytc1012 <18001193130@163.com> Date: Thu, 13 Nov 2025 15:45:28 +0800 Subject: [PATCH] first commit --- .gitignore | 91 +++ .metadata | 30 + README.md | 94 +++ analysis_options.yaml | 1 + android/app/src/main/AndroidManifest.xml | 31 + .../com/autotime/tracker/MainActivity.kt | 8 + .../autotime/tracker/TimeTrackingPlugin.kt | 189 ++++++ ios/Runner/AppDelegate.swift | 19 + ios/Runner/TimeTrackingPlugin.swift | 154 +++++ lib/database/app_usage_dao.dart | 154 +++++ lib/database/daily_stats_dao.dart | 165 +++++ lib/database/database_helper.dart | 114 ++++ lib/database/time_goal_dao.dart | 130 ++++ lib/main.dart | 84 +++ lib/models/app_usage.dart | 70 +++ lib/models/daily_stats.dart | 64 ++ lib/models/time_goal.dart | 54 ++ lib/providers/background_sync_provider.dart | 20 + lib/providers/statistics_provider.dart | 54 ++ lib/providers/time_tracking_provider.dart | 20 + lib/screens/about_screen.dart | 293 +++++++++ lib/screens/appearance_settings_screen.dart | 263 ++++++++ lib/screens/category_management_screen.dart | 290 +++++++++ lib/screens/data_privacy_screen.dart | 414 +++++++++++++ lib/screens/export_data_screen.dart | 403 +++++++++++++ lib/screens/goal_setting_screen.dart | 494 +++++++++++++++ lib/screens/home_screen.dart | 73 +++ lib/screens/notification_settings_screen.dart | 252 ++++++++ lib/screens/permission_screen.dart | 229 +++++++ lib/screens/settings_screen.dart | 270 +++++++++ lib/screens/stats_screen.dart | 564 +++++++++++++++++ lib/screens/today_screen.dart | 407 +++++++++++++ lib/services/background_sync_service.dart | 60 ++ lib/services/category_service.dart | 183 ++++++ lib/services/export_service.dart | 156 +++++ lib/services/mock_data_service.dart | 114 ++++ lib/services/statistics_service.dart | 199 ++++++ lib/services/time_tracking_service.dart | 182 ++++++ lib/theme/app_theme.dart | 119 ++++ lib/widgets/custom_time_picker_dialog.dart | 241 ++++++++ lib/widgets/empty_state_widget.dart | 149 +++++ lib/widgets/error_state_widget.dart | 141 +++++ pubspec.lock | 570 ++++++++++++++++++ pubspec.yaml | 43 ++ web/favicon.png | Bin 0 -> 917 bytes web/icons/Icon-192.png | Bin 0 -> 5292 bytes web/icons/Icon-512.png | Bin 0 -> 8252 bytes web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes web/index.html | 38 ++ web/manifest.json | 35 ++ 开发进度.md | 211 +++++++ 快速启动指南.md | 116 ++++ 真实数据测试指南.md | 357 +++++++++++ 54 files changed, 8412 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/autotime/tracker/MainActivity.kt create mode 100644 android/app/src/main/kotlin/com/autotime/tracker/TimeTrackingPlugin.kt create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/TimeTrackingPlugin.swift create mode 100644 lib/database/app_usage_dao.dart create mode 100644 lib/database/daily_stats_dao.dart create mode 100644 lib/database/database_helper.dart create mode 100644 lib/database/time_goal_dao.dart create mode 100644 lib/main.dart create mode 100644 lib/models/app_usage.dart create mode 100644 lib/models/daily_stats.dart create mode 100644 lib/models/time_goal.dart create mode 100644 lib/providers/background_sync_provider.dart create mode 100644 lib/providers/statistics_provider.dart create mode 100644 lib/providers/time_tracking_provider.dart create mode 100644 lib/screens/about_screen.dart create mode 100644 lib/screens/appearance_settings_screen.dart create mode 100644 lib/screens/category_management_screen.dart create mode 100644 lib/screens/data_privacy_screen.dart create mode 100644 lib/screens/export_data_screen.dart create mode 100644 lib/screens/goal_setting_screen.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/screens/notification_settings_screen.dart create mode 100644 lib/screens/permission_screen.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/screens/stats_screen.dart create mode 100644 lib/screens/today_screen.dart create mode 100644 lib/services/background_sync_service.dart create mode 100644 lib/services/category_service.dart create mode 100644 lib/services/export_service.dart create mode 100644 lib/services/mock_data_service.dart create mode 100644 lib/services/statistics_service.dart create mode 100644 lib/services/time_tracking_service.dart create mode 100644 lib/theme/app_theme.dart create mode 100644 lib/widgets/custom_time_picker_dialog.dart create mode 100644 lib/widgets/empty_state_widget.dart create mode 100644 lib/widgets/error_state_widget.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 web/favicon.png create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/icons/Icon-maskable-192.png create mode 100644 web/icons/Icon-maskable-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 开发进度.md create mode 100644 快速启动指南.md create mode 100644 真实数据测试指南.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..535b5fc --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..7007e3b --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..d350fc1 --- /dev/null +++ b/README.md @@ -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 基本完成,核心功能已实现,可以进行真实设备测试。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..045a785 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/autotime/tracker/MainActivity.kt b/android/app/src/main/kotlin/com/autotime/tracker/MainActivity.kt new file mode 100644 index 0000000..92dd10c --- /dev/null +++ b/android/app/src/main/kotlin/com/autotime/tracker/MainActivity.kt @@ -0,0 +1,8 @@ +package com.autotime.tracker + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { + // MainActivity 实现 +} + diff --git a/android/app/src/main/kotlin/com/autotime/tracker/TimeTrackingPlugin.kt b/android/app/src/main/kotlin/com/autotime/tracker/TimeTrackingPlugin.kt new file mode 100644 index 0000000..9e15a77 --- /dev/null +++ b/android/app/src/main/kotlin/com/autotime/tracker/TimeTrackingPlugin.kt @@ -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>()) + return + } + + // 转换为 Flutter 可用的格式 + val appUsageList = mutableListOf>() + + // 按包名聚合数据 + val packageMap = mutableMapOf() + 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) + } +} + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..f6b4eff --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -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) + } +} + diff --git a/ios/Runner/TimeTrackingPlugin.swift b/ios/Runner/TimeTrackingPlugin.swift new file mode 100644 index 0000000..c985bb6 --- /dev/null +++ b/ios/Runner/TimeTrackingPlugin.swift @@ -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) + } +} + diff --git a/lib/database/app_usage_dao.dart b/lib/database/app_usage_dao.dart new file mode 100644 index 0000000..5616477 --- /dev/null +++ b/lib/database/app_usage_dao.dart @@ -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 insertAppUsage(AppUsage usage) async { + final db = await _dbHelper.database; + return await db.insert('app_usage', usage.toMap()); + } + + // 批量插入 + Future batchInsertAppUsages(List 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> 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> 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> 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 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 deleteAppUsage(int id) async { + if (_isWeb) return; + + final db = await _dbHelper.database; + await db.delete( + 'app_usage', + where: 'id = ?', + whereArgs: [id], + ); + } + + // 删除指定日期之前的数据(用于数据清理) + Future 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], + ); + } +} + diff --git a/lib/database/daily_stats_dao.dart b/lib/database/daily_stats_dao.dart new file mode 100644 index 0000000..300c1a6 --- /dev/null +++ b/lib/database/daily_stats_dao.dart @@ -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 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 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 getTodayStats() async { + return await getDailyStats(DateTime.now()); + } + + // 获取指定日期范围的统计 + Future> 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> 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> 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 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 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 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 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')}'; + } +} + diff --git a/lib/database/database_helper.dart b/lib/database/database_helper.dart new file mode 100644 index 0000000..bfabf65 --- /dev/null +++ b/lib/database/database_helper.dart @@ -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 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 _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 _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 close() async { + final db = await database; + await db.close(); + } +} + diff --git a/lib/database/time_goal_dao.dart b/lib/database/time_goal_dao.dart new file mode 100644 index 0000000..d8d3270 --- /dev/null +++ b/lib/database/time_goal_dao.dart @@ -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 _webGoals = []; + + // Web 平台检查 + bool get _isWeb => kIsWeb; + + // 插入或更新时间目标 + Future 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> 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> getAllGoals() async { + if (_isWeb) { + return List.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 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 deleteGoal(int id) async { + final db = await _dbHelper.database; + await db.delete( + 'time_goal', + where: 'id = ?', + whereArgs: [id], + ); + } + + // 停用目标 + Future 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], + ); + } +} + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..9c415d2 --- /dev/null +++ b/lib/main.dart @@ -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('重试'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/models/app_usage.dart b/lib/models/app_usage.dart new file mode 100644 index 0000000..67f4ff2 --- /dev/null +++ b/lib/models/app_usage.dart @@ -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 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 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'; + } +} + diff --git a/lib/models/daily_stats.dart b/lib/models/daily_stats.dart new file mode 100644 index 0000000..1ea51a1 --- /dev/null +++ b/lib/models/daily_stats.dart @@ -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 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 + } + } +} + diff --git a/lib/models/time_goal.dart b/lib/models/time_goal.dart new file mode 100644 index 0000000..b8c74a3 --- /dev/null +++ b/lib/models/time_goal.dart @@ -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 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 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}分钟'; + } +} + diff --git a/lib/providers/background_sync_provider.dart b/lib/providers/background_sync_provider.dart new file mode 100644 index 0000000..51d0175 --- /dev/null +++ b/lib/providers/background_sync_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/background_sync_service.dart'; + +// BackgroundSyncService Provider +final backgroundSyncServiceProvider = Provider((ref) { + final service = BackgroundSyncService(); + + // 当 Provider 被销毁时,停止服务 + ref.onDispose(() { + service.stop(); + }); + + return service; +}); + +// 后台同步状态 Provider +final backgroundSyncStatusProvider = StateProvider((ref) { + return false; +}); + diff --git a/lib/providers/statistics_provider.dart b/lib/providers/statistics_provider.dart new file mode 100644 index 0000000..ab1740b --- /dev/null +++ b/lib/providers/statistics_provider.dart @@ -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((ref) { + return StatisticsService(); +}); + +// 今日统计 Provider +final todayStatsProvider = FutureProvider((ref) async { + final service = ref.read(statisticsServiceProvider); + return await service.getTodayStats(); +}); + +// 本周统计 Provider +final weekStatsProvider = FutureProvider>((ref) async { + final service = ref.read(statisticsServiceProvider); + return await service.getWeekStats(); +}); + +// 本月统计 Provider +final monthStatsProvider = FutureProvider>((ref) async { + final service = ref.read(statisticsServiceProvider); + return await service.getMonthStats(); +}); + +// 今日统计列表 Provider(用于日视图,只返回今日数据) +final todayStatsListProvider = FutureProvider>((ref) async { + final service = ref.read(statisticsServiceProvider); + final todayStats = await service.getTodayStats(); + return [todayStats]; +}); + +// 今日 Top 应用 Provider +final todayTopAppsProvider = FutureProvider>((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((ref, _) async { + final service = ref.read(statisticsServiceProvider); + return await service.refreshTodayStats(); +}); + diff --git a/lib/providers/time_tracking_provider.dart b/lib/providers/time_tracking_provider.dart new file mode 100644 index 0000000..ed00938 --- /dev/null +++ b/lib/providers/time_tracking_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/time_tracking_service.dart'; + +// TimeTrackingService Provider +final timeTrackingServiceProvider = Provider((ref) { + return TimeTrackingService(); +}); + +// 权限状态 Provider +final permissionStatusProvider = FutureProvider((ref) async { + final service = ref.read(timeTrackingServiceProvider); + return await service.hasPermission(); +}); + +// 后台追踪状态 Provider +final backgroundTrackingStatusProvider = FutureProvider((ref) async { + final service = ref.read(timeTrackingServiceProvider); + return await service.isBackgroundTrackingActive(); +}); + diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart new file mode 100644 index 0000000..5b6cc07 --- /dev/null +++ b/lib/screens/about_screen.dart @@ -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, + ), + ), + ); + } +} + diff --git a/lib/screens/appearance_settings_screen.dart b/lib/screens/appearance_settings_screen.dart new file mode 100644 index 0000000..1af9fcf --- /dev/null +++ b/lib/screens/appearance_settings_screen.dart @@ -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 createState() => _AppearanceSettingsScreenState(); +} + +class _AppearanceSettingsScreenState extends State { + ThemeMode _themeMode = ThemeMode.system; + double _fontSize = 1.0; // 1.0 = 正常,0.8 = 小,1.2 = 大 + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _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 _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( + title: const Text('跟随系统'), + subtitle: const Text('根据系统设置自动切换'), + value: ThemeMode.system, + groupValue: _themeMode, + onChanged: (value) { + if (value != null) { + setState(() { + _themeMode = value; + }); + _saveSettings(); + // 通知应用更新主题 + // 注意:这需要重启应用或使用 Provider 来管理主题 + } + }, + ), + RadioListTile( + title: const Text('浅色模式'), + subtitle: const Text('始终使用浅色主题'), + value: ThemeMode.light, + groupValue: _themeMode, + onChanged: (value) { + if (value != null) { + setState(() { + _themeMode = value; + }); + _saveSettings(); + } + }, + ), + RadioListTile( + 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 '大'; + } + } +} + diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart new file mode 100644 index 0000000..281f830 --- /dev/null +++ b/lib/screens/category_management_screen.dart @@ -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 createState() => _CategoryManagementScreenState(); +} + +class _CategoryManagementScreenState extends ConsumerState { + final CategoryService _categoryService = CategoryService(); + final AppUsageDao _appUsageDao = AppUsageDao(); + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + // 可用分类列表 + final List _availableCategories = ['work', 'study', 'entertainment', 'social', 'tool', 'other']; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future> _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 = {}; + 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>( + 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( + 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( + 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, + }); +} + diff --git a/lib/screens/data_privacy_screen.dart b/lib/screens/data_privacy_screen.dart new file mode 100644 index 0000000..6e6100b --- /dev/null +++ b/lib/screens/data_privacy_screen.dart @@ -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 createState() => _DataPrivacyScreenState(); +} + +class _DataPrivacyScreenState extends State { + 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 _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 _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; + }); + } + } + } +} + diff --git a/lib/screens/export_data_screen.dart b/lib/screens/export_data_screen.dart new file mode 100644 index 0000000..86c0ae4 --- /dev/null +++ b/lib/screens/export_data_screen.dart @@ -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 createState() => _ExportDataScreenState(); +} + +class _ExportDataScreenState extends State { + final ExportService _exportService = ExportService(); + DateTime _startDate = DateTime.now().subtract(const Duration(days: 7)); + DateTime _endDate = DateTime.now(); + bool _isExporting = false; + + Future _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 _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 _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 _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 '导出失败,请稍后重试'; + } +} + diff --git a/lib/screens/goal_setting_screen.dart b/lib/screens/goal_setting_screen.dart new file mode 100644 index 0000000..879b9c8 --- /dev/null +++ b/lib/screens/goal_setting_screen.dart @@ -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 createState() => _GoalSettingScreenState(); +} + +class _GoalSettingScreenState extends ConsumerState { + final TimeGoalDao _goalDao = TimeGoalDao(); + + TimeGoal? _dailyTotalGoal; + final Map _categoryGoals = {}; + + @override + void initState() { + super.initState(); + _loadGoals(); + } + + Future _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 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 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('保存'), + ), + ], + ); + } +} + diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..b504f64 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + int _currentIndex = 0; + + final List _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', + ), + ], + ), + ); + } +} + diff --git a/lib/screens/notification_settings_screen.dart b/lib/screens/notification_settings_screen.dart new file mode 100644 index 0000000..0009b12 --- /dev/null +++ b/lib/screens/notification_settings_screen.dart @@ -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 createState() => _NotificationSettingsScreenState(); +} + +class _NotificationSettingsScreenState extends State { + 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 _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 _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), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/screens/permission_screen.dart b/lib/screens/permission_screen.dart new file mode 100644 index 0000000..8448a29 --- /dev/null +++ b/lib/screens/permission_screen.dart @@ -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), + ), + ), + ], + ), + ); + } +} + diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..81ccdfb --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -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, + ), + ), + ), + ), + ], + ), + ); + } +} + diff --git a/lib/screens/stats_screen.dart b/lib/screens/stats_screen.dart new file mode 100644 index 0000000..a3cc7d8 --- /dev/null +++ b/lib/screens/stats_screen.dart @@ -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 createState() => _StatsScreenState(); +} + +class _StatsScreenState extends ConsumerState { + String _selectedPeriod = '周'; // 日、周、月 + + // 根据时间段获取图表数据 + List> _getChartData(List? 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( + segments: const [ + ButtonSegment(value: '日', label: Text('日')), + ButtonSegment(value: '周', label: Text('周')), + ButtonSegment(value: '月', label: Text('月')), + ], + selected: {_selectedPeriod}, + onSelectionChanged: (Set 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> 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> 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 '加载失败,请稍后重试'; + } +} + diff --git a/lib/screens/today_screen.dart b/lib/screens/today_screen.dart new file mode 100644 index 0000000..ebd437c --- /dev/null +++ b/lib/screens/today_screen.dart @@ -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> 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> 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, + ), + ), + ], + ), + ); + } +} + diff --git a/lib/services/background_sync_service.dart b/lib/services/background_sync_service.dart new file mode 100644 index 0000000..6ca4e94 --- /dev/null +++ b/lib/services/background_sync_service.dart @@ -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 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 stop() async { + _isRunning = false; + _syncTimer?.cancel(); + _syncTimer = null; + + await _timeTrackingService.stopBackgroundTracking(); + } + + /// 立即同步 + Future 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; +} + diff --git a/lib/services/category_service.dart b/lib/services/category_service.dart new file mode 100644 index 0000000..fe160ee --- /dev/null +++ b/lib/services/category_service.dart @@ -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 _webCustomCategories = {}; + + // 预设分类规则 + static const Map 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 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 _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 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 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> getAllCustomCategories() async { + // Web 平台使用内存存储 + if (kIsWeb) { + return Map.from(_webCustomCategories); + } + + final db = await _dbHelper.database; + final maps = await db.query( + 'app_category', + where: 'is_custom = 1', + ); + + final result = {}; + for (final map in maps) { + result[map['package_name'] as String] = map['category'] as String; + } + return result; + } + + // 批量设置分类 + Future batchSetCategories(Map 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); + } +} + diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart new file mode 100644 index 0000000..9399fb8 --- /dev/null +++ b/lib/services/export_service.dart @@ -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 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 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 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'; + } +} + diff --git a/lib/services/mock_data_service.dart b/lib/services/mock_data_service.dart new file mode 100644 index 0000000..a6d6b8b --- /dev/null +++ b/lib/services/mock_data_service.dart @@ -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 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 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; + } +} + diff --git a/lib/services/statistics_service.dart b/lib/services/statistics_service.dart new file mode 100644 index 0000000..a325f4f --- /dev/null +++ b/lib/services/statistics_service.dart @@ -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 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 _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 = {}; + 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 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> 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 = []; + 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> getMonthStats() async { + return await _dailyStatsDao.getMonthStats(); + } + + // 刷新今日统计(重新计算) + Future 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> 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, + ); + } +} + diff --git a/lib/services/time_tracking_service.dart b/lib/services/time_tracking_service.dart new file mode 100644 index 0000000..9f0bb97 --- /dev/null +++ b/lib/services/time_tracking_service.dart @@ -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 hasPermission() async { + try { + final result = await _channel.invokeMethod('hasPermission'); + return result ?? false; + } catch (e) { + print('Error checking permission: $e'); + return false; + } + } + + /// 请求权限 + Future requestPermission() async { + try { + final result = await _channel.invokeMethod('requestPermission'); + return result ?? false; + } catch (e) { + print('Error requesting permission: $e'); + return false; + } + } + + /// 获取应用使用数据(从系统 API) + Future> getAppUsageFromSystem({ + required DateTime startTime, + required DateTime endTime, + }) async { + try { + final result = await _channel.invokeMethod>('getAppUsage', { + 'startTime': startTime.millisecondsSinceEpoch, + 'endTime': endTime.millisecondsSinceEpoch, + }); + + if (result == null) return []; + + return result + .map((item) => AppUsageData.fromMap(Map.from(item))) + .toList(); + } catch (e) { + print('Error getting app usage from system: $e'); + return []; + } + } + + /// 同步应用使用数据到数据库 + Future 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 = []; + 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 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 startBackgroundTracking() async { + try { + await _channel.invokeMethod('startBackgroundTracking'); + } catch (e) { + print('Error starting background tracking: $e'); + } + } + + /// 停止后台追踪 + Future stopBackgroundTracking() async { + try { + await _channel.invokeMethod('stopBackgroundTracking'); + } catch (e) { + print('Error stopping background tracking: $e'); + } + } + + /// 检查后台追踪状态 + Future isBackgroundTrackingActive() async { + try { + final result = await _channel.invokeMethod('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 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?, + ); + } +} + diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..5562e1d --- /dev/null +++ b/lib/theme/app_theme.dart @@ -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 '其他'; + } + } +} + diff --git a/lib/widgets/custom_time_picker_dialog.dart b/lib/widgets/custom_time_picker_dialog.dart new file mode 100644 index 0000000..0bb466f --- /dev/null +++ b/lib/widgets/custom_time_picker_dialog.dart @@ -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 createState() => _CustomTimePickerDialogState(); + + /// 显示时间选择器对话框 + static Future show({ + required BuildContext context, + required String title, + required TimeOfDay initialTime, + }) async { + return await showDialog( + context: context, + builder: (context) => CustomTimePickerDialog( + title: title, + initialTime: initialTime, + ), + ); + } +} + +class _CustomTimePickerDialogState extends State { + 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, + ), + ), + ); + } +} + diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart new file mode 100644 index 0000000..adbc813 --- /dev/null +++ b/lib/widgets/empty_state_widget.dart @@ -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, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + diff --git a/lib/widgets/error_state_widget.dart b/lib/widgets/error_state_widget.dart new file mode 100644 index 0000000..1250c2f --- /dev/null +++ b/lib/widgets/error_state_widget.dart @@ -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, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..cb27583 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..59df15d --- /dev/null +++ b/pubspec.yaml @@ -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/ + diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 zcmd5=2T+s!lYZ%-(h(2@5fr2dC?F^$C=i-}R6$UX8af(!je;W5yC_|HmujSgN*6?W z3knF*TL1$|?oD*=zPbBVex*RUIKsL<(&Rj9%^UD2IK3W?2j>D?eWQgvS-HLymHo9%~|N2Q{~j za?*X-{b9JRowv_*Mh|;*-kPFn>PI;r<#kFaxFqbn?aq|PduQg=2Q;~Qc}#z)_T%x9 zE|0!a70`58wjREmAH38H1)#gof)U3g9FZ^ zF7&-0^Hy{4XHWLoC*hOG(dg~2g6&?-wqcpf{ z&3=o8vw7lMi22jCG9RQbv8H}`+}9^zSk`nlR8?Z&G2dlDy$4#+WOlg;VHqzuE=fM@ z?OI6HEJH4&tA?FVG}9>jAnq_^tlw8NbjNhfqk2rQr?h(F&WiKy03Sn=-;ZJRh~JrD zbt)zLbnabttEZ>zUiu`N*u4sfQaLE8-WDn@tHp50uD(^r-}UsUUu)`!Rl1PozAc!a z?uj|2QDQ%oV-jxUJmJycySBINSKdX{kDYRS=+`HgR2GO19fg&lZKyBFbbXhQV~v~L za^U944F1_GtuFXtvDdDNDvp<`fqy);>Vw=ncy!NB85Tw{&sT5&Ox%-p%8fTS;OzlRBwErvO+ROe?{%q-Zge=%Up|D4L#>4K@Ke=x%?*^_^P*KD zgXueMiS63!sEw@fNLB-i^F|@Oib+S4bcy{eu&e}Xvb^(mA!=U=Xr3||IpV~3K zQWzEsUeX_qBe6fky#M zzOJm5b+l;~>=sdp%i}}0h zO?B?i*W;Ndn02Y0GUUPxERG`3Bjtj!NroLoYtyVdLtl?SE*CYpf4|_${ku2s`*_)k zN=a}V8_2R5QANlxsq!1BkT6$4>9=-Ix4As@FSS;1q^#TXPrBsw>hJ}$jZ{kUHoP+H zvoYiR39gX}2OHIBYCa~6ERRPJ#V}RIIZakUmuIoLF*{sO8rAUEB9|+A#C|@kw5>u0 zBd=F!4I)Be8ycH*)X1-VPiZ+Ts8_GB;YW&ZFFUo|Sw|x~ZajLsp+_3gv((Q#N>?Jz zFBf`~p_#^${zhPIIJY~yo!7$-xi2LK%3&RkFg}Ax)3+dFCjGgKv^1;lUzQlPo^E{K zmCnrwJ)NuSaJEmueEPO@(_6h3f5mFffhkU9r8A8(JC5eOkux{gPmx_$Uv&|hyj)gN zd>JP8l2U&81@1Hc>#*su2xd{)T`Yw< zN$dSLUN}dfx)Fu`NcY}TuZ)SdviT{JHaiYgP4~@`x{&h*Hd>c3K_To9BnQi@;tuoL z%PYQo&{|IsM)_>BrF1oB~+`2_uZQ48z9!)mtUR zdfKE+b*w8cPu;F6RYJiYyV;PRBbThqHBEu_(U{(gGtjM}Zi$pL8Whx}<JwE3RM0F8x7%!!s)UJVq|TVd#hf1zVLya$;mYp(^oZQ2>=ZXU1c$}f zm|7kfk>=4KoQoQ!2&SOW5|JP1)%#55C$M(u4%SP~tHa&M+=;YsW=v(Old9L3(j)`u z2?#fK&1vtS?G6aOt@E`gZ9*qCmyvc>Ma@Q8^I4y~f3gs7*d=ATlP>1S zyF=k&6p2;7dn^8?+!wZO5r~B+;@KXFEn^&C=6ma1J7Au6y29iMIxd7#iW%=iUzq&C=$aPLa^Q zncia$@TIy6UT@69=nbty5epP>*fVW@5qbUcb2~Gg75dNd{COFLdiz3}kODn^U*=@E z0*$7u7Rl2u)=%fk4m8EK1ctR!6%Ve`e!O20L$0LkM#f+)n9h^dn{n`T*^~d+l*Qlx z$;JC0P9+en2Wlxjwq#z^a6pdnD6fJM!GV7_%8%c)kc5LZs_G^qvw)&J#6WSp< zmsd~1-(GrgjC56Pdf6#!dt^y8Rg}!#UXf)W%~PeU+kU`FeSZHk)%sFv++#Dujk-~m zFHvVJC}UBn2jN& zs!@nZ?e(iyZPNo`p1i#~wsv9l@#Z|ag3JR>0#u1iW9M1RK1iF6-RbJ4KYg?B`dET9 zyR~DjZ>%_vWYm*Z9_+^~hJ_|SNTzBKx=U0l9 z9x(J96b{`R)UVQ$I`wTJ@$_}`)_DyUNOso6=WOmQKI1e`oyYy1C&%AQU<0-`(ow)1 zT}gYdwWdm4wW6|K)LcfMe&psE0XGhMy&xS`@vLi|1#Za{D6l@#D!?nW87wcscUZgELT{Cz**^;Zb~7 z(~WFRO`~!WvyZAW-8v!6n&j*PLm9NlN}BuUN}@E^TX*4Or#dMMF?V9KBeLSiLO4?B zcE3WNIa-H{ThrlCoN=XjOGk1dT=xwwrmt<1a)mrRzg{35`@C!T?&_;Q4Ce=5=>z^*zE_c(0*vWo2_#TD<2)pLXV$FlwP}Ik74IdDQU@yhkCr5h zn5aa>B7PWy5NQ!vf7@p_qtC*{dZ8zLS;JetPkHi>IvPjtJ#ThGQD|Lq#@vE2xdl%`x4A8xOln}BiQ92Po zW;0%A?I5CQ_O`@Ad=`2BLPPbBuPUp@Hb%a_OOI}y{Rwa<#h z5^6M}s7VzE)2&I*33pA>e71d78QpF>sNK;?lj^Kl#wU7G++`N_oL4QPd-iPqBhhs| z(uVM}$ItF-onXuuXO}o$t)emBO3Hjfyil@*+GF;9j?`&67GBM;TGkLHi>@)rkS4Nj zAEk;u)`jc4C$qN6WV2dVd#q}2X6nKt&X*}I@jP%Srs%%DS92lpDY^K*Sx4`l;aql$ zt*-V{U&$DM>pdO?%jt$t=vg5|p+Rw?SPaLW zB6nvZ69$ne4Z(s$3=Rf&RX8L9PWMV*S0@R zuIk&ba#s6sxVZ51^4Kon46X^9`?DC9mEhWB3f+o4#2EXFqy0(UTc>GU| zGCJmI|Dn-dX#7|_6(fT)>&YQ0H&&JX3cTvAq(a@ydM4>5Njnuere{J8p;3?1az60* z$1E7Yyxt^ytULeokgDnRVKQw9vzHg1>X@@jM$n$HBlveIrKP5-GJq%iWH#odVwV6cF^kKX(@#%%uQVb>#T6L^mC@)%SMd4DF? zVky!~ge27>cpUP1Vi}Z32lbLV+CQy+T5Wdmva6Fg^lKb!zrg|HPU=5Qu}k;4GVH+x z%;&pN1LOce0w@9i1Mo-Y|7|z}fbch@BPp2{&R-5{GLoeu8@limQmFF zaJRR|^;kW_nw~0V^ zfTnR!Ni*;-%oSHG1yItARs~uxra|O?YJxBzLjpeE-=~TO3Dn`JL5Gz;F~O1u3|FE- zvK2Vve`ylc`a}G`gpHg58Cqc9fMoy1L}7x7T>%~b&irrNMo?np3`q;d3d;zTK>nrK zOjPS{@&74-fA7j)8uT9~*g23uGnxwIVj9HorzUX#s0pcp2?GH6i}~+kv9fWChtPa_ z@T3m+$0pbjdQw7jcnHn;Pi85hk_u2-1^}c)LNvjdam8K-XJ+KgKQ%!?2n_!#{$H|| zLO=%;hRo6EDmnOBKCL9Cg~ETU##@u^W_5joZ%Et%X_n##%JDOcsO=0VL|Lkk!VdRJ z^|~2pB@PUspT?NOeO?=0Vb+fAGc!j%Ufn-cB`s2A~W{Zj{`wqWq_-w0wr@6VrM zbzni@8c>WS!7c&|ZR$cQ;`niRw{4kG#e z70e!uX8VmP23SuJ*)#(&R=;SxGAvq|&>geL&!5Z7@0Z(No*W561n#u$Uc`f9pD70# z=sKOSK|bF~#khTTn)B28h^a1{;>EaRnHj~>i=Fnr3+Fa4 z`^+O5_itS#7kPd20rq66_wH`%?HNzWk@XFK0n;Z@Cx{kx==2L22zWH$Yg?7 zvDj|u{{+NR3JvUH({;b*$b(U5U z7(lF!1bz2%06+|-v(D?2KgwNw7( zJB#Tz+ZRi&U$i?f34m7>uTzO#+E5cbaiQ&L}UxyOQq~afbNB4EI{E04ZWg53w0A{O%qo=lF8d zf~ktGvIgf-a~zQoWf>loF7pOodrd0a2|BzwwPDV}ShauTK8*fmF6NRbO>Iw9zZU}u zw8Ya}?seBnEGQDmH#XpUUkj}N49tP<2jYwTFp!P+&Fd(%Z#yo80|5@zN(D{_pNow*&4%ql zW~&yp@scb-+Qj-EmErY+Tu=dUmf@*BoXY2&oKT8U?8?s1d}4a`Aq>7SV800m$FE~? zjmz(LY+Xx9sDX$;vU`xgw*jLw7dWOnWWCO8o|;}f>cu0Q&`0I{YudMn;P;L3R-uz# zfns_mZED_IakFBPP2r_S8XM$X)@O-xVKi4`7373Jkd5{2$M#%cRhWer3M(vr{S6>h zj{givZJ3(`yFL@``(afn&~iNx@B1|-qfYiZu?-_&Z8+R~v`d6R-}EX9IVXWO-!hL5 z*k6T#^2zAXdardU3Ao~I)4DGdAv2bx{4nOK`20rJo>rmk3S2ZDu}))8Z1m}CKigf0 z3L`3Y`{huj`xj9@`$xTZzZc3je?n^yG<8sw$`Y%}9mUsjUR%T!?k^(q)6FH6Af^b6 zlPg~IEwg0y;`t9y;#D+uz!oE4VP&Je!<#q*F?m5L5?J3i@!0J6q#eu z!RRU`-)HeqGi_UJZ(n~|PSNsv+Wgl{P-TvaUQ9j?ZCtvb^37U$sFpBrkT{7Jpd?HpIvj2!}RIq zH{9~+gErN2+}J`>Jvng2hwM`=PLNkc7pkjblKW|+Fk9rc)G1R>Ww>RC=r-|!m-u7( zc(a$9NG}w#PjWNMS~)o=i~WA&4L(YIW25@AL9+H9!?3Y}sv#MOdY{bb9j>p`{?O(P zIvb`n?_(gP2w3P#&91JX*md+bBEr%xUHMVqfB;(f?OPtMnAZ#rm5q5mh;a2f_si2_ z3oXWB?{NF(JtkAn6F(O{z@b76OIqMC$&oJ_&S|YbFJ*)3qVX_uNf5b8(!vGX19hsG z(OP>RmZp29KH9Ge2kKjKigUmOe^K_!UXP`von)PR8Qz$%=EmOB9xS(ZxE_tnyzo}7 z=6~$~9k0M~v}`w={AeqF?_)9q{m8K#6M{a&(;u;O41j)I$^T?lx5(zlebpY@NT&#N zR+1bB)-1-xj}R8uwqwf=iP1GbxBjneCC%UrSdSxK1vM^i9;bUkS#iRZw2H>rS<2<$ zNT3|sDH>{tXb=zq7XZi*K?#Zsa1h1{h5!Tq_YbKFm_*=A5-<~j63he;4`77!|LBlo zR^~tR3yxcU=gDFbshyF6>o0bdp$qmHS7D}m3;^QZq9kBBU|9$N-~oU?G5;jyFR7>z hN`IR97YZXIo@y!QgFWddJ3|0`sjFx!m))><{BI=FK%f8s literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0bbcbcb --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + autotime_tracker + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..43f207b --- /dev/null +++ b/web/manifest.json @@ -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" + } + ] +} diff --git a/开发进度.md b/开发进度.md new file mode 100644 index 0000000..d99f239 --- /dev/null +++ b/开发进度.md @@ -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 + diff --git a/快速启动指南.md b/快速启动指南.md new file mode 100644 index 0000000..5879401 --- /dev/null +++ b/快速启动指南.md @@ -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 +``` + +#### 启动 Android 模拟器(无需 Android Studio) + +```bash +# 1. 查看可用模拟器 +flutter emulators + +# 2. 启动模拟器 +flutter emulators --launch + +# 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 +``` + +### 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) - 真实设备测试说明 + diff --git a/真实数据测试指南.md b/真实数据测试指南.md new file mode 100644 index 0000000..3683d54 --- /dev/null +++ b/真实数据测试指南.md @@ -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 + +# 或者直接运行(会自动选择设备) +flutter run +``` + +#### iOS 设备(Mac 用户) + +```bash +# 1. 在 iPhone 上:设置 → 通用 → VPN与设备管理 → 信任电脑 + +# 2. 连接 iPhone 到 Mac + +# 3. 运行应用 +flutter run -d +``` + +**优点:** +- ✅ 实时调试 +- ✅ 支持热重载 +- ✅ 可以看到日志输出 +- ✅ 无需手动安装 + +--- + +### 方式 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 连接运行是最简单的方式。** 📱 +