Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2605d4c26 | |||
| 34a46dc698 | |||
| 3c2f52bcc8 | |||
| 6b64d1a0cd | |||
| 4d9a554f0d | |||
| 1db7ced409 | |||
| a4c1a2aeda | |||
| ab623efeb0 | |||
| f8aaf86cf8 | |||
| 85d741e8d1 | |||
| 4cf1b4208a | |||
| eaff7a309b | |||
| 569b4f4498 |
@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="dashboard"
|
||||
android:name="${applicationName}"
|
||||
|
||||
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 = [
|
||||
'Wakatime',
|
||||
'LeetCode',
|
||||
'GitHub',
|
||||
'Gitea',
|
||||
'KodBox',
|
||||
];
|
||||
|
||||
@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('未知平台'));
|
||||
}
|
||||
|
||||
@ -15,9 +15,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _leetcodeUserSlugController = TextEditingController();
|
||||
final _giteaController = TextEditingController();
|
||||
final _giteaUsernameController = TextEditingController();
|
||||
final _kodboxController = TextEditingController();
|
||||
final _kodboxUsernameController = TextEditingController();
|
||||
final _kodboxPasswordController = TextEditingController();
|
||||
final _githubUsernameController = TextEditingController();
|
||||
final _githubTokenController = TextEditingController();
|
||||
final _wakatimeTokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -31,9 +33,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_leetcodeUserSlugController.dispose();
|
||||
_giteaController.dispose();
|
||||
_giteaUsernameController.dispose();
|
||||
_kodboxController.dispose();
|
||||
_kodboxUsernameController.dispose();
|
||||
_kodboxPasswordController.dispose();
|
||||
_githubUsernameController.dispose();
|
||||
_githubTokenController.dispose();
|
||||
_wakatimeTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -49,11 +53,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_giteaController.text = authService.credentials['gitea_token'] ?? '';
|
||||
_giteaUsernameController.text =
|
||||
authService.credentials['gitea_username'] ?? '';
|
||||
_kodboxController.text = authService.credentials['kodbox_token'] ?? '';
|
||||
_kodboxUsernameController.text =
|
||||
authService.credentials['kodbox_username'] ?? '';
|
||||
_kodboxPasswordController.text =
|
||||
authService.credentials['kodbox_password'] ?? '';
|
||||
_githubUsernameController.text =
|
||||
authService.credentials['github_username'] ?? '';
|
||||
_githubTokenController.text =
|
||||
authService.credentials['github_token'] ?? '';
|
||||
_wakatimeTokenController.text =
|
||||
authService.credentials['wakatime_token'] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@ -82,8 +91,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_giteaUsernameController.text,
|
||||
);
|
||||
}
|
||||
if (_kodboxController.text.isNotEmpty) {
|
||||
await authService.saveConfigs('kodbox_token', _kodboxController.text);
|
||||
if (_kodboxUsernameController.text.isNotEmpty) {
|
||||
await authService.saveConfigs(
|
||||
'kodbox_username',
|
||||
_kodboxUsernameController.text,
|
||||
);
|
||||
}
|
||||
if (_kodboxPasswordController.text.isNotEmpty) {
|
||||
await authService.saveConfigs(
|
||||
'kodbox_password',
|
||||
_kodboxPasswordController.text,
|
||||
);
|
||||
}
|
||||
if (_githubUsernameController.text.isNotEmpty) {
|
||||
await authService.saveConfigs(
|
||||
@ -97,6 +115,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_githubTokenController.text,
|
||||
);
|
||||
}
|
||||
if (_wakatimeTokenController.text.isNotEmpty) {
|
||||
await authService.saveConfigs(
|
||||
'wakatime_token',
|
||||
_wakatimeTokenController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
@ -171,10 +195,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
icon: Icons.folder,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _kodboxController,
|
||||
controller: _kodboxUsernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Token',
|
||||
hintText: '请输入 KodBox API Token',
|
||||
labelText: '用户名',
|
||||
hintText: '请输入 KodBox 用户名',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _kodboxPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: '请输入 KodBox 密码',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
@ -206,6 +239,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('保存设置')),
|
||||
],
|
||||
|
||||
667
lib/screens/wakatime_screen.dart
Normal file
667
lib/screens/wakatime_screen.dart
Normal file
@ -0,0 +1,667 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,9 @@ 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');
|
||||
final kodboxUname = await _storage.read(key: 'kodbox_username');
|
||||
final kodboxPwd = await _storage.read(key: 'kodbox_password');
|
||||
|
||||
if (leetcodeCookie != null) {
|
||||
_credentials['leetcode_cookie'] = leetcodeCookie;
|
||||
@ -46,6 +49,15 @@ class AuthService extends ChangeNotifier {
|
||||
if (githubToken != null) {
|
||||
_credentials['github_token'] = githubToken;
|
||||
}
|
||||
if (wakatimeToken != null) {
|
||||
_credentials['wakatime_token'] = wakatimeToken;
|
||||
}
|
||||
if (kodboxUname != null) {
|
||||
_credentials['kodbox_username'] = kodboxUname;
|
||||
}
|
||||
if (kodboxPwd != null) {
|
||||
_credentials['kodbox_password'] = kodboxPwd;
|
||||
}
|
||||
_isAuthenticated = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
65
lib/services/kodbox_service.dart
Normal file
65
lib/services/kodbox_service.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'auth_service.dart';
|
||||
|
||||
class KodBoxService {
|
||||
static const String baseUrl = 'https://cloud.jdysya.top';
|
||||
|
||||
Future<String> getToken(String username, String password) async {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'$baseUrl/?user/index/loginSubmit&name=$username&password=$password'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['code'] == true) {
|
||||
return data['info'];
|
||||
} else {
|
||||
throw Exception('获取token失败: ${data['info'] ?? '未知错误'}');
|
||||
}
|
||||
} else {
|
||||
throw Exception('请求失败: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> validateToken(String token) async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/?admin/log/get&accessToken=$token'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['code'] != '10001';
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> getValidToken(AuthService authService) async {
|
||||
final username = authService.credentials['kodbox_username'];
|
||||
final password = authService.credentials['kodbox_password'];
|
||||
final currentToken = authService.credentials['kodbox_token'];
|
||||
|
||||
if (username == null || password == null) {
|
||||
throw Exception('请先在设置中配置 KodBox 用户名和密码');
|
||||
}
|
||||
|
||||
// 如果当前有token,先验证是否有效
|
||||
if (currentToken != null) {
|
||||
final isValid = await validateToken(currentToken);
|
||||
if (isValid) {
|
||||
return currentToken;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取新token
|
||||
final newToken = await getToken(username, password);
|
||||
await authService.saveConfigs('kodbox_token', newToken);
|
||||
return newToken;
|
||||
}
|
||||
}
|
||||
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}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,6 +125,13 @@ class _GiteaCardState extends State<GiteaCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.deepPurple.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.deepPurple.withOpacity(0.1)),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
|
||||
@ -101,6 +101,13 @@ class _GithubCardState extends State<GithubCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.deepPurple.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.deepPurple.withOpacity(0.1)),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/kodbox_service.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class KodBoxCard extends StatefulWidget {
|
||||
const KodBoxCard({super.key});
|
||||
@ -16,6 +20,7 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
List<dynamic> _logs = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
final _kodboxService = KodBoxService();
|
||||
|
||||
Future<void> _fetchLogs() async {
|
||||
setState(() {
|
||||
@ -25,11 +30,7 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final token = authService.credentials['kodbox_token'];
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('未配置 KodBox Token');
|
||||
}
|
||||
final token = await _kodboxService.getValidToken(authService);
|
||||
|
||||
// 计算时间范围(最近7天)
|
||||
final now = DateTime.now();
|
||||
@ -39,7 +40,7 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('https://cloud.jdysya.top/?admin/log/get&accessToken=$token'),
|
||||
Uri.parse('${KodBoxService.baseUrl}/?admin/log/get&accessToken=$token'),
|
||||
);
|
||||
|
||||
request.fields.addAll({
|
||||
@ -87,47 +88,119 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
return '未知路径';
|
||||
}
|
||||
|
||||
int? _getSourceID(Map<String, dynamic> desc) {
|
||||
if (desc['sourceInfo'] == false) {
|
||||
// 处理删除文件的情况
|
||||
return desc['parentInfo']?['sourceID'];
|
||||
} else if (desc['sourceInfo'] != null) {
|
||||
return desc['sourceInfo']?['sourceID'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _launchFileUrl(int sourceID) async {
|
||||
final url = Uri.parse('${KodBoxService.baseUrl}/#explorer&sidf=$sourceID');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法打开文件链接')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLogItem(Map<String, dynamic> log) {
|
||||
final desc = log['desc'];
|
||||
final isFileOperation =
|
||||
desc != null &&
|
||||
final isFileOperation = desc != null &&
|
||||
(desc['sourceInfo'] != null || desc['parentInfo'] != null);
|
||||
|
||||
final sourceID = isFileOperation ? _getSourceID(desc) : null;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
child: Text(log['nickName']?[0].toUpperCase() ?? '?'),
|
||||
elevation: 4,
|
||||
shadowColor: Colors.deepPurple.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.deepPurple.withOpacity(0.1)),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 5.0),
|
||||
child: InkWell(
|
||||
onTap: sourceID != null ? () => _launchFileUrl(sourceID) : null,
|
||||
splashColor: Colors.deepPurple.withAlpha(30),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.deepPurple.shade200,
|
||||
radius: 18,
|
||||
child: Text(
|
||||
log['nickName']?[0].toUpperCase() ?? '?',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
log['nickName'] ?? log['name'] ?? '未知用户',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (log['title'] != null)
|
||||
Text(
|
||||
log['title']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isFileOperation) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.insert_drive_file,
|
||||
size: 18, color: Colors.deepPurple),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'文件路径: ${_getFilePath(desc)}',
|
||||
style: TextStyle(
|
||||
color: sourceID != null ? Colors.blue : Colors.black,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
log['nickName'] ?? log['name'] ?? '未知用户',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(log['title'] ?? '未知操作'),
|
||||
],
|
||||
),
|
||||
if (isFileOperation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'文件路径: ${_getFilePath(desc)}',
|
||||
style: const TextStyle(color: Colors.blue),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.access_time, size: 16, color: Colors.grey),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(int.parse(log['createTime']) * 1000), locale: 'zh_CN')}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(int.parse(log['createTime']) * 1000), locale: 'zh_CN')}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -142,6 +215,13 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: Colors.deepPurple.withOpacity(0.15)),
|
||||
),
|
||||
elevation: 6,
|
||||
shadowColor: Colors.deepPurple.withOpacity(0.4),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
@ -152,36 +232,60 @@ class _KodBoxCardState extends State<KodBoxCard> {
|
||||
children: [
|
||||
const Text(
|
||||
'KodBox 最近操作',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _fetchLogs,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepPurple.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.deepPurple),
|
||||
onPressed: _isLoading ? null : _fetchLogs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: CircularProgressIndicator(color: Colors.deepPurple),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_logs.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('暂无操作记录'),
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'暂无操作记录',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
|
||||
@ -12,7 +12,10 @@ class LeetCodeCard extends StatefulWidget {
|
||||
State<LeetCodeCard> createState() => _LeetCodeCardState();
|
||||
}
|
||||
|
||||
class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
class _LeetCodeCardState extends State<LeetCodeCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _refreshController;
|
||||
|
||||
List<dynamic> _submissions = [];
|
||||
Map<String, dynamic>? _progress;
|
||||
bool _isLoading = false;
|
||||
@ -118,6 +121,10 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchData();
|
||||
_refreshController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCard() {
|
||||
@ -128,33 +135,86 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
_progress!['totalQuestionBeatsPercentage'] as double;
|
||||
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'解题进度',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.bar_chart, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'解题进度',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
...acceptedQuestions.map((q) {
|
||||
final difficulty = q['difficulty'] as String;
|
||||
final count = q['count'] as int;
|
||||
Color difficultyColor = Colors.grey;
|
||||
if (difficulty == 'EASY')
|
||||
difficultyColor = Colors.green;
|
||||
else if (difficulty == 'MEDIUM')
|
||||
difficultyColor = Colors.orange;
|
||||
else if (difficulty == 'HARD') difficultyColor = Colors.red;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [Text(difficulty), Text('$count 题')],
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: difficultyColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
difficulty == 'EASY'
|
||||
? '简单'
|
||||
: difficulty == 'MEDIUM'
|
||||
? '中等'
|
||||
: '困难',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('$count 题', style: const TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
}).toList(),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('击败用户'),
|
||||
Text('${totalBeatsPercentage.toStringAsFixed(1)}%'),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.workspace_premium,
|
||||
size: 18, color: Colors.green),
|
||||
SizedBox(width: 6),
|
||||
Text('击败用户', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Text('${totalBeatsPercentage.toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Colors.green)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -166,6 +226,13 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.deepPurple.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.deepPurple.withOpacity(0.1)),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
@ -176,28 +243,68 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
children: [
|
||||
const Text(
|
||||
'LeetCode 最近提交',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _fetchData,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: RotationTransition(
|
||||
turns: _refreshController,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh,
|
||||
size: 24, color: Colors.green),
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () async {
|
||||
_refreshController.forward(from: 0.0);
|
||||
await _fetchData();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: AnimatedBuilder(
|
||||
animation: _refreshController,
|
||||
builder: (_, child) => Transform.rotate(
|
||||
angle: _refreshController.value * 2 * 3.14,
|
||||
child: child,
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.green,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -212,7 +319,11 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('暂无提交记录'),
|
||||
child: Text(
|
||||
'暂无提交记录',
|
||||
style:
|
||||
TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
@ -224,16 +335,40 @@ class _LeetCodeCardState extends State<LeetCodeCard> {
|
||||
final submission = _submissions[index];
|
||||
final question = submission['question'];
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
question['translatedTitle'] ??
|
||||
question['title'],
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
subtitle: Text(
|
||||
'提交时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(submission['submitTime'] * 1000), locale: 'zh_CN')}',
|
||||
),
|
||||
trailing: Text(
|
||||
'#${question['questionFrontendId']}',
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.green,
|
||||
radius: 16,
|
||||
child: Text(
|
||||
question['questionFrontendId'].toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
question['translatedTitle'] ??
|
||||
question['title'],
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'提交时间: ${timeago.format(DateTime.fromMillisecondsSinceEpoch(submission['submitTime'] * 1000), locale: 'zh_CN')}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -7,9 +7,13 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@ -9,10 +9,12 @@ import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
88
pubspec.lock
88
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:
|
||||
@ -565,6 +581,70 @@ packages:
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "6.3.1"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "6.3.16"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
12
pubspec.yaml
12
pubspec.yaml
@ -16,10 +16,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.1.0+2
|
||||
|
||||
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,16 @@ 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
|
||||
url_launcher: ^6.2.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -51,7 +53,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
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user