feat(wakatime): 添加 Wakatime 平台支持
- 在首页添加 Wakatime 卡片和相关功能 - 在设置页面添加 Wakatime API Token 配置 - 更新 auth_service 以支持 Wakatime 认证 - 添加 fl_chart 依赖用于 Wakatime 数据图表展示 - 更新 flutter_lints 版本到 2.0.0
This commit is contained in:
parent
66f46980f0
commit
569b4f4498
541
lib/models/wakatime_summary.dart
Normal file
541
lib/models/wakatime_summary.dart
Normal file
@ -0,0 +1,541 @@
|
||||
class WakatimeSummary {
|
||||
final List<SummaryData> data;
|
||||
final String start;
|
||||
final String end;
|
||||
final CumulativeTotal cumulativeTotal;
|
||||
final DailyAverage dailyAverage;
|
||||
|
||||
WakatimeSummary({
|
||||
required this.data,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.cumulativeTotal,
|
||||
required this.dailyAverage,
|
||||
});
|
||||
|
||||
factory WakatimeSummary.fromJson(Map<String, dynamic> json) {
|
||||
return WakatimeSummary(
|
||||
data: (json['data'] as List).map((e) => SummaryData.fromJson(e)).toList(),
|
||||
start: json['start'],
|
||||
end: json['end'],
|
||||
cumulativeTotal: CumulativeTotal.fromJson(json['cumulative_total']),
|
||||
dailyAverage: DailyAverage.fromJson(json['daily_average']),
|
||||
);
|
||||
}
|
||||
|
||||
// 汇总语言数据
|
||||
List<Language> getAggregatedLanguages() {
|
||||
final Map<String, Language> aggregated = {};
|
||||
|
||||
for (var dayData in data) {
|
||||
for (var lang in dayData.languages) {
|
||||
if (aggregated.containsKey(lang.name)) {
|
||||
final existing = aggregated[lang.name]!;
|
||||
aggregated[lang.name] = Language(
|
||||
name: lang.name,
|
||||
totalSeconds: existing.totalSeconds + lang.totalSeconds,
|
||||
digital: _formatTime(existing.totalSeconds + lang.totalSeconds),
|
||||
decimal: ((existing.totalSeconds + lang.totalSeconds) / 3600)
|
||||
.toStringAsFixed(2),
|
||||
text: _formatTimeText(existing.totalSeconds + lang.totalSeconds),
|
||||
hours: ((existing.totalSeconds + lang.totalSeconds) / 3600).floor(),
|
||||
minutes: ((existing.totalSeconds + lang.totalSeconds) % 3600 / 60)
|
||||
.floor(),
|
||||
seconds: ((existing.totalSeconds + lang.totalSeconds) % 60).floor(),
|
||||
percent: 0, // 将在计算完总和后更新
|
||||
);
|
||||
} else {
|
||||
aggregated[lang.name] = lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final totalSeconds = aggregated.values
|
||||
.fold<double>(0, (sum, lang) => sum + lang.totalSeconds);
|
||||
final result = aggregated.values
|
||||
.map((lang) => Language(
|
||||
name: lang.name,
|
||||
totalSeconds: lang.totalSeconds,
|
||||
digital: lang.digital,
|
||||
decimal: lang.decimal,
|
||||
text: lang.text,
|
||||
hours: lang.hours,
|
||||
minutes: lang.minutes,
|
||||
seconds: lang.seconds,
|
||||
percent: (lang.totalSeconds / totalSeconds * 100),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 按使用时间排序
|
||||
result.sort((a, b) => b.totalSeconds.compareTo(a.totalSeconds));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 汇总编辑器数据
|
||||
List<Editor> getAggregatedEditors() {
|
||||
final Map<String, Editor> aggregated = {};
|
||||
|
||||
for (var dayData in data) {
|
||||
for (var editor in dayData.editors) {
|
||||
if (aggregated.containsKey(editor.name)) {
|
||||
final existing = aggregated[editor.name]!;
|
||||
aggregated[editor.name] = Editor(
|
||||
name: editor.name,
|
||||
totalSeconds: existing.totalSeconds + editor.totalSeconds,
|
||||
digital: _formatTime(existing.totalSeconds + editor.totalSeconds),
|
||||
decimal: ((existing.totalSeconds + editor.totalSeconds) / 3600)
|
||||
.toStringAsFixed(2),
|
||||
text: _formatTimeText(existing.totalSeconds + editor.totalSeconds),
|
||||
hours:
|
||||
((existing.totalSeconds + editor.totalSeconds) / 3600).floor(),
|
||||
minutes: ((existing.totalSeconds + editor.totalSeconds) % 3600 / 60)
|
||||
.floor(),
|
||||
seconds:
|
||||
((existing.totalSeconds + editor.totalSeconds) % 60).floor(),
|
||||
percent: 0, // 将在计算完总和后更新
|
||||
);
|
||||
} else {
|
||||
aggregated[editor.name] = editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final totalSeconds = aggregated.values
|
||||
.fold<double>(0, (sum, editor) => sum + editor.totalSeconds);
|
||||
final result = aggregated.values
|
||||
.map((editor) => Editor(
|
||||
name: editor.name,
|
||||
totalSeconds: editor.totalSeconds,
|
||||
digital: editor.digital,
|
||||
decimal: editor.decimal,
|
||||
text: editor.text,
|
||||
hours: editor.hours,
|
||||
minutes: editor.minutes,
|
||||
seconds: editor.seconds,
|
||||
percent: (editor.totalSeconds / totalSeconds * 100),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 按使用时间排序
|
||||
result.sort((a, b) => b.totalSeconds.compareTo(a.totalSeconds));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 汇总项目数据
|
||||
List<Project> getAggregatedProjects() {
|
||||
final Map<String, Project> aggregated = {};
|
||||
|
||||
for (var dayData in data) {
|
||||
for (var project in dayData.projects) {
|
||||
if (aggregated.containsKey(project.name)) {
|
||||
final existing = aggregated[project.name]!;
|
||||
aggregated[project.name] = Project(
|
||||
name: project.name,
|
||||
totalSeconds: existing.totalSeconds + project.totalSeconds,
|
||||
color: project.color,
|
||||
digital: _formatTime(existing.totalSeconds + project.totalSeconds),
|
||||
decimal: ((existing.totalSeconds + project.totalSeconds) / 3600)
|
||||
.toStringAsFixed(2),
|
||||
text: _formatTimeText(existing.totalSeconds + project.totalSeconds),
|
||||
hours:
|
||||
((existing.totalSeconds + project.totalSeconds) / 3600).floor(),
|
||||
minutes:
|
||||
((existing.totalSeconds + project.totalSeconds) % 3600 / 60)
|
||||
.floor(),
|
||||
seconds:
|
||||
((existing.totalSeconds + project.totalSeconds) % 60).floor(),
|
||||
percent: 0, // 将在计算完总和后更新
|
||||
);
|
||||
} else {
|
||||
aggregated[project.name] = project;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final totalSeconds = aggregated.values
|
||||
.fold<double>(0, (sum, project) => sum + project.totalSeconds);
|
||||
final result = aggregated.values
|
||||
.map((project) => Project(
|
||||
name: project.name,
|
||||
totalSeconds: project.totalSeconds,
|
||||
color: project.color,
|
||||
digital: project.digital,
|
||||
decimal: project.decimal,
|
||||
text: project.text,
|
||||
hours: project.hours,
|
||||
minutes: project.minutes,
|
||||
seconds: project.seconds,
|
||||
percent: (project.totalSeconds / totalSeconds * 100),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 按使用时间排序
|
||||
result.sort((a, b) => b.totalSeconds.compareTo(a.totalSeconds));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 格式化时间为 HH:mm:ss
|
||||
String _formatTime(double seconds) {
|
||||
final hours = (seconds / 3600).floor();
|
||||
final minutes = ((seconds % 3600) / 60).floor();
|
||||
final secs = (seconds % 60).floor();
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// 格式化时间为可读文本
|
||||
String _formatTimeText(double seconds) {
|
||||
final hours = (seconds / 3600).floor();
|
||||
final minutes = ((seconds % 3600) / 60).floor();
|
||||
final secs = (seconds % 60).floor();
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours hr${hours > 1 ? 's' : ''} ${minutes > 0 ? '$minutes min${minutes > 1 ? 's' : ''}' : ''}';
|
||||
} else if (minutes > 0) {
|
||||
return '$minutes min${minutes > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return '$secs sec${secs > 1 ? 's' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SummaryData {
|
||||
final GrandTotal grandTotal;
|
||||
final Range range;
|
||||
final List<Project> projects;
|
||||
final List<Language> languages;
|
||||
final List<Editor> editors;
|
||||
final List<OperatingSystem> operatingSystems;
|
||||
final List<Category> categories;
|
||||
|
||||
SummaryData({
|
||||
required this.grandTotal,
|
||||
required this.range,
|
||||
required this.projects,
|
||||
required this.languages,
|
||||
required this.editors,
|
||||
required this.operatingSystems,
|
||||
required this.categories,
|
||||
});
|
||||
|
||||
factory SummaryData.fromJson(Map<String, dynamic> json) {
|
||||
return SummaryData(
|
||||
grandTotal: GrandTotal.fromJson(json['grand_total']),
|
||||
range: Range.fromJson(json['range']),
|
||||
projects:
|
||||
(json['projects'] as List).map((e) => Project.fromJson(e)).toList(),
|
||||
languages:
|
||||
(json['languages'] as List).map((e) => Language.fromJson(e)).toList(),
|
||||
editors:
|
||||
(json['editors'] as List).map((e) => Editor.fromJson(e)).toList(),
|
||||
operatingSystems: (json['operating_systems'] as List)
|
||||
.map((e) => OperatingSystem.fromJson(e))
|
||||
.toList(),
|
||||
categories: (json['categories'] as List)
|
||||
.map((e) => Category.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GrandTotal {
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final double totalSeconds;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
|
||||
GrandTotal({
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.totalSeconds,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
factory GrandTotal.fromJson(Map<String, dynamic> json) {
|
||||
return GrandTotal(
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Range {
|
||||
final String start;
|
||||
final String end;
|
||||
final String date;
|
||||
final String text;
|
||||
final String timezone;
|
||||
|
||||
Range({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.date,
|
||||
required this.text,
|
||||
required this.timezone,
|
||||
});
|
||||
|
||||
factory Range.fromJson(Map<String, dynamic> json) {
|
||||
return Range(
|
||||
start: json['start'],
|
||||
end: json['end'],
|
||||
date: json['date'],
|
||||
text: json['text'],
|
||||
timezone: json['timezone'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Project {
|
||||
final String name;
|
||||
final double totalSeconds;
|
||||
final String? color;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final int seconds;
|
||||
final double percent;
|
||||
|
||||
Project({
|
||||
required this.name,
|
||||
required this.totalSeconds,
|
||||
this.color,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.seconds,
|
||||
required this.percent,
|
||||
});
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) {
|
||||
return Project(
|
||||
name: json['name'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
color: json['color'],
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
seconds: json['seconds'],
|
||||
percent: json['percent'].toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Language {
|
||||
final String name;
|
||||
final double totalSeconds;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final int seconds;
|
||||
final double percent;
|
||||
|
||||
Language({
|
||||
required this.name,
|
||||
required this.totalSeconds,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.seconds,
|
||||
required this.percent,
|
||||
});
|
||||
|
||||
factory Language.fromJson(Map<String, dynamic> json) {
|
||||
return Language(
|
||||
name: json['name'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
seconds: json['seconds'],
|
||||
percent: json['percent'].toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Editor {
|
||||
final String name;
|
||||
final double totalSeconds;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final int seconds;
|
||||
final double percent;
|
||||
|
||||
Editor({
|
||||
required this.name,
|
||||
required this.totalSeconds,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.seconds,
|
||||
required this.percent,
|
||||
});
|
||||
|
||||
factory Editor.fromJson(Map<String, dynamic> json) {
|
||||
return Editor(
|
||||
name: json['name'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
seconds: json['seconds'],
|
||||
percent: json['percent'].toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OperatingSystem {
|
||||
final String name;
|
||||
final double totalSeconds;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final int seconds;
|
||||
final double percent;
|
||||
|
||||
OperatingSystem({
|
||||
required this.name,
|
||||
required this.totalSeconds,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.seconds,
|
||||
required this.percent,
|
||||
});
|
||||
|
||||
factory OperatingSystem.fromJson(Map<String, dynamic> json) {
|
||||
return OperatingSystem(
|
||||
name: json['name'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
seconds: json['seconds'],
|
||||
percent: json['percent'].toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Category {
|
||||
final String name;
|
||||
final double totalSeconds;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
final String text;
|
||||
final int hours;
|
||||
final int minutes;
|
||||
final int seconds;
|
||||
final double percent;
|
||||
|
||||
Category({
|
||||
required this.name,
|
||||
required this.totalSeconds,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
required this.text,
|
||||
required this.hours,
|
||||
required this.minutes,
|
||||
required this.seconds,
|
||||
required this.percent,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
name: json['name'],
|
||||
totalSeconds: json['total_seconds'].toDouble(),
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
text: json['text'],
|
||||
hours: json['hours'],
|
||||
minutes: json['minutes'],
|
||||
seconds: json['seconds'],
|
||||
percent: json['percent'].toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CumulativeTotal {
|
||||
final double seconds;
|
||||
final String text;
|
||||
final String digital;
|
||||
final String decimal;
|
||||
|
||||
CumulativeTotal({
|
||||
required this.seconds,
|
||||
required this.text,
|
||||
required this.digital,
|
||||
required this.decimal,
|
||||
});
|
||||
|
||||
factory CumulativeTotal.fromJson(Map<String, dynamic> json) {
|
||||
return CumulativeTotal(
|
||||
seconds: json['seconds'].toDouble(),
|
||||
text: json['text'],
|
||||
digital: json['digital'],
|
||||
decimal: json['decimal'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DailyAverage {
|
||||
final int holidays;
|
||||
final int daysMinusHolidays;
|
||||
final int daysIncludingHolidays;
|
||||
final int seconds;
|
||||
final int secondsIncludingOtherLanguage;
|
||||
final String text;
|
||||
final String textIncludingOtherLanguage;
|
||||
|
||||
DailyAverage({
|
||||
required this.holidays,
|
||||
required this.daysMinusHolidays,
|
||||
required this.daysIncludingHolidays,
|
||||
required this.seconds,
|
||||
required this.secondsIncludingOtherLanguage,
|
||||
required this.text,
|
||||
required this.textIncludingOtherLanguage,
|
||||
});
|
||||
|
||||
factory DailyAverage.fromJson(Map<String, dynamic> json) {
|
||||
return DailyAverage(
|
||||
holidays: json['holidays'],
|
||||
daysMinusHolidays: json['days_minus_holidays'],
|
||||
daysIncludingHolidays: json['days_including_holidays'],
|
||||
seconds: json['seconds'],
|
||||
secondsIncludingOtherLanguage: json['seconds_including_other_language'],
|
||||
text: json['text'],
|
||||
textIncludingOtherLanguage: json['text_including_other_language'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import '../widgets/gitea_card.dart';
|
||||
import '../widgets/kodbox_card.dart';
|
||||
import '../widgets/github_card.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'wakatime_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@ -16,7 +17,13 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _selectedIndex = 0;
|
||||
final List<String> _platforms = ['LeetCode', 'Gitea', 'KodBox', 'GitHub'];
|
||||
final List<String> _platforms = [
|
||||
'LeetCode',
|
||||
'Gitea',
|
||||
'KodBox',
|
||||
'GitHub',
|
||||
'Wakatime'
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -82,6 +89,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return Icons.folder;
|
||||
case 'GitHub':
|
||||
return Icons.code;
|
||||
case 'Wakatime':
|
||||
return Icons.timer;
|
||||
default:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
@ -97,6 +106,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return const KodBoxCard();
|
||||
case 'GitHub':
|
||||
return const GithubCard();
|
||||
case 'Wakatime':
|
||||
return const WakatimeScreen();
|
||||
default:
|
||||
return const Center(child: Text('未知平台'));
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _kodboxController = TextEditingController();
|
||||
final _githubUsernameController = TextEditingController();
|
||||
final _githubTokenController = TextEditingController();
|
||||
final _wakatimeTokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -34,6 +35,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_kodboxController.dispose();
|
||||
_githubUsernameController.dispose();
|
||||
_githubTokenController.dispose();
|
||||
_wakatimeTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -54,6 +56,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
authService.credentials['github_username'] ?? '';
|
||||
_githubTokenController.text =
|
||||
authService.credentials['github_token'] ?? '';
|
||||
_wakatimeTokenController.text =
|
||||
authService.credentials['wakatime_token'] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@ -97,6 +101,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_githubTokenController.text,
|
||||
);
|
||||
}
|
||||
if (_wakatimeTokenController.text.isNotEmpty) {
|
||||
await authService.saveConfigs(
|
||||
'wakatime_token',
|
||||
_wakatimeTokenController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
@ -206,6 +216,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPlatformSettings(
|
||||
title: 'Wakatime 设置',
|
||||
icon: Icons.timer,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _wakatimeTokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Token',
|
||||
hintText: '请输入 Wakatime API Token',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(onPressed: _saveSettings, child: const Text('保存设置')),
|
||||
],
|
||||
|
||||
337
lib/screens/wakatime_screen.dart
Normal file
337
lib/screens/wakatime_screen.dart
Normal file
@ -0,0 +1,337 @@
|
||||
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> {
|
||||
final WakatimeService _wakatimeService = WakatimeService();
|
||||
WakatimeSummary? _summary;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
final TextEditingController _startDateController = TextEditingController();
|
||||
final TextEditingController _endDateController = TextEditingController();
|
||||
int? _touchedIndex;
|
||||
|
||||
// 预定义的颜色列表
|
||||
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();
|
||||
// 设置默认日期为今天和昨天
|
||||
final now = DateTime.now();
|
||||
_startDateController.text =
|
||||
_formatDate(now.subtract(const Duration(days: 1)));
|
||||
_endDateController.text = _formatDate(now);
|
||||
}
|
||||
|
||||
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}分钟';
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLegend(List<dynamic> data, String Function(dynamic) getName) {
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final isTouched = index == _touchedIndex;
|
||||
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)})' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isTouched ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPieChart(
|
||||
List<dynamic> data, String title, String Function(dynamic) getName) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildLegend(data, getName),
|
||||
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;
|
||||
return;
|
||||
}
|
||||
_touchedIndex =
|
||||
pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||
});
|
||||
},
|
||||
),
|
||||
sections: data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final isTouched = index == _touchedIndex;
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@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: [
|
||||
// 日期选择
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _startDateController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '开始日期',
|
||||
hintText: 'YYYY-MM-DD',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _endDateController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '结束日期',
|
||||
hintText: 'YYYY-MM-DD',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 获取数据按钮
|
||||
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) ...[
|
||||
const Text(
|
||||
'总编码时间',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(_summary!.cumulativeTotal.text),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 语言使用饼图
|
||||
_buildPieChart(
|
||||
_summary!.getAggregatedLanguages(),
|
||||
'编程语言使用情况',
|
||||
(lang) => lang.name,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 编辑器使用饼图
|
||||
_buildPieChart(
|
||||
_summary!.getAggregatedEditors(),
|
||||
'编辑器使用情况',
|
||||
(editor) => editor.name,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 项目时间分布
|
||||
_buildProjectList(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_startDateController.dispose();
|
||||
_endDateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ class AuthService extends ChangeNotifier {
|
||||
final kodboxToken = await _storage.read(key: 'kodbox_token');
|
||||
final githubUserName = await _storage.read(key: 'github_username');
|
||||
final githubToken = await _storage.read(key: 'github_token');
|
||||
final wakatimeToken = await _storage.read(key: 'wakatime_token');
|
||||
|
||||
if (leetcodeCookie != null) {
|
||||
_credentials['leetcode_cookie'] = leetcodeCookie;
|
||||
@ -46,6 +47,9 @@ class AuthService extends ChangeNotifier {
|
||||
if (githubToken != null) {
|
||||
_credentials['github_token'] = githubToken;
|
||||
}
|
||||
if (wakatimeToken != null) {
|
||||
_credentials['wakatime_token'] = wakatimeToken;
|
||||
}
|
||||
_isAuthenticated = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
27
lib/services/wakatime_service.dart
Normal file
27
lib/services/wakatime_service.dart
Normal file
@ -0,0 +1,27 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/wakatime_summary.dart';
|
||||
|
||||
class WakatimeService {
|
||||
static const String baseUrl = 'https://wakatime.com/api/v1';
|
||||
|
||||
Future<WakatimeSummary> getSummary(
|
||||
String token,
|
||||
String startDate,
|
||||
String endDate,
|
||||
) async {
|
||||
final url = Uri.parse(
|
||||
'$baseUrl/users/current/summaries?start=$startDate&end=$endDate',
|
||||
);
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Basic ${base64Encode(utf8.encode(token))}'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return WakatimeSummary.fromJson(json.decode(response.body));
|
||||
} else {
|
||||
throw Exception('Failed to load summary: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
24
pubspec.lock
24
pubspec.lock
@ -81,6 +81,14 @@ packages:
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -113,6 +121,14 @@ packages:
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "5a74434cc83bf64346efb562f1a06eefaf1bcb530dc3d96a104f631a1eff8d79"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "0.65.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -130,10 +146,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "2.0.3"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -252,10 +268,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
version: "2.1.1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
sdk: '>=3.2.3 <4.0.0'
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
@ -33,14 +33,15 @@ dependencies:
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
http: ^1.2.0
|
||||
cupertino_icons: ^1.0.2
|
||||
http: ^1.1.0
|
||||
provider: ^6.1.1
|
||||
shared_preferences: ^2.2.2
|
||||
intl: ^0.19.0
|
||||
flutter_secure_storage: ^9.0.0
|
||||
cached_network_image: ^3.3.1
|
||||
timeago: ^3.7.1
|
||||
fl_chart: ^0.65.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -51,7 +52,7 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_lints: ^2.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user