565 lines
20 KiB
Dart
565 lines
20 KiB
Dart
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 '加载失败,请稍后重试';
|
||
}
|
||
}
|
||
|