Files
AutoTime-Tracker/lib/screens/stats_screen.dart
2025-11-13 15:45:28 +08:00

565 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '加载失败,请稍后重试';
}
}