- 添加日期范围选择器,支持今天、近三天和近一周的快速选择 - 实现开始日期和结束日期的单独选择功能 - 新增每日编码时间图表,展示所选日期范围内的编码时间分布 - 优化界面布局,提升用户体验
666 lines
21 KiB
Dart
666 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),
|
|
),
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
if (_touchedDayIndex != null) ...[
|
|
const SizedBox(height: 16),
|
|
Center(
|
|
child: Text(
|
|
'${_formatDate(DateTime.parse(dailyData[_touchedDayIndex!].range.date))}: ${_formatDuration(dailyData[_touchedDayIndex!].grandTotal.totalSeconds.toInt())}',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
@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();
|
|
}
|
|
}
|