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 createState() => _WakatimeScreenState(); } class _WakatimeScreenState extends State 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 _animation; DateTime _selectedStartDate = DateTime.now(); DateTime _selectedEndDate = DateTime.now(); int? _touchedDayIndex; // 预定义的颜色列表 final List _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 _fetchData() async { final authService = Provider.of(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 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 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( 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(); } }