flutter_dashboard/lib/screens/wakatime_screen.dart

668 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:provider/provider.dart';
import '../models/wakatime_summary.dart';
import '../services/wakatime_service.dart';
import '../services/auth_service.dart';
class WakatimeScreen extends StatefulWidget {
const WakatimeScreen({super.key});
@override
State<WakatimeScreen> createState() => _WakatimeScreenState();
}
class _WakatimeScreenState extends State<WakatimeScreen>
with SingleTickerProviderStateMixin {
final WakatimeService _wakatimeService = WakatimeService();
WakatimeSummary? _summary;
bool _isLoading = false;
String? _error;
final TextEditingController _startDateController = TextEditingController();
final TextEditingController _endDateController = TextEditingController();
int? _touchedIndex;
String? _touchedChartType;
late AnimationController _animationController;
late Animation<double> _animation;
DateTime _selectedStartDate = DateTime.now();
DateTime _selectedEndDate = DateTime.now();
int? _touchedDayIndex;
// 预定义的颜色列表
final List<Color> _colors = [
Colors.blue,
Colors.red,
Colors.green,
Colors.orange,
Colors.purple,
Colors.teal,
Colors.pink,
Colors.indigo,
Colors.amber,
Colors.cyan,
Colors.lime,
Colors.brown,
];
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
_animationController.forward();
// 设置默认日期为今天
final now = DateTime.now();
_selectedStartDate = now;
_selectedEndDate = now;
_updateDateControllers();
_fetchData();
}
void _updateDateControllers() {
_startDateController.text = _formatDate(_selectedStartDate);
_endDateController.text = _formatDate(_selectedEndDate);
}
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
return '${hours}小时${minutes}分钟';
}
void _selectDateRange(String range) {
final now = DateTime.now();
switch (range) {
case 'today':
_selectedStartDate = now;
_selectedEndDate = now;
break;
case '3days':
_selectedStartDate = now.subtract(const Duration(days: 2));
_selectedEndDate = now;
break;
case 'week':
_selectedStartDate = now.subtract(const Duration(days: 6));
_selectedEndDate = now;
break;
}
_updateDateControllers();
_fetchData();
}
Future<void> _fetchData() async {
final authService = Provider.of<AuthService>(context, listen: false);
final token = authService.credentials['wakatime_token'];
if (token == null || token.isEmpty) {
setState(() => _error = '请在设置中配置Wakatime API Token');
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final summary = await _wakatimeService.getSummary(
token,
_startDateController.text,
_endDateController.text,
);
setState(() {
_summary = summary;
_isLoading = false;
});
_animationController.reset();
_animationController.forward();
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Widget _buildLegend(
List<dynamic> data, String Function(dynamic) getName, String chartType) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: data.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isTouched =
index == _touchedIndex && _touchedChartType == chartType;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
color: _colors[index % _colors.length],
),
const SizedBox(width: 8),
Text(
'${getName(item)}${isTouched ? ' (${_formatDuration(item.totalSeconds.toInt())})' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}).toList(),
);
}
Widget _buildPieChart(List<dynamic> data, String title,
String Function(dynamic) getName, String chartType) {
return Column(
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildLegend(data, getName, chartType),
const SizedBox(height: 16),
SizedBox(
height: 300,
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = null;
_touchedChartType = null;
return;
}
_touchedIndex =
pieTouchResponse.touchedSection!.touchedSectionIndex;
_touchedChartType = chartType;
});
},
),
sections: data.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isTouched =
index == _touchedIndex && _touchedChartType == chartType;
final percent = item.percent;
return PieChartSectionData(
value: item.totalSeconds.toDouble(),
title: percent > 5
? '${getName(item)}\n${percent.toStringAsFixed(1)}%'
: '',
radius: isTouched ? 110 : 100,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
color: _colors[index % _colors.length],
);
}).toList(),
),
),
),
],
);
}
Widget _buildProjectList() {
final projects = _summary!.getAggregatedProjects();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'项目时间分布',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: projects.length,
itemBuilder: (context, index) {
final project = projects[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 16,
height: 16,
color: _colors[index % _colors.length],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_formatDuration(project.totalSeconds.toInt()),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
Text(
'${project.percent.toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
),
],
);
}
Widget _buildTotalTime() {
if (_summary == null) return const SizedBox.shrink();
return FadeTransition(
opacity: _animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(_animation),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor,
Theme.of(context).primaryColor.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).primaryColor.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Column(
children: [
const Text(
'总编码时间',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
Text(
_summary!.cumulativeTotal.text,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
'${_startDateController.text}${_endDateController.text}',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
),
);
}
Widget _buildDateRangeSelector() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDateRangeButton('今天', 'today'),
_buildDateRangeButton('近三天', '3days'),
_buildDateRangeButton('近一周', 'week'),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _startDateController,
readOnly: true,
decoration: const InputDecoration(
labelText: '开始日期',
hintText: 'YYYY-MM-DD',
suffixIcon: Icon(Icons.calendar_today),
),
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedStartDate,
firstDate:
DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
_selectedStartDate = picked;
if (picked.isAfter(_selectedEndDate)) {
_selectedEndDate = picked;
_endDateController.text = _formatDate(picked);
}
_startDateController.text = _formatDate(picked);
_fetchData();
});
}
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _endDateController,
readOnly: true,
decoration: const InputDecoration(
labelText: '结束日期',
hintText: 'YYYY-MM-DD',
suffixIcon: Icon(Icons.calendar_today),
),
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedEndDate,
firstDate: _selectedStartDate,
lastDate: DateTime.now(),
);
if (picked != null) {
setState(() {
_selectedEndDate = picked;
if (picked.isBefore(_selectedStartDate)) {
_selectedStartDate = picked;
_startDateController.text = _formatDate(picked);
}
_endDateController.text = _formatDate(picked);
_fetchData();
});
}
},
),
),
],
),
],
);
}
Widget _buildDateRangeButton(String text, String range) {
final isSelected =
(range == 'today' && _selectedStartDate == _selectedEndDate) ||
(range == '3days' &&
_selectedEndDate.difference(_selectedStartDate).inDays == 2) ||
(range == 'week' &&
_selectedEndDate.difference(_selectedStartDate).inDays == 6);
return ElevatedButton(
onPressed: () => _selectDateRange(range),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? Theme.of(context).primaryColor : null,
foregroundColor: isSelected ? Colors.white : null,
),
child: Text(text),
);
}
Widget _buildDailyChart() {
if (_summary == null || _summary!.data.length <= 1)
return const SizedBox.shrink();
final dailyData = _summary!.data;
final maxSeconds = dailyData
.map((d) => d.grandTotal.totalSeconds)
.reduce((a, b) => a > b ? a : b);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'每日编码时间',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(
height: 40,
child: Center(
child: _touchedDayIndex != null
? Text(
'${_formatDate(DateTime.parse(dailyData[_touchedDayIndex!].range.date))}: ${_formatDuration(dailyData[_touchedDayIndex!].grandTotal.totalSeconds.toInt())}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
)
: null,
),
),
// const SizedBox(height: 16),
SizedBox(
height: 200,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxSeconds.toDouble(),
barTouchData: BarTouchData(
touchCallback: (FlTouchEvent event, response) {
if (event.isInterestedForInteractions &&
response != null &&
response.spot != null) {
setState(() {
_touchedDayIndex = response.spot!.touchedBarGroupIndex;
});
} else {
setState(() {
_touchedDayIndex = null;
});
}
},
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final date =
DateTime.parse(dailyData[groupIndex].range.date);
final seconds = rod.toY.toInt();
return BarTooltipItem(
'${_formatDate(date)}\n${_formatDuration(seconds)}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < dailyData.length) {
final date =
DateTime.parse(dailyData[value.toInt()].range.date);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${date.month}/${date.day}',
style: const TextStyle(fontSize: 10),
),
);
}
return const Text('');
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Text(
'${(value / 3600).round()}h',
style: const TextStyle(fontSize: 10),
);
},
),
),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(show: false),
barGroups: dailyData.asMap().entries.map((entry) {
final index = entry.key;
final day = entry.value;
final isTouched = index == _touchedDayIndex;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: day.grandTotal.totalSeconds.toDouble(),
width: 20,
color: isTouched
? Theme.of(context).primaryColor
: Theme.of(context).primaryColor.withOpacity(0.5),
),
],
);
}).toList(),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Wakatime 统计'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDateRangeSelector(),
const SizedBox(height: 16),
// 获取数据按钮
Center(
child: ElevatedButton(
onPressed: _isLoading ? null : _fetchData,
child: _isLoading
? const CircularProgressIndicator()
: const Text('获取数据'),
),
),
const SizedBox(height: 16),
// 错误提示
if (_error != null)
Text(
_error!,
style: const TextStyle(color: Colors.red),
),
// 数据展示
if (_summary != null) ...[
_buildTotalTime(),
const SizedBox(height: 24),
// 每日编码时间图表
_buildDailyChart(),
const SizedBox(height: 24),
// 项目时间分布
_buildProjectList(),
const SizedBox(height: 24),
// 编辑器使用饼图
_buildPieChart(
_summary!.getAggregatedEditors(),
'编辑器使用情况',
(editor) => editor.name,
'editor',
),
const SizedBox(height: 24),
// 语言使用饼图
_buildPieChart(
_summary!.getAggregatedLanguages(),
'编程语言使用情况',
(lang) => lang.name,
'language',
),
],
],
),
),
);
}
@override
void dispose() {
_startDateController.dispose();
_endDateController.dispose();
_animationController.dispose();
super.dispose();
}
}