first commit

This commit is contained in:
ytc1012
2025-11-13 15:45:28 +08:00
commit 6b321890c0
54 changed files with 8412 additions and 0 deletions

View 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 '加载失败,请稍后重试';
}
}