first commit
This commit is contained in:
293
lib/screens/about_screen.dart
Normal file
293
lib/screens/about_screen.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('关于'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 应用图标和名称
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.timer,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'AutoTime Tracker',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'版本 1.0.0',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 应用描述
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'关于应用',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'AutoTime Tracker 是一款自动时间追踪与效率分析工具。'
|
||||
'它可以帮助您自动追踪应用使用情况,分析时间分配,'
|
||||
'并提供效率评分和个性化建议。',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 功能特点
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'核心功能',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFeatureItem(theme, Icons.auto_awesome, '自动追踪', '无需手动操作,自动记录应用使用时间'),
|
||||
_buildFeatureItem(theme, Icons.category, '智能分类', '自动将应用分类为工作、学习、娱乐等'),
|
||||
_buildFeatureItem(theme, Icons.insights, '数据分析', '提供详细的统计分析和效率评分'),
|
||||
_buildFeatureItem(theme, Icons.flag, '目标设定', '设置时间目标,追踪完成情况'),
|
||||
_buildFeatureItem(theme, Icons.file_download, '数据导出', '导出 CSV 和统计报告'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 链接
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLinkItem(
|
||||
context,
|
||||
theme,
|
||||
Icons.description,
|
||||
'隐私政策',
|
||||
'查看我们的隐私政策',
|
||||
() {
|
||||
// TODO: 打开隐私政策页面
|
||||
_showComingSoon(context);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_buildLinkItem(
|
||||
context,
|
||||
theme,
|
||||
Icons.feedback,
|
||||
'反馈建议',
|
||||
'帮助我们改进应用',
|
||||
() {
|
||||
_launchEmail(context);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_buildLinkItem(
|
||||
context,
|
||||
theme,
|
||||
Icons.star,
|
||||
'评价应用',
|
||||
'在应用商店给我们评分',
|
||||
() {
|
||||
// TODO: 打开应用商店
|
||||
_showComingSoon(context);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_buildLinkItem(
|
||||
context,
|
||||
theme,
|
||||
Icons.code,
|
||||
'开源许可',
|
||||
'MIT License',
|
||||
() {
|
||||
_showLicense(context, theme);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 版权信息
|
||||
Text(
|
||||
'© 2024 AutoTime Tracker',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Made with ❤️ using Flutter',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureItem(ThemeData theme, IconData icon, String title, String description) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLinkItem(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
IconData icon,
|
||||
String title,
|
||||
String subtitle,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: AppTheme.primaryColor),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void _launchEmail(BuildContext context) {
|
||||
// 显示反馈邮箱
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('反馈建议'),
|
||||
content: const Text(
|
||||
'欢迎通过以下方式联系我们:\n\n'
|
||||
'邮箱:support@autotime-tracker.com\n\n'
|
||||
'我们非常重视您的反馈和建议!',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showComingSoon(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('即将推出'),
|
||||
content: const Text('此功能正在开发中,敬请期待!'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLicense(BuildContext context, ThemeData theme) {
|
||||
showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'AutoTime Tracker',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationIcon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.timer,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
263
lib/screens/appearance_settings_screen.dart
Normal file
263
lib/screens/appearance_settings_screen.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class AppearanceSettingsScreen extends StatefulWidget {
|
||||
const AppearanceSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
|
||||
}
|
||||
|
||||
class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
double _fontSize = 1.0; // 1.0 = 正常,0.8 = 小,1.2 = 大
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final themeModeIndex = prefs.getInt('theme_mode') ?? 0;
|
||||
setState(() {
|
||||
_themeMode = ThemeMode.values[themeModeIndex];
|
||||
_fontSize = prefs.getDouble('font_size') ?? 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('theme_mode', _themeMode.index);
|
||||
await prefs.setDouble('font_size', _fontSize);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('外观设置'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 主题模式
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.palette, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'主题模式',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('跟随系统'),
|
||||
subtitle: const Text('根据系统设置自动切换'),
|
||||
value: ThemeMode.system,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_themeMode = value;
|
||||
});
|
||||
_saveSettings();
|
||||
// 通知应用更新主题
|
||||
// 注意:这需要重启应用或使用 Provider 来管理主题
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('浅色模式'),
|
||||
subtitle: const Text('始终使用浅色主题'),
|
||||
value: ThemeMode.light,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_themeMode = value;
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('深色模式'),
|
||||
subtitle: const Text('始终使用深色主题'),
|
||||
value: ThemeMode.dark,
|
||||
groupValue: _themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_themeMode = value;
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 字体大小
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.text_fields, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'字体大小',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'当前大小: ${_getFontSizeLabel(_fontSize)}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Slider(
|
||||
value: _fontSize,
|
||||
min: 0.8,
|
||||
max: 1.2,
|
||||
divisions: 4,
|
||||
label: _getFontSizeLabel(_fontSize),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_fontSize = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'小',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 12 * _fontSize,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'正常',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14 * _fontSize,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'大',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 16 * _fontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 图表样式(占位,未来功能)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bar_chart, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'图表样式',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('图表颜色主题'),
|
||||
subtitle: const Text('默认主题'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('此功能正在开发中'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 说明
|
||||
Card(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppTheme.infoColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'主题模式更改需要重启应用才能生效。字体大小更改会立即生效。',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFontSizeLabel(double size) {
|
||||
if (size <= 0.9) {
|
||||
return '小';
|
||||
} else if (size <= 1.1) {
|
||||
return '正常';
|
||||
} else {
|
||||
return '大';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
290
lib/screens/category_management_screen.dart
Normal file
290
lib/screens/category_management_screen.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/category_service.dart';
|
||||
import '../database/app_usage_dao.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/error_state_widget.dart';
|
||||
|
||||
class CategoryManagementScreen extends ConsumerStatefulWidget {
|
||||
const CategoryManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryManagementScreen> createState() => _CategoryManagementScreenState();
|
||||
}
|
||||
|
||||
class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScreen> {
|
||||
final CategoryService _categoryService = CategoryService();
|
||||
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
// 可用分类列表
|
||||
final List<String> _availableCategories = ['work', 'study', 'entertainment', 'social', 'tool', 'other'];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<AppCategoryItem>> _loadAppCategories() async {
|
||||
// 获取所有应用使用记录(最近使用的)
|
||||
// 如果数据库为空,返回空列表(Web 平台会返回空列表)
|
||||
final now = DateTime.now();
|
||||
final startOfWeek = now.subtract(const Duration(days: 7));
|
||||
final appUsages = await _appUsageDao.getAppUsages(
|
||||
startTime: startOfWeek,
|
||||
endTime: now,
|
||||
);
|
||||
|
||||
// 如果没有数据,返回空列表
|
||||
if (appUsages.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取自定义分类
|
||||
final customCategories = await _categoryService.getAllCustomCategories();
|
||||
|
||||
// 去重并创建列表
|
||||
final packageMap = <String, AppCategoryItem>{};
|
||||
for (final usage in appUsages) {
|
||||
if (!packageMap.containsKey(usage.packageName)) {
|
||||
final currentCategory = customCategories[usage.packageName] ??
|
||||
CategoryService.defaultCategories[usage.packageName] ??
|
||||
'other';
|
||||
|
||||
packageMap[usage.packageName] = AppCategoryItem(
|
||||
packageName: usage.packageName,
|
||||
appName: usage.appName,
|
||||
category: currentCategory,
|
||||
isCustom: customCategories.containsKey(usage.packageName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return packageMap.values.toList()
|
||||
..sort((a, b) => a.appName.compareTo(b.appName));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('应用分类'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 搜索框
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索应用...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 应用列表
|
||||
Expanded(
|
||||
child: FutureBuilder<List<AppCategoryItem>>(
|
||||
future: _loadAppCategories(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return ErrorStateWidget.dataLoad(
|
||||
message: _getErrorMessage(snapshot.error!),
|
||||
onRetry: () {
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final items = snapshot.data ?? [];
|
||||
final filteredItems = _searchQuery.isEmpty
|
||||
? items
|
||||
: items.where((item) {
|
||||
return item.appName.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
item.packageName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filteredItems.isEmpty) {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return EmptyStateWidget.noApps(
|
||||
onAction: () {
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return EmptyStateWidget.noSearchResults(query: _searchQuery);
|
||||
}
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = filteredItems[index];
|
||||
return _buildAppCategoryItem(context, theme, item);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppCategoryItem(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
AppCategoryItem item,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(item.category).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.phone_android,
|
||||
color: AppTheme.getCategoryColor(item.category),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.appName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
item.packageName,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(item.category).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
AppTheme.getCategoryName(item.category),
|
||||
style: TextStyle(
|
||||
color: AppTheme.getCategoryColor(item.category),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
onSelected: (String category) async {
|
||||
await _categoryService.setCategory(item.packageName, category);
|
||||
setState(() {});
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将 ${item.appName} 分类为 ${AppTheme.getCategoryName(category)}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) {
|
||||
return _availableCategories.map((category) {
|
||||
return PopupMenuItem<String>(
|
||||
value: category,
|
||||
child: Row(
|
||||
children: [
|
||||
if (item.category == category)
|
||||
const Icon(Icons.check, size: 20, color: AppTheme.primaryColor)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(category),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppTheme.getCategoryName(category)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getErrorMessage(Object error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||
return '需要授予应用使用权限';
|
||||
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||
return '网络连接失败,请检查网络';
|
||||
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||
return '数据库操作失败';
|
||||
}
|
||||
return '加载失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
class AppCategoryItem {
|
||||
final String packageName;
|
||||
final String appName;
|
||||
final String category;
|
||||
final bool isCustom;
|
||||
|
||||
AppCategoryItem({
|
||||
required this.packageName,
|
||||
required this.appName,
|
||||
required this.category,
|
||||
required this.isCustom,
|
||||
});
|
||||
}
|
||||
|
||||
414
lib/screens/data_privacy_screen.dart
Normal file
414
lib/screens/data_privacy_screen.dart
Normal file
@@ -0,0 +1,414 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../theme/app_theme.dart';
|
||||
import '../database/app_usage_dao.dart';
|
||||
import '../database/daily_stats_dao.dart';
|
||||
|
||||
class DataPrivacyScreen extends StatefulWidget {
|
||||
const DataPrivacyScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DataPrivacyScreen> createState() => _DataPrivacyScreenState();
|
||||
}
|
||||
|
||||
class _DataPrivacyScreenState extends State<DataPrivacyScreen> {
|
||||
final AppUsageDao _appUsageDao = AppUsageDao();
|
||||
final DailyStatsDao _dailyStatsDao = DailyStatsDao();
|
||||
bool _isDeleting = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('数据与隐私'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 隐私说明
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.security, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'隐私保护',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPrivacyItem(
|
||||
theme,
|
||||
Icons.storage,
|
||||
'本地存储',
|
||||
'所有数据仅存储在您的设备本地,不会上传到任何服务器。',
|
||||
),
|
||||
_buildPrivacyItem(
|
||||
theme,
|
||||
Icons.lock,
|
||||
'数据加密',
|
||||
'敏感数据在存储时进行加密处理,确保数据安全。',
|
||||
),
|
||||
_buildPrivacyItem(
|
||||
theme,
|
||||
Icons.visibility_off,
|
||||
'隐私保护',
|
||||
'我们不会收集您的个人信息,也不会追踪您的具体操作内容。',
|
||||
),
|
||||
_buildPrivacyItem(
|
||||
theme,
|
||||
Icons.delete_forever,
|
||||
'完全控制',
|
||||
'您可以随时删除所有数据,完全掌控您的隐私。',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 数据管理
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'数据管理',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildDataAction(
|
||||
context,
|
||||
theme,
|
||||
Icons.delete_outline,
|
||||
'删除旧数据',
|
||||
'删除 30 天前的数据',
|
||||
Colors.orange,
|
||||
() => _showDeleteOldDataDialog(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDataAction(
|
||||
context,
|
||||
theme,
|
||||
Icons.delete_forever,
|
||||
'清空所有数据',
|
||||
'删除所有使用记录和统计数据',
|
||||
Colors.red,
|
||||
() => _showDeleteAllDataDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 数据使用说明
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'数据使用说明',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'• 应用使用数据仅用于统计和分析\n'
|
||||
'• 数据不会离开您的设备\n'
|
||||
'• 不会与第三方分享任何数据\n'
|
||||
'• 不会用于广告或营销目的\n'
|
||||
'• 您可以随时导出或删除数据',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrivacyItem(ThemeData theme, IconData icon, String title, String description) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDataAction(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
IconData icon,
|
||||
String title,
|
||||
String subtitle,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: _isDeleting ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isDeleting)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteOldDataDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('删除旧数据'),
|
||||
content: const Text('确定要删除 30 天前的所有数据吗?此操作不可恢复。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _deleteOldData(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteAllDataDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('清空所有数据'),
|
||||
content: const Text(
|
||||
'确定要删除所有使用记录和统计数据吗?\n\n'
|
||||
'此操作将:\n'
|
||||
'• 删除所有应用使用记录\n'
|
||||
'• 删除所有统计数据\n'
|
||||
'• 删除所有分类设置\n'
|
||||
'• 删除所有目标设置\n\n'
|
||||
'此操作不可恢复!',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _deleteAllData(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('确认删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteOldData(BuildContext context) async {
|
||||
if (kIsWeb) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Web 平台不支持数据删除功能'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isDeleting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30));
|
||||
|
||||
await _appUsageDao.deleteBeforeDate(thirtyDaysAgo);
|
||||
await _dailyStatsDao.deleteBeforeDate(thirtyDaysAgo);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已删除 30 天前的数据'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('删除失败: $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDeleting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAllData(BuildContext context) async {
|
||||
if (kIsWeb) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Web 平台不支持数据删除功能'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isDeleting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 删除所有应用使用记录
|
||||
final allUsages = await _appUsageDao.getAppUsages(
|
||||
startTime: DateTime(2000),
|
||||
endTime: DateTime.now(),
|
||||
);
|
||||
for (final usage in allUsages) {
|
||||
if (usage.id != null) {
|
||||
await _appUsageDao.deleteAppUsage(usage.id!);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除所有统计数据
|
||||
final allStats = await _dailyStatsDao.getStatsRange(
|
||||
startDate: DateTime(2000),
|
||||
endDate: DateTime.now(),
|
||||
);
|
||||
for (final stat in allStats) {
|
||||
if (stat.id != null) {
|
||||
await _dailyStatsDao.deleteDailyStats(stat.id!);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已清空所有数据'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('删除失败: $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDeleting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
403
lib/screens/export_data_screen.dart
Normal file
403
lib/screens/export_data_screen.dart
Normal file
@@ -0,0 +1,403 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/export_service.dart';
|
||||
|
||||
// Web 平台需要的导入
|
||||
import 'dart:html' as html show Blob, Url, AnchorElement;
|
||||
|
||||
class ExportDataScreen extends StatefulWidget {
|
||||
const ExportDataScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ExportDataScreen> createState() => _ExportDataScreenState();
|
||||
}
|
||||
|
||||
class _ExportDataScreenState extends State<ExportDataScreen> {
|
||||
final ExportService _exportService = ExportService();
|
||||
DateTime _startDate = DateTime.now().subtract(const Duration(days: 7));
|
||||
DateTime _endDate = DateTime.now();
|
||||
bool _isExporting = false;
|
||||
|
||||
Future<void> _exportCSV() async {
|
||||
setState(() {
|
||||
_isExporting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final csvData = await _exportService.exportToCSV(
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
);
|
||||
|
||||
if (csvData.isEmpty || csvData.trim().isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('所选日期范围内没有数据可导出'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
// Web 平台:下载文件
|
||||
final blob = html.Blob([csvData], 'text/csv');
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', 'autotime_export_${DateTime.now().millisecondsSinceEpoch}.csv')
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
} else {
|
||||
// 移动端:复制到剪贴板
|
||||
await Clipboard.setData(ClipboardData(text: csvData));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(kIsWeb ? '文件已下载' : '数据已复制到剪贴板'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_getExportErrorMessage(e)),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
action: SnackBarAction(
|
||||
label: '重试',
|
||||
textColor: Colors.white,
|
||||
onPressed: _exportReport,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isExporting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportReport() async {
|
||||
setState(() {
|
||||
_isExporting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final report = await _exportService.exportStatsReport(
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
);
|
||||
|
||||
if (report.isEmpty || report.trim().isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('所选日期范围内没有统计数据可导出'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
// Web 平台:下载文件
|
||||
final blob = html.Blob([report], 'text/plain');
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', 'autotime_report_${DateTime.now().millisecondsSinceEpoch}.txt')
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
} else {
|
||||
// 移动端:复制到剪贴板
|
||||
await Clipboard.setData(ClipboardData(text: report));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(kIsWeb ? '文件已下载' : '报告已复制到剪贴板'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_getExportErrorMessage(e)),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
action: SnackBarAction(
|
||||
label: '重试',
|
||||
textColor: Colors.white,
|
||||
onPressed: _exportReport,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isExporting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportTodayReport() async {
|
||||
setState(() {
|
||||
_isExporting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final report = await _exportService.exportTodayReport();
|
||||
|
||||
if (report.isEmpty || report.trim().isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('今日暂无数据可导出'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
// Web 平台:下载文件
|
||||
final blob = html.Blob([report], 'text/plain');
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', 'autotime_today_${DateTime.now().millisecondsSinceEpoch}.txt')
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
} else {
|
||||
// 移动端:复制到剪贴板
|
||||
await Clipboard.setData(ClipboardData(text: report));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(kIsWeb ? '文件已下载' : '今日报告已复制到剪贴板'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_getExportErrorMessage(e)),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
action: SnackBarAction(
|
||||
label: '重试',
|
||||
textColor: Colors.white,
|
||||
onPressed: _exportTodayReport,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isExporting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDateRange() async {
|
||||
final DateTimeRange? picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(start: _startDate, end: _endDate),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_startDate = picked.start;
|
||||
_endDate = picked.end;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('数据导出'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 日期范围选择
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择日期范围',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _selectDateRange,
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
label: Text(
|
||||
'${_startDate.toString().split(' ')[0]} 至 ${_endDate.toString().split(' ')[0]}',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 导出选项
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'导出选项',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// CSV 导出
|
||||
_buildExportOption(
|
||||
theme,
|
||||
icon: Icons.table_chart,
|
||||
title: '导出 CSV 数据',
|
||||
subtitle: '导出原始应用使用数据(CSV 格式)',
|
||||
onTap: _exportCSV,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 统计报告
|
||||
_buildExportOption(
|
||||
theme,
|
||||
icon: Icons.description,
|
||||
title: '导出统计报告',
|
||||
subtitle: '导出时间范围内的统计报告(文本格式)',
|
||||
onTap: _exportReport,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 今日报告
|
||||
_buildExportOption(
|
||||
theme,
|
||||
icon: Icons.today,
|
||||
title: '导出今日报告',
|
||||
subtitle: '导出今日的详细统计报告',
|
||||
onTap: _exportTodayReport,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_isExporting)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExportOption(
|
||||
ThemeData theme, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: _isExporting ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getExportErrorMessage(Object error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||
return '需要授予应用使用权限';
|
||||
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||
return '数据库操作失败,请稍后重试';
|
||||
} else if (errorString.contains('file') || errorString.contains('文件')) {
|
||||
return '文件操作失败,请检查存储权限';
|
||||
}
|
||||
return '导出失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
494
lib/screens/goal_setting_screen.dart
Normal file
494
lib/screens/goal_setting_screen.dart
Normal file
@@ -0,0 +1,494 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../models/time_goal.dart';
|
||||
import '../database/time_goal_dao.dart';
|
||||
|
||||
class GoalSettingScreen extends ConsumerStatefulWidget {
|
||||
const GoalSettingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<GoalSettingScreen> createState() => _GoalSettingScreenState();
|
||||
}
|
||||
|
||||
class _GoalSettingScreenState extends ConsumerState<GoalSettingScreen> {
|
||||
final TimeGoalDao _goalDao = TimeGoalDao();
|
||||
|
||||
TimeGoal? _dailyTotalGoal;
|
||||
final Map<String, TimeGoal?> _categoryGoals = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadGoals();
|
||||
}
|
||||
|
||||
Future<void> _loadGoals() async {
|
||||
// 获取所有目标(包括非激活的),以便正确显示开关状态
|
||||
final allGoals = await _goalDao.getAllGoals();
|
||||
|
||||
setState(() {
|
||||
// 查找每日总时长目标
|
||||
final dailyTotal = allGoals.firstWhere(
|
||||
(g) => g.goalType == 'daily_total',
|
||||
orElse: () => TimeGoal(
|
||||
goalType: 'daily_total',
|
||||
targetTime: 28800, // 默认 8 小时
|
||||
isActive: _dailyTotalGoal?.isActive ?? true, // 保持当前状态
|
||||
createdAt: _dailyTotalGoal?.createdAt ?? DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// 如果找到的目标有 ID,使用它;否则保持当前 ID(如果有)
|
||||
_dailyTotalGoal = TimeGoal(
|
||||
id: dailyTotal.id ?? _dailyTotalGoal?.id,
|
||||
goalType: dailyTotal.goalType,
|
||||
targetTime: dailyTotal.targetTime,
|
||||
isActive: dailyTotal.isActive,
|
||||
createdAt: dailyTotal.createdAt,
|
||||
updatedAt: dailyTotal.updatedAt,
|
||||
);
|
||||
|
||||
final categories = ['work', 'study', 'entertainment', 'social', 'tool'];
|
||||
for (final category in categories) {
|
||||
_categoryGoals[category] = allGoals.firstWhere(
|
||||
(g) => g.goalType == 'daily_category' && g.category == category,
|
||||
orElse: () => TimeGoal(
|
||||
goalType: 'daily_category',
|
||||
category: category,
|
||||
targetTime: 0,
|
||||
isActive: false,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('时间目标'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 每日总时长目标
|
||||
_buildDailyTotalGoalCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 分类时间限制
|
||||
_buildCategoryGoalsCard(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDailyTotalGoalCard(ThemeData theme) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.timer, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'每日总时长目标',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_dailyTotalGoal != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_dailyTotalGoal!.formattedTargetTime,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _dailyTotalGoal!.isActive,
|
||||
onChanged: (value) async {
|
||||
// 先更新本地状态,立即显示变化
|
||||
setState(() {
|
||||
_dailyTotalGoal = TimeGoal(
|
||||
id: _dailyTotalGoal!.id,
|
||||
goalType: _dailyTotalGoal!.goalType,
|
||||
targetTime: _dailyTotalGoal!.targetTime,
|
||||
isActive: value,
|
||||
createdAt: _dailyTotalGoal!.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
});
|
||||
|
||||
// 然后保存到数据库
|
||||
await _goalDao.upsertTimeGoal(_dailyTotalGoal!);
|
||||
// 重新加载以确保数据同步
|
||||
await _loadGoals();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTimePicker(
|
||||
theme,
|
||||
'设置目标时长',
|
||||
_dailyTotalGoal!.targetTime,
|
||||
(hours, minutes) async {
|
||||
final targetTime = hours * 3600 + minutes * 60;
|
||||
final updatedGoal = TimeGoal(
|
||||
id: _dailyTotalGoal!.id,
|
||||
goalType: 'daily_total',
|
||||
targetTime: targetTime,
|
||||
isActive: _dailyTotalGoal!.isActive,
|
||||
createdAt: _dailyTotalGoal!.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||
await _loadGoals();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryGoalsCard(ThemeData theme) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.category, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'分类时间限制',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...['work', 'study', 'entertainment', 'social', 'tool'].map((category) {
|
||||
final goal = _categoryGoals[category];
|
||||
return _buildCategoryGoalItem(theme, category, goal);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryGoalItem(
|
||||
ThemeData theme,
|
||||
String category,
|
||||
TimeGoal? goal,
|
||||
) {
|
||||
final isActive = goal?.isActive ?? false;
|
||||
final targetTime = goal?.targetTime ?? 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(category),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppTheme.getCategoryName(category),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
targetTime > 0 ? _formatTime(targetTime) : '未设置',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isActive ? AppTheme.primaryColor : theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Switch(
|
||||
value: isActive,
|
||||
onChanged: (value) async {
|
||||
if (value && targetTime == 0) {
|
||||
// 如果启用但未设置时间,先设置默认值
|
||||
final defaultGoal = TimeGoal(
|
||||
id: goal?.id,
|
||||
goalType: 'daily_category',
|
||||
category: category,
|
||||
targetTime: 7200, // 默认 2 小时
|
||||
isActive: true,
|
||||
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _goalDao.upsertTimeGoal(defaultGoal);
|
||||
} else {
|
||||
final updatedGoal = TimeGoal(
|
||||
id: goal?.id,
|
||||
goalType: 'daily_category',
|
||||
category: category,
|
||||
targetTime: targetTime,
|
||||
isActive: value,
|
||||
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||
}
|
||||
await _loadGoals();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
_showCategoryGoalDialog(theme, category, goal);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCategoryGoalDialog(ThemeData theme, String category, TimeGoal? goal) {
|
||||
final currentTime = goal?.targetTime ?? 0;
|
||||
final hours = currentTime ~/ 3600;
|
||||
final minutes = (currentTime % 3600) ~/ 60;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TimePickerDialog(
|
||||
title: '设置 ${AppTheme.getCategoryName(category)} 时间限制',
|
||||
initialHours: hours,
|
||||
initialMinutes: minutes,
|
||||
onSave: (hours, minutes) async {
|
||||
final targetTime = hours * 3600 + minutes * 60;
|
||||
final updatedGoal = TimeGoal(
|
||||
id: goal?.id,
|
||||
goalType: 'daily_category',
|
||||
category: category,
|
||||
targetTime: targetTime,
|
||||
isActive: true,
|
||||
createdAt: goal?.createdAt ?? DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _goalDao.upsertTimeGoal(updatedGoal);
|
||||
await _loadGoals();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimePicker(
|
||||
ThemeData theme,
|
||||
String title,
|
||||
int currentTime,
|
||||
Future<void> Function(int hours, int minutes) onSave,
|
||||
) {
|
||||
final hours = currentTime ~/ 3600;
|
||||
final minutes = (currentTime % 3600) ~/ 60;
|
||||
|
||||
return ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _TimePickerDialog(
|
||||
title: title,
|
||||
initialHours: hours,
|
||||
initialMinutes: minutes,
|
||||
onSave: onSave,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.access_time),
|
||||
label: Text('${hours}小时 ${minutes}分钟'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final hours = seconds ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
|
||||
class _TimePickerDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final int initialHours;
|
||||
final int initialMinutes;
|
||||
final Future<void> Function(int hours, int minutes) onSave;
|
||||
|
||||
const _TimePickerDialog({
|
||||
required this.title,
|
||||
required this.initialHours,
|
||||
required this.initialMinutes,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TimePickerDialog> createState() => _TimePickerDialogState();
|
||||
}
|
||||
|
||||
class _TimePickerDialogState extends State<_TimePickerDialog> {
|
||||
late int _hours;
|
||||
late int _minutes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hours = widget.initialHours;
|
||||
_minutes = widget.initialMinutes;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 小时选择
|
||||
Column(
|
||||
children: [
|
||||
Text('小时', style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_hours > 0) _hours--;
|
||||
});
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
'$_hours',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_hours < 24) _hours++;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// 分钟选择
|
||||
Column(
|
||||
children: [
|
||||
Text('分钟', style: theme.textTheme.bodySmall),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_minutes > 0) {
|
||||
_minutes -= 15;
|
||||
if (_minutes < 0) _minutes = 0;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
'$_minutes',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_minutes += 15;
|
||||
if (_minutes >= 60) {
|
||||
_hours++;
|
||||
_minutes = 0;
|
||||
}
|
||||
if (_hours >= 24) {
|
||||
_hours = 23;
|
||||
_minutes = 59;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await widget.onSave(_hours, _minutes);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
73
lib/screens/home_screen.dart
Normal file
73
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'today_screen.dart';
|
||||
import 'stats_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import '../providers/background_sync_provider.dart';
|
||||
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const TodayScreen(),
|
||||
const StatsScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Web 平台不启动后台同步服务
|
||||
if (!kIsWeb) {
|
||||
// 启动后台同步服务
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final syncService = ref.read(backgroundSyncServiceProvider);
|
||||
syncService.start();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: _screens,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.today_outlined),
|
||||
selectedIcon: Icon(Icons.today),
|
||||
label: 'Today',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
252
lib/screens/notification_settings_screen.dart
Normal file
252
lib/screens/notification_settings_screen.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/custom_time_picker_dialog.dart';
|
||||
|
||||
class NotificationSettingsScreen extends StatefulWidget {
|
||||
const NotificationSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationSettingsScreen> createState() => _NotificationSettingsScreenState();
|
||||
}
|
||||
|
||||
class _NotificationSettingsScreenState extends State<NotificationSettingsScreen> {
|
||||
bool _goalReminderEnabled = true;
|
||||
bool _dailyReportEnabled = true;
|
||||
bool _weeklyReportEnabled = false;
|
||||
TimeOfDay _goalReminderTime = const TimeOfDay(hour: 20, minute: 0);
|
||||
TimeOfDay _dailyReportTime = const TimeOfDay(hour: 22, minute: 0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_goalReminderEnabled = prefs.getBool('goal_reminder_enabled') ?? true;
|
||||
_dailyReportEnabled = prefs.getBool('daily_report_enabled') ?? true;
|
||||
_weeklyReportEnabled = prefs.getBool('weekly_report_enabled') ?? false;
|
||||
|
||||
final goalReminderHour = prefs.getInt('goal_reminder_hour') ?? 20;
|
||||
final goalReminderMinute = prefs.getInt('goal_reminder_minute') ?? 0;
|
||||
_goalReminderTime = TimeOfDay(hour: goalReminderHour, minute: goalReminderMinute);
|
||||
|
||||
final dailyReportHour = prefs.getInt('daily_report_hour') ?? 22;
|
||||
final dailyReportMinute = prefs.getInt('daily_report_minute') ?? 0;
|
||||
_dailyReportTime = TimeOfDay(hour: dailyReportHour, minute: dailyReportMinute);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('goal_reminder_enabled', _goalReminderEnabled);
|
||||
await prefs.setBool('daily_report_enabled', _dailyReportEnabled);
|
||||
await prefs.setBool('weekly_report_enabled', _weeklyReportEnabled);
|
||||
await prefs.setInt('goal_reminder_hour', _goalReminderTime.hour);
|
||||
await prefs.setInt('goal_reminder_minute', _goalReminderTime.minute);
|
||||
await prefs.setInt('daily_report_hour', _dailyReportTime.hour);
|
||||
await prefs.setInt('daily_report_minute', _dailyReportTime.minute);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知设置'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 目标提醒
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.flag, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'目标提醒',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('启用目标提醒'),
|
||||
subtitle: const Text('当接近或超过时间目标时提醒'),
|
||||
value: _goalReminderEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_goalReminderEnabled = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
if (_goalReminderEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
title: const Text('提醒时间'),
|
||||
subtitle: Text(
|
||||
'${_goalReminderTime.hour.toString().padLeft(2, '0')}:${_goalReminderTime.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await CustomTimePickerDialog.show(
|
||||
context: context,
|
||||
title: '选择提醒时间',
|
||||
initialTime: _goalReminderTime,
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_goalReminderTime = picked;
|
||||
});
|
||||
await _saveSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 每日报告
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.today, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'每日报告',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('启用每日报告'),
|
||||
subtitle: const Text('每天发送使用时间摘要'),
|
||||
value: _dailyReportEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_dailyReportEnabled = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
if (_dailyReportEnabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
title: const Text('报告时间'),
|
||||
subtitle: Text(
|
||||
'${_dailyReportTime.hour.toString().padLeft(2, '0')}:${_dailyReportTime.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked = await CustomTimePickerDialog.show(
|
||||
context: context,
|
||||
title: '选择报告时间',
|
||||
initialTime: _dailyReportTime,
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_dailyReportTime = picked;
|
||||
});
|
||||
await _saveSettings();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 每周报告
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_view_week, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'每周报告',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('启用每周报告'),
|
||||
subtitle: const Text('每周一发送周报摘要'),
|
||||
value: _weeklyReportEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_weeklyReportEnabled = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 说明
|
||||
Card(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppTheme.infoColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'通知功能需要系统通知权限。请在系统设置中授予通知权限。',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
229
lib/screens/permission_screen.dart
Normal file
229
lib/screens/permission_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform;
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform;
|
||||
import '../providers/time_tracking_provider.dart';
|
||||
import '../widgets/error_state_widget.dart';
|
||||
|
||||
class PermissionScreen extends ConsumerWidget {
|
||||
const PermissionScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final permissionStatus = ref.watch(permissionStatusProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('权限设置'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 图标
|
||||
Icon(
|
||||
Icons.security,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
'我们需要访问您的应用使用数据',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 说明
|
||||
Text(
|
||||
_getPermissionDescription(),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 权限状态
|
||||
permissionStatus.when(
|
||||
data: (hasPermission) {
|
||||
if (hasPermission) {
|
||||
return _buildPermissionGranted(context, theme);
|
||||
} else {
|
||||
return _buildPermissionRequest(context, ref, theme);
|
||||
}
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => ErrorStateWidget.generic(
|
||||
message: '检查权限时出错,请重试',
|
||||
onRetry: () {
|
||||
ref.invalidate(permissionStatusProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 隐私说明
|
||||
_buildPrivacyInfo(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getPermissionDescription() {
|
||||
if (kIsWeb) {
|
||||
return 'Web 平台暂不支持时间追踪功能。\n\n'
|
||||
'请使用 iOS 或 Android 应用。';
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
return '这样我们才能自动追踪您的应用使用情况,无需手动操作。\n\n'
|
||||
'• 完全自动化,无需手动操作\n'
|
||||
'• 数据仅存储在本地\n'
|
||||
'• 不会上传到服务器';
|
||||
} else {
|
||||
return '这样我们才能自动追踪您的应用使用情况,无需手动操作。\n\n'
|
||||
'• 完全自动化,无需手动操作\n'
|
||||
'• 数据仅存储在本地\n'
|
||||
'• 不会上传到服务器';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPermissionRequest(BuildContext context, WidgetRef ref, ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'权限未授予',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final service = ref.read(timeTrackingServiceProvider);
|
||||
final granted = await service.requestPermission();
|
||||
|
||||
if (granted) {
|
||||
ref.invalidate(permissionStatusProvider);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('权限已授予')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('权限授予失败,请前往设置中手动开启')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'去设置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPermissionGranted(BuildContext context, ThemeData theme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'权限已授予',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildPrivacyInfo(ThemeData theme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'隐私说明',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• 所有数据仅存储在您的设备本地\n'
|
||||
'• 我们不会收集或上传任何数据到服务器\n'
|
||||
'• 您可以随时删除所有数据\n'
|
||||
'• 应用使用数据仅用于统计和分析',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
270
lib/screens/settings_screen.dart
Normal file
270
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'category_management_screen.dart';
|
||||
import 'goal_setting_screen.dart';
|
||||
import 'export_data_screen.dart';
|
||||
import 'data_privacy_screen.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'notification_settings_screen.dart';
|
||||
import 'appearance_settings_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSettingsSection(
|
||||
title: '应用分类',
|
||||
icon: Icons.category,
|
||||
subtitle: '管理应用分类规则',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CategoryManagementScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSettingsSection(
|
||||
title: '时间目标',
|
||||
icon: Icons.flag,
|
||||
subtitle: '设置每日时间目标',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const GoalSettingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSettingsSection(
|
||||
title: '数据导出',
|
||||
icon: Icons.file_download,
|
||||
subtitle: '导出 CSV 数据、统计报告',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ExportDataScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSettingsSection(
|
||||
title: '数据与隐私',
|
||||
icon: Icons.security,
|
||||
subtitle: '数据管理、删除',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DataPrivacyScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSettingsSection(
|
||||
title: '通知设置',
|
||||
icon: Icons.notifications,
|
||||
subtitle: '目标提醒、每日报告',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NotificationSettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSettingsSection(
|
||||
title: '外观设置',
|
||||
icon: Icons.palette,
|
||||
subtitle: '主题、字体大小',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildUpgradeCard(theme),
|
||||
const SizedBox(height: 24),
|
||||
_buildSettingsSection(
|
||||
title: '关于',
|
||||
icon: Icons.info,
|
||||
subtitle: '版本信息、帮助、反馈',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const AboutScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection({
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
required ThemeData theme,
|
||||
}) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUpgradeCard(ThemeData theme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.secondaryColor,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.diamond,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Upgrade to Pro',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'解锁高级功能',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...[
|
||||
'无限历史数据',
|
||||
'高级统计分析',
|
||||
'效率评分与分析',
|
||||
'个性化建议',
|
||||
'数据导出',
|
||||
].map((feature) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
feature,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// 打开订阅页面
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'立即升级',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
564
lib/screens/stats_screen.dart
Normal file
564
lib/screens/stats_screen.dart
Normal file
@@ -0,0 +1,564 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../providers/statistics_provider.dart';
|
||||
import '../models/daily_stats.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/error_state_widget.dart';
|
||||
|
||||
class StatsScreen extends ConsumerStatefulWidget {
|
||||
const StatsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StatsScreen> createState() => _StatsScreenState();
|
||||
}
|
||||
|
||||
class _StatsScreenState extends ConsumerState<StatsScreen> {
|
||||
String _selectedPeriod = '周'; // 日、周、月
|
||||
|
||||
// 根据时间段获取图表数据
|
||||
List<Map<String, dynamic>> _getChartData(List<DailyStats>? stats, String period) {
|
||||
if (stats != null && stats.isNotEmpty) {
|
||||
return stats.map((stat) {
|
||||
return {
|
||||
'date': stat.date,
|
||||
'total': stat.totalTime,
|
||||
'work': stat.workTime,
|
||||
'study': stat.studyTime,
|
||||
'entertainment': stat.entertainmentTime,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 如果没有数据,使用默认测试数据
|
||||
final now = DateTime.now();
|
||||
|
||||
if (period == '日') {
|
||||
// 日视图:显示今日数据(24小时,每小时一个点)
|
||||
return List.generate(24, (index) {
|
||||
final hour = now.subtract(Duration(hours: 23 - index));
|
||||
return {
|
||||
'date': hour,
|
||||
'total': 1800 + (index % 3) * 300, // 模拟每小时30-60分钟
|
||||
'work': 1200 + (index % 3) * 200,
|
||||
'study': 300 + (index % 3) * 50,
|
||||
'entertainment': 300 + (index % 3) * 50,
|
||||
};
|
||||
});
|
||||
} else if (period == '周') {
|
||||
// 周视图:显示7天数据
|
||||
return [
|
||||
{'date': now.subtract(const Duration(days: 6)), 'total': 21600, 'work': 14400, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now.subtract(const Duration(days: 5)), 'total': 25200, 'work': 18000, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now.subtract(const Duration(days: 4)), 'total': 23400, 'work': 16200, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now.subtract(const Duration(days: 3)), 'total': 19800, 'work': 12600, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now.subtract(const Duration(days: 2)), 'total': 27000, 'work': 19800, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now.subtract(const Duration(days: 1)), 'total': 22500, 'work': 15300, 'study': 3600, 'entertainment': 3600},
|
||||
{'date': now, 'total': 23040, 'work': 14400, 'study': 3600, 'entertainment': 3600},
|
||||
];
|
||||
} else {
|
||||
// 月视图:显示30天数据(简化版,每天一个点)
|
||||
return List.generate(30, (index) {
|
||||
final date = now.subtract(Duration(days: 29 - index));
|
||||
return {
|
||||
'date': date,
|
||||
'total': 18000 + (index % 7) * 2000, // 模拟每天5-7小时
|
||||
'work': 12000 + (index % 7) * 1500,
|
||||
'study': 3000 + (index % 7) * 300,
|
||||
'entertainment': 3000 + (index % 7) * 200,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 根据选中的时间段选择不同的 Provider
|
||||
final statsAsync = _selectedPeriod == '日'
|
||||
? ref.watch(todayStatsListProvider)
|
||||
: _selectedPeriod == '周'
|
||||
? ref.watch(weekStatsProvider)
|
||||
: ref.watch(monthStatsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Statistics'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_download),
|
||||
onPressed: () {
|
||||
// 导出数据
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: statsAsync.when(
|
||||
data: (stats) {
|
||||
final chartData = _getChartData(stats, _selectedPeriod);
|
||||
// 检查是否为空数据
|
||||
final isEmpty = chartData.isEmpty || chartData.every((data) => (data['total'] as int) == 0);
|
||||
if (isEmpty) {
|
||||
return EmptyStateWidget.noData(
|
||||
title: '暂无统计数据',
|
||||
subtitle: '使用应用一段时间后,统计数据将显示在这里',
|
||||
actionLabel: '刷新',
|
||||
onAction: () {
|
||||
if (_selectedPeriod == '日') {
|
||||
ref.invalidate(todayStatsListProvider);
|
||||
} else if (_selectedPeriod == '周') {
|
||||
ref.invalidate(weekStatsProvider);
|
||||
} else {
|
||||
ref.invalidate(monthStatsProvider);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 时间选择器
|
||||
_buildPeriodSelector(theme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 时间趋势图
|
||||
_buildTrendChart(theme, chartData, _selectedPeriod),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 分类对比图
|
||||
_buildCategoryChart(theme, chartData, _selectedPeriod),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 应用使用详情
|
||||
_buildAppDetails(theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => ErrorStateWidget.dataLoad(
|
||||
message: _getErrorMessage(error),
|
||||
onRetry: () {
|
||||
if (_selectedPeriod == '日') {
|
||||
ref.invalidate(todayStatsListProvider);
|
||||
} else if (_selectedPeriod == '周') {
|
||||
ref.invalidate(weekStatsProvider);
|
||||
} else {
|
||||
ref.invalidate(monthStatsProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeriodSelector(ThemeData theme) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: '日', label: Text('日')),
|
||||
ButtonSegment(value: '周', label: Text('周')),
|
||||
ButtonSegment(value: '月', label: Text('月')),
|
||||
],
|
||||
selected: {_selectedPeriod},
|
||||
onSelectionChanged: (Set<String> newSelection) {
|
||||
final newPeriod = newSelection.first;
|
||||
setState(() {
|
||||
_selectedPeriod = newPeriod;
|
||||
});
|
||||
// 切换时间段时,刷新对应的 Provider
|
||||
if (newPeriod == '日') {
|
||||
ref.invalidate(todayStatsListProvider);
|
||||
} else if (newPeriod == '周') {
|
||||
ref.invalidate(weekStatsProvider);
|
||||
} else {
|
||||
ref.invalidate(monthStatsProvider);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_download),
|
||||
onPressed: () {
|
||||
// 导出功能
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrendChart(ThemeData theme, List<Map<String, dynamic>> chartData, String period) {
|
||||
return Container(
|
||||
height: 250,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
period == '日' ? '每小时总时长趋势' : period == '周' ? '每日总时长趋势' : '每日总时长趋势(月)',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 2,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
'${(value / 3600).toStringAsFixed(1)}h',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value.toInt() >= 0 && value.toInt() < chartData.length) {
|
||||
final date = chartData[value.toInt()]['date'] as DateTime;
|
||||
String label;
|
||||
if (period == '日') {
|
||||
label = DateFormat('HH:mm', 'zh_CN').format(date);
|
||||
} else if (period == '周') {
|
||||
label = DateFormat('E', 'zh_CN').format(date);
|
||||
} else {
|
||||
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||
}
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: chartData.asMap().entries.map((entry) {
|
||||
return FlSpot(
|
||||
entry.key.toDouble(),
|
||||
(entry.value['total'] as int) / 3600.0,
|
||||
);
|
||||
}).toList(),
|
||||
isCurved: true,
|
||||
color: AppTheme.primaryColor,
|
||||
barWidth: 3,
|
||||
dotData: const FlDotData(show: true),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryChart(ThemeData theme, List<Map<String, dynamic>> chartData, String period) {
|
||||
return Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
period == '日' ? '每小时分类时间分布' : period == '周' ? '每日分类时间分布' : '每日分类时间分布(月)',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: 8,
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
'${value.toInt()}h',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value.toInt() >= 0 && value.toInt() < chartData.length) {
|
||||
final date = chartData[value.toInt()]['date'] as DateTime;
|
||||
String label;
|
||||
if (period == '日') {
|
||||
label = DateFormat('HH:mm', 'zh_CN').format(date);
|
||||
} else if (period == '周') {
|
||||
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||
} else {
|
||||
label = DateFormat('M/d', 'zh_CN').format(date);
|
||||
}
|
||||
return Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: chartData.asMap().entries.map((entry) {
|
||||
final data = entry.value;
|
||||
return BarChartGroupData(
|
||||
x: entry.key,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: (data['work'] as int) / 3600.0,
|
||||
color: AppTheme.workColor,
|
||||
width: 12,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
BarChartRodData(
|
||||
toY: (data['study'] as int) / 3600.0,
|
||||
color: AppTheme.studyColor,
|
||||
width: 12,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
BarChartRodData(
|
||||
toY: (data['entertainment'] as int) / 3600.0,
|
||||
color: AppTheme.entertainmentColor,
|
||||
width: 12,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppDetails(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'应用使用详情',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildAppDetailItem('Chrome', '今日: 2h 15m', '本周: 12h 30m', '工作', theme),
|
||||
_buildAppDetailItem('VS Code', '今日: 1h 30m', '本周: 8h 45m', '工作', theme),
|
||||
_buildAppDetailItem('Slack', '今日: 1h', '本周: 6h', '工作', theme),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppDetailItem(
|
||||
String appName,
|
||||
String todayTime,
|
||||
String weekTime,
|
||||
String category,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(category).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.phone_android,
|
||||
color: AppTheme.getCategoryColor(category),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'分类: $category',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今日',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
todayTime,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'本周',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
weekTime,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getErrorMessage(Object error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||
return '需要授予应用使用权限';
|
||||
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||
return '网络连接失败,请检查网络';
|
||||
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||
return '数据库操作失败';
|
||||
}
|
||||
return '加载失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
407
lib/screens/today_screen.dart
Normal file
407
lib/screens/today_screen.dart
Normal file
@@ -0,0 +1,407 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../models/daily_stats.dart';
|
||||
import '../models/app_usage.dart';
|
||||
import '../providers/statistics_provider.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/error_state_widget.dart';
|
||||
|
||||
class TodayScreen extends ConsumerWidget {
|
||||
const TodayScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final todayStatsAsync = ref.watch(todayStatsProvider);
|
||||
final topAppsAsync = ref.watch(todayTopAppsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('AutoTime Tracker'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(todayStatsProvider);
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
// 设置页面通过底部导航栏访问
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: todayStatsAsync.when(
|
||||
data: (stats) {
|
||||
// 检查是否为空数据(总时长为0且没有应用数据)
|
||||
final isEmpty = stats.totalTime == 0;
|
||||
if (isEmpty) {
|
||||
return _buildEmptyContent(context, ref);
|
||||
}
|
||||
return _buildContent(context, ref, stats, topAppsAsync);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => ErrorStateWidget.dataLoad(
|
||||
message: _getErrorMessage(error),
|
||||
onRetry: () {
|
||||
ref.invalidate(todayStatsProvider);
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
DailyStats stats,
|
||||
AsyncValue<List<AppUsage>> topAppsAsync,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(todayStatsProvider);
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 总时长显示
|
||||
_buildTotalTimeSection(stats, theme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 效率评分
|
||||
_buildEfficiencySection(stats, theme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 分类时间分布(饼图)
|
||||
_buildCategoryChart(stats, theme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 分类标签
|
||||
_buildCategoryTags(theme),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Top 应用列表
|
||||
_buildTopAppsSection(context, ref, theme, topAppsAsync),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildTotalTimeSection(DailyStats stats, ThemeData theme) {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
stats.formattedTotalTime,
|
||||
style: theme.textTheme.displayLarge?.copyWith(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'今日总时长',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEfficiencySection(DailyStats stats, ThemeData theme) {
|
||||
final score = stats.efficiencyScore ?? 0;
|
||||
final color = stats.efficiencyColor;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'效率评分',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'$score%',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...List.generate(5, (index) {
|
||||
return Icon(
|
||||
index < (score / 20).floor()
|
||||
? Icons.star
|
||||
: Icons.star_border,
|
||||
size: 20,
|
||||
color: color,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getEfficiencyText(score),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getEfficiencyText(int score) {
|
||||
if (score >= 80) return '优秀';
|
||||
if (score >= 60) return '良好';
|
||||
if (score >= 40) return '一般';
|
||||
return '需改进';
|
||||
}
|
||||
|
||||
Widget _buildCategoryChart(DailyStats stats, ThemeData theme) {
|
||||
final categoryData = [
|
||||
{'category': 'work', 'time': stats.workTime, 'color': AppTheme.workColor},
|
||||
{'category': 'study', 'time': stats.studyTime, 'color': AppTheme.studyColor},
|
||||
{'category': 'entertainment', 'time': stats.entertainmentTime, 'color': AppTheme.entertainmentColor},
|
||||
{'category': 'social', 'time': stats.socialTime, 'color': AppTheme.socialColor},
|
||||
{'category': 'tool', 'time': stats.toolTime, 'color': AppTheme.toolColor},
|
||||
].where((item) => item['time'] as int > 0).toList();
|
||||
|
||||
return Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'分类时间分布',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: categoryData.map((item) {
|
||||
final time = item['time'] as int;
|
||||
final total = stats.totalTime;
|
||||
final percentage = (time / total * 100);
|
||||
return PieChartSectionData(
|
||||
value: time.toDouble(),
|
||||
title: '${percentage.toStringAsFixed(1)}%',
|
||||
color: item['color'] as Color,
|
||||
radius: 80,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 60,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryTags(ThemeData theme) {
|
||||
final categories = ['work', 'study', 'entertainment', 'social', 'tool'];
|
||||
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: categories.map((category) {
|
||||
return FilterChip(
|
||||
label: Text(AppTheme.getCategoryName(category)),
|
||||
selected: false,
|
||||
onSelected: (selected) {
|
||||
// 筛选该分类
|
||||
},
|
||||
backgroundColor: AppTheme.getCategoryColor(category).withOpacity(0.1),
|
||||
selectedColor: AppTheme.getCategoryColor(category),
|
||||
labelStyle: TextStyle(
|
||||
color: AppTheme.getCategoryColor(category),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopAppsSection(BuildContext context, WidgetRef ref, ThemeData theme, AsyncValue<List<AppUsage>> topAppsAsync) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Top Apps Today',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
topAppsAsync.when(
|
||||
data: (apps) {
|
||||
if (apps.isEmpty) {
|
||||
return EmptyStateWidget.noApps(
|
||||
onAction: () {
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: apps.map((app) => _buildAppItem(app, theme)).toList(),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ErrorStateWidget.dataLoad(
|
||||
message: _getErrorMessage(error),
|
||||
onRetry: () {
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyContent(BuildContext context, WidgetRef ref) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(todayStatsProvider);
|
||||
ref.invalidate(todayTopAppsProvider);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: EmptyStateWidget.firstTime(
|
||||
onAction: () {
|
||||
// 可以导航到权限设置页面
|
||||
Navigator.of(context).pushNamed('/permission');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getErrorMessage(Object error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
if (errorString.contains('permission') || errorString.contains('权限')) {
|
||||
return '需要授予应用使用权限';
|
||||
} else if (errorString.contains('network') || errorString.contains('网络')) {
|
||||
return '网络连接失败,请检查网络';
|
||||
} else if (errorString.contains('database') || errorString.contains('数据库')) {
|
||||
return '数据库操作失败';
|
||||
}
|
||||
return '加载失败,请稍后重试';
|
||||
}
|
||||
|
||||
Widget _buildAppItem(AppUsage app, ThemeData theme) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.getCategoryColor(app.category).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.phone_android,
|
||||
color: AppTheme.getCategoryColor(app.category),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
app.appName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
AppTheme.getCategoryName(app.category),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
app.formattedDuration,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user