first commit
This commit is contained in:
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 '加载失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user