Remade frontend dashboard as flutter dashboard, still WIP

This commit is contained in:
Nate Anderson
2025-06-13 09:20:42 -06:00
parent 8ea06b12f7
commit b373a93f0e
207 changed files with 9869 additions and 46 deletions
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'src/services/api_service.dart';
import 'src/services/dashboard_provider.dart';
import 'src/screens/dashboard_screen.dart';
import 'src/theme/app_theme.dart';
void main() {
runApp(const XPDashboardApp());
}
class XPDashboardApp extends StatelessWidget {
const XPDashboardApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<ApiService>(
create: (_) => ApiService(),
dispose: (_, apiService) => apiService.dispose(),
),
ChangeNotifierProxyProvider<ApiService, DashboardProvider>(
create: (context) => DashboardProvider(context.read<ApiService>()),
update: (context, apiService, previous) =>
previous ?? DashboardProvider(apiService),
),
],
child: MaterialApp(
title: 'XP Nix Dashboard',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
home: const DashboardScreen(),
debugShowCheckedModeBanner: false,
),
);
}
}
@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:xp_models/xp_models.dart';
import '../services/dashboard_provider.dart';
import '../widgets/stats_header.dart';
import '../widgets/progress_card.dart';
import '../widgets/xp_chart.dart';
import '../widgets/recent_activity_card.dart';
import '../widgets/xp_breakdown_card.dart';
import '../widgets/achievements_card.dart';
import '../widgets/config_card.dart';
import '../widgets/classification_card.dart';
import '../widgets/logs_card.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<DashboardProvider>().initialize();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('🎮 XP Nix Dashboard'),
actions: [
Consumer<DashboardProvider>(
builder: (context, provider, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// WebSocket connection indicator
Container(
width: 12,
height: 12,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.isWebSocketConnected
? Colors.green
: Colors.red,
),
),
Text(
provider.isWebSocketConnected ? 'Live' : 'Offline',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.white70,
),
),
const SizedBox(width: 16),
// Refresh button
IconButton(
icon: provider.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.refresh),
onPressed: provider.isLoading
? null
: () => provider.refresh(),
tooltip: 'Refresh Dashboard',
),
],
);
},
),
],
),
body: Consumer<DashboardProvider>(
builder: (context, provider, child) {
if (provider.isLoading && provider.stats == null) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading dashboard...'),
],
),
);
}
if (provider.error != null && provider.stats == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
'Failed to load dashboard',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
provider.error!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => provider.refresh(),
child: const Text('Retry'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => provider.refresh(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stats Header
StatsHeader(stats: provider.stats),
const SizedBox(height: 24),
// Dashboard Grid
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 1200;
final isMedium = constraints.maxWidth > 800;
if (isWide) {
return _buildWideLayout(provider);
} else if (isMedium) {
return _buildMediumLayout(provider);
} else {
return _buildNarrowLayout(provider);
}
},
),
],
),
),
);
},
),
);
}
Widget _buildWideLayout(DashboardProvider provider) {
return Column(
children: [
// First row: Progress, Chart, Recent Activity
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 1,
child: ProgressCard(stats: provider.stats),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: XPChart(history: provider.statsHistory),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: RecentActivityCard(activities: provider.stats?.recentActivity ?? []),
),
],
),
const SizedBox(height: 16),
// Second row: XP Breakdown, Achievements
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 1,
child: XPBreakdownCard(breakdown: provider.xpBreakdown),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: AchievementsCard(achievements: provider.achievements),
),
],
),
const SizedBox(height: 16),
// Third row: Config, Classifications, Logs
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 1,
child: ConfigCard(
config: provider.config,
onConfigUpdate: provider.updateConfig,
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: ClassificationCard(
classifications: provider.classifications,
unclassified: provider.unclassified,
onSaveClassification: provider.saveClassification,
onDeleteClassification: provider.deleteClassification,
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: LogsCard(
logs: provider.logs,
onRefresh: provider.loadLogs,
),
),
],
),
],
);
}
Widget _buildMediumLayout(DashboardProvider provider) {
return Column(
children: [
// First row: Progress, Chart
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 1,
child: ProgressCard(stats: provider.stats),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: XPChart(history: provider.statsHistory),
),
],
),
const SizedBox(height: 16),
// Second row: Recent Activity, XP Breakdown
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: RecentActivityCard(activities: provider.stats?.recentActivity ?? []),
),
const SizedBox(width: 16),
Expanded(
child: XPBreakdownCard(breakdown: provider.xpBreakdown),
),
],
),
const SizedBox(height: 16),
// Third row: Achievements, Config
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AchievementsCard(achievements: provider.achievements),
),
const SizedBox(width: 16),
Expanded(
child: ConfigCard(
config: provider.config,
onConfigUpdate: provider.updateConfig,
),
),
],
),
const SizedBox(height: 16),
// Fourth row: Classifications, Logs
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClassificationCard(
classifications: provider.classifications,
unclassified: provider.unclassified,
onSaveClassification: provider.saveClassification,
onDeleteClassification: provider.deleteClassification,
),
),
const SizedBox(width: 16),
Expanded(
child: LogsCard(
logs: provider.logs,
onRefresh: provider.loadLogs,
),
),
],
),
],
);
}
Widget _buildNarrowLayout(DashboardProvider provider) {
return Column(
children: [
ProgressCard(stats: provider.stats),
const SizedBox(height: 16),
XPChart(history: provider.statsHistory),
const SizedBox(height: 16),
RecentActivityCard(activities: provider.stats?.recentActivity ?? []),
const SizedBox(height: 16),
XPBreakdownCard(breakdown: provider.xpBreakdown),
const SizedBox(height: 16),
AchievementsCard(achievements: provider.achievements),
const SizedBox(height: 16),
ConfigCard(
config: provider.config,
onConfigUpdate: provider.updateConfig,
),
const SizedBox(height: 16),
ClassificationCard(
classifications: provider.classifications,
unclassified: provider.unclassified,
onSaveClassification: provider.saveClassification,
onDeleteClassification: provider.deleteClassification,
),
const SizedBox(height: 16),
LogsCard(
logs: provider.logs,
onRefresh: provider.loadLogs,
),
],
);
}
}
@@ -0,0 +1,244 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:xp_models/xp_models.dart';
class ApiService {
static const String _baseUrl = 'http://[::1]:8080';
static const String _wsUrl = 'ws://[::1]:8080/ws';
final http.Client _httpClient;
WebSocketChannel? _wsChannel;
ApiService({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client();
// HTTP API Methods
Future<DashboardStats> getStats() async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/stats'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return DashboardStats.fromJson(json);
} else {
throw ApiException('Failed to get stats: ${response.statusCode}');
}
}
Future<List<StatsHistory>> getStatsHistory({int days = 7}) async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/stats/history?days=$days'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => StatsHistory.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get stats history: ${response.statusCode}');
}
}
Future<List<Achievement>> getAchievements({int limit = 5}) async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/achievements?limit=$limit'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => Achievement.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get achievements: ${response.statusCode}');
}
}
Future<List<Activity>> getActivities({int limit = 100}) async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/activities?limit=$limit'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => Activity.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get activities: ${response.statusCode}');
}
}
Future<List<FocusSession>> getFocusSessions({int limit = 50}) async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/focus-sessions?limit=$limit'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => FocusSession.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get focus sessions: ${response.statusCode}');
}
}
Future<Map<String, int>> getXPBreakdown({String? date}) async {
final url = date != null
? '$_baseUrl/api/xp-breakdown?date=$date'
: '$_baseUrl/api/xp-breakdown';
final response = await _httpClient.get(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int));
} else {
throw ApiException('Failed to get XP breakdown: ${response.statusCode}');
}
}
Future<SystemLogResponse> getLogs({int count = 100, LogLevel? level}) async {
final url = level != null
? '$_baseUrl/api/logs?count=$count&level=${level.name}'
: '$_baseUrl/api/logs?count=$count';
final response = await _httpClient.get(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return SystemLogResponse.fromJson(json);
} else {
throw ApiException('Failed to get logs: ${response.statusCode}');
}
}
Future<Map<String, dynamic>> getConfig() async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/config'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
throw ApiException('Failed to get config: ${response.statusCode}');
}
}
Future<void> updateConfig(Map<String, dynamic> updates) async {
final response = await _httpClient.post(
Uri.parse('$_baseUrl/api/config'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(updates),
);
if (response.statusCode != 200) {
throw ApiException('Failed to update config: ${response.statusCode}');
}
}
Future<List<ApplicationClassification>> getClassifications() async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/classifications'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => ApplicationClassification.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get classifications: ${response.statusCode}');
}
}
Future<void> saveClassification(ClassificationRequest request) async {
final response = await _httpClient.post(
Uri.parse('$_baseUrl/api/classifications'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(request.toJson()),
);
if (response.statusCode != 200) {
throw ApiException('Failed to save classification: ${response.statusCode}');
}
}
Future<void> deleteClassification(String applicationName) async {
final encodedName = Uri.encodeComponent(applicationName);
final response = await _httpClient.delete(
Uri.parse('$_baseUrl/api/classifications/$encodedName'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode != 200) {
throw ApiException('Failed to delete classification: ${response.statusCode}');
}
}
Future<List<UnclassifiedApplication>> getUnclassified() async {
final response = await _httpClient.get(
Uri.parse('$_baseUrl/api/unclassified'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List<dynamic>;
return json.map((item) => UnclassifiedApplication.fromJson(item as Map<String, dynamic>)).toList();
} else {
throw ApiException('Failed to get unclassified applications: ${response.statusCode}');
}
}
// WebSocket Methods
WebSocketChannel connectWebSocket() {
_wsChannel?.sink.close();
_wsChannel = WebSocketChannel.connect(Uri.parse(_wsUrl));
return _wsChannel!;
}
void disconnectWebSocket() {
_wsChannel?.sink.close();
_wsChannel = null;
}
Stream<WebSocketMessage> get webSocketStream {
if (_wsChannel == null) {
throw StateError('WebSocket not connected. Call connectWebSocket() first.');
}
return _wsChannel!.stream.map((data) {
final json = jsonDecode(data as String) as Map<String, dynamic>;
return WebSocketMessage.fromJson(json);
});
}
void sendWebSocketMessage(WebSocketMessage message) {
if (_wsChannel == null) {
throw StateError('WebSocket not connected. Call connectWebSocket() first.');
}
_wsChannel!.sink.add(jsonEncode(message.toJson()));
}
void dispose() {
_httpClient.close();
disconnectWebSocket();
}
}
class ApiException implements Exception {
final String message;
ApiException(this.message);
@override
String toString() => 'ApiException: $message';
}
@@ -0,0 +1,420 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:xp_models/xp_models.dart';
import 'api_service.dart';
class DashboardProvider extends ChangeNotifier {
final ApiService _apiService;
// State variables
DashboardStats? _stats;
List<StatsHistory> _statsHistory = [];
List<Achievement> _achievements = [];
List<Activity> _activities = [];
List<FocusSession> _focusSessions = [];
Map<String, int> _xpBreakdown = {};
SystemLogResponse? _logs;
Map<String, dynamic> _config = {};
List<ApplicationClassification> _classifications = [];
List<UnclassifiedApplication> _unclassified = [];
// Loading states
bool _isLoading = false;
bool _isStatsLoading = false;
bool _isHistoryLoading = false;
bool _isAchievementsLoading = false;
bool _isActivitiesLoading = false;
bool _isFocusSessionsLoading = false;
bool _isXpBreakdownLoading = false;
bool _isLogsLoading = false;
bool _isConfigLoading = false;
bool _isClassificationsLoading = false;
bool _isUnclassifiedLoading = false;
// Error states
String? _error;
String? _statsError;
String? _historyError;
String? _achievementsError;
String? _activitiesError;
String? _focusSessionsError;
String? _xpBreakdownError;
String? _logsError;
String? _configError;
String? _classificationsError;
String? _unclassifiedError;
// WebSocket
StreamSubscription<WebSocketMessage>? _wsSubscription;
bool _isWebSocketConnected = false;
DashboardProvider(this._apiService);
// Getters
DashboardStats? get stats => _stats;
List<StatsHistory> get statsHistory => _statsHistory;
List<Achievement> get achievements => _achievements;
List<Activity> get activities => _activities;
List<FocusSession> get focusSessions => _focusSessions;
Map<String, int> get xpBreakdown => _xpBreakdown;
SystemLogResponse? get logs => _logs;
Map<String, dynamic> get config => _config;
List<ApplicationClassification> get classifications => _classifications;
List<UnclassifiedApplication> get unclassified => _unclassified;
// Loading state getters
bool get isLoading => _isLoading;
bool get isStatsLoading => _isStatsLoading;
bool get isHistoryLoading => _isHistoryLoading;
bool get isAchievementsLoading => _isAchievementsLoading;
bool get isActivitiesLoading => _isActivitiesLoading;
bool get isFocusSessionsLoading => _isFocusSessionsLoading;
bool get isXpBreakdownLoading => _isXpBreakdownLoading;
bool get isLogsLoading => _isLogsLoading;
bool get isConfigLoading => _isConfigLoading;
bool get isClassificationsLoading => _isClassificationsLoading;
bool get isUnclassifiedLoading => _isUnclassifiedLoading;
// Error getters
String? get error => _error;
String? get statsError => _statsError;
String? get historyError => _historyError;
String? get achievementsError => _achievementsError;
String? get activitiesError => _activitiesError;
String? get focusSessionsError => _focusSessionsError;
String? get xpBreakdownError => _xpBreakdownError;
String? get logsError => _logsError;
String? get configError => _configError;
String? get classificationsError => _classificationsError;
String? get unclassifiedError => _unclassifiedError;
bool get isWebSocketConnected => _isWebSocketConnected;
// Initialize dashboard data
Future<void> initialize() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
await Future.wait([
loadStats(),
loadStatsHistory(),
loadAchievements(),
loadActivities(),
loadFocusSessions(),
loadXPBreakdown(),
loadConfig(),
loadClassifications(),
loadUnclassified(),
]);
// Connect WebSocket after initial data load
connectWebSocket();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
// Load individual data sections
Future<void> loadStats() async {
_isStatsLoading = true;
_statsError = null;
notifyListeners();
try {
_stats = await _apiService.getStats();
} catch (e) {
_statsError = e.toString();
} finally {
_isStatsLoading = false;
notifyListeners();
}
}
Future<void> loadStatsHistory({int days = 7}) async {
_isHistoryLoading = true;
_historyError = null;
notifyListeners();
try {
_statsHistory = await _apiService.getStatsHistory(days: days);
} catch (e) {
_historyError = e.toString();
} finally {
_isHistoryLoading = false;
notifyListeners();
}
}
Future<void> loadAchievements({int limit = 5}) async {
_isAchievementsLoading = true;
_achievementsError = null;
notifyListeners();
try {
_achievements = await _apiService.getAchievements(limit: limit);
} catch (e) {
_achievementsError = e.toString();
} finally {
_isAchievementsLoading = false;
notifyListeners();
}
}
Future<void> loadActivities({int limit = 100}) async {
_isActivitiesLoading = true;
_activitiesError = null;
notifyListeners();
try {
_activities = await _apiService.getActivities(limit: limit);
} catch (e) {
_activitiesError = e.toString();
} finally {
_isActivitiesLoading = false;
notifyListeners();
}
}
Future<void> loadFocusSessions({int limit = 50}) async {
_isFocusSessionsLoading = true;
_focusSessionsError = null;
notifyListeners();
try {
_focusSessions = await _apiService.getFocusSessions(limit: limit);
} catch (e) {
_focusSessionsError = e.toString();
} finally {
_isFocusSessionsLoading = false;
notifyListeners();
}
}
Future<void> loadXPBreakdown({String? date}) async {
_isXpBreakdownLoading = true;
_xpBreakdownError = null;
notifyListeners();
try {
_xpBreakdown = await _apiService.getXPBreakdown(date: date);
} catch (e) {
_xpBreakdownError = e.toString();
} finally {
_isXpBreakdownLoading = false;
notifyListeners();
}
}
Future<void> loadLogs({int count = 100, LogLevel? level}) async {
_isLogsLoading = true;
_logsError = null;
notifyListeners();
try {
_logs = await _apiService.getLogs(count: count, level: level);
} catch (e) {
_logsError = e.toString();
} finally {
_isLogsLoading = false;
notifyListeners();
}
}
Future<void> loadConfig() async {
_isConfigLoading = true;
_configError = null;
notifyListeners();
try {
_config = await _apiService.getConfig();
} catch (e) {
_configError = e.toString();
} finally {
_isConfigLoading = false;
notifyListeners();
}
}
Future<void> updateConfig(Map<String, dynamic> updates) async {
try {
await _apiService.updateConfig(updates);
await loadConfig(); // Reload config after update
} catch (e) {
_configError = e.toString();
notifyListeners();
rethrow;
}
}
Future<void> loadClassifications() async {
_isClassificationsLoading = true;
_classificationsError = null;
notifyListeners();
try {
_classifications = await _apiService.getClassifications();
} catch (e) {
_classificationsError = e.toString();
} finally {
_isClassificationsLoading = false;
notifyListeners();
}
}
Future<void> loadUnclassified() async {
_isUnclassifiedLoading = true;
_unclassifiedError = null;
notifyListeners();
try {
_unclassified = await _apiService.getUnclassified();
} catch (e) {
_unclassifiedError = e.toString();
} finally {
_isUnclassifiedLoading = false;
notifyListeners();
}
}
Future<void> saveClassification(String applicationName, String categoryId) async {
try {
final request = ClassificationRequest(
applicationName: applicationName,
categoryId: categoryId,
);
await _apiService.saveClassification(request);
// Reload classifications and unclassified after saving
await Future.wait([
loadClassifications(),
loadUnclassified(),
]);
} catch (e) {
_classificationsError = e.toString();
notifyListeners();
rethrow;
}
}
Future<void> deleteClassification(String applicationName) async {
try {
await _apiService.deleteClassification(applicationName);
// Reload classifications and unclassified after deletion
await Future.wait([
loadClassifications(),
loadUnclassified(),
]);
} catch (e) {
_classificationsError = e.toString();
notifyListeners();
rethrow;
}
}
// WebSocket methods
void connectWebSocket() {
try {
_apiService.connectWebSocket();
_wsSubscription = _apiService.webSocketStream.listen(
_handleWebSocketMessage,
onError: _handleWebSocketError,
onDone: _handleWebSocketDone,
);
_isWebSocketConnected = true;
notifyListeners();
} catch (e) {
debugPrint('Failed to connect WebSocket: $e');
}
}
void disconnectWebSocket() {
_wsSubscription?.cancel();
_wsSubscription = null;
_apiService.disconnectWebSocket();
_isWebSocketConnected = false;
notifyListeners();
}
void _handleWebSocketMessage(WebSocketMessage message) {
switch (message.type) {
case WebSocketMessageType.statsUpdate:
if (message.data != null) {
_stats = DashboardStats.fromJson(message.data!);
notifyListeners();
}
break;
case WebSocketMessageType.xpBreakdownUpdate:
if (message.data != null) {
_xpBreakdown = message.data!.map((key, value) => MapEntry(key, value as int));
notifyListeners();
}
break;
case WebSocketMessageType.achievementUnlocked:
// Reload achievements when a new one is unlocked
loadAchievements();
break;
case WebSocketMessageType.levelUp:
// Reload stats when level changes
loadStats();
break;
case WebSocketMessageType.focusSessionComplete:
// Reload focus sessions and stats
Future.wait([
loadFocusSessions(),
loadStats(),
]);
break;
case WebSocketMessageType.ping:
// Respond to ping with pong
_apiService.sendWebSocketMessage(WebSocketMessage.pong());
break;
case WebSocketMessageType.pong:
// Handle pong response (optional)
break;
}
}
void _handleWebSocketError(error) {
debugPrint('WebSocket error: $error');
_isWebSocketConnected = false;
notifyListeners();
// Try to reconnect after a delay
Timer(const Duration(seconds: 5), () {
if (!_isWebSocketConnected) {
connectWebSocket();
}
});
}
void _handleWebSocketDone() {
debugPrint('WebSocket connection closed');
_isWebSocketConnected = false;
notifyListeners();
// Try to reconnect after a delay
Timer(const Duration(seconds: 5), () {
if (!_isWebSocketConnected) {
connectWebSocket();
}
});
}
// Refresh all data
Future<void> refresh() async {
await initialize();
}
@override
void dispose() {
disconnectWebSocket();
_apiService.dispose();
super.dispose();
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color primaryColor = Color(0xFF667eea);
static const Color secondaryColor = Color(0xFF764ba2);
static const Color accentColor = Color(0xFF4facfe);
static const Color successColor = Color(0xFF4CAF50);
static const Color warningColor = Color(0xFFFF9800);
static const Color errorColor = Color(0xFFF44336);
static const Color infoColor = Color(0xFF2196F3);
static const Color backgroundLight = Color(0xFFF5F7FA);
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color cardLight = Color(0xFFFFFFFF);
static const Color backgroundDark = Color(0xFF121212);
static const Color surfaceDark = Color(0xFF1E1E1E);
static const Color cardDark = Color(0xFF2D2D2D);
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light,
),
scaffoldBackgroundColor: backgroundLight,
cardTheme: CardThemeData(
color: cardLight,
elevation: 2,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
appBarTheme: const AppBarTheme(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: errorColor),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: errorColor, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.grey.shade100,
selectedColor: primaryColor.withOpacity(0.2),
labelStyle: const TextStyle(color: Colors.black87),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
static ThemeData darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark,
),
scaffoldBackgroundColor: backgroundDark,
cardTheme: CardThemeData(
color: cardDark,
elevation: 4,
shadowColor: Colors.black.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
appBarTheme: const AppBarTheme(
backgroundColor: surfaceDark,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade600),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade600),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: errorColor),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: errorColor, width: 2),
),
filled: true,
fillColor: Colors.grey.shade800,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.grey.shade800,
selectedColor: primaryColor.withOpacity(0.3),
labelStyle: const TextStyle(color: Colors.white70),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
// Custom gradient decorations
static const LinearGradient primaryGradient = LinearGradient(
colors: [primaryColor, secondaryColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const LinearGradient accentGradient = LinearGradient(
colors: [accentColor, primaryColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
// XP source colors
static const Map<String, Color> xpSourceColors = {
'coding': Color(0xFF4CAF50),
'focused_browsing': Color(0xFF2196F3),
'collaboration': Color(0xFFFF9800),
'meetings': Color(0xFF9C27B0),
'misc': Color(0xFF607D8B),
'uncategorized': Color(0xFF795548),
'focus_session': Color(0xFFE91E63),
'achievement': Color(0xFFFFD700),
'manual_boost': Color(0xFF00BCD4),
};
static Color getXPSourceColor(String source) {
return xpSourceColors[source] ?? Colors.grey;
}
// Activity type colors
static Color getActivityTypeColor(String type) {
switch (type.toLowerCase()) {
case 'coding':
return const Color(0xFF4CAF50);
case 'focused_browsing':
return const Color(0xFF2196F3);
case 'collaboration':
return const Color(0xFFFF9800);
case 'meetings':
return const Color(0xFF9C27B0);
case 'misc':
return const Color(0xFF607D8B);
default:
return Colors.grey;
}
}
// Log level colors
static Color getLogLevelColor(String logEntry) {
if (logEntry.contains('[ERROR]')) return errorColor;
if (logEntry.contains('[WARN]')) return warningColor;
if (logEntry.contains('[INFO]')) return infoColor;
if (logEntry.contains('[DEBUG]')) return Colors.grey;
return Colors.grey;
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class AchievementsCard extends StatelessWidget {
final List<Achievement> achievements;
const AchievementsCard({
super.key,
required this.achievements,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.emoji_events, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Recent Achievements',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
if (achievements.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No achievements yet'),
),
)
else
...achievements.take(3).map((achievement) => _AchievementItem(achievement: achievement)),
],
),
),
);
}
}
class _AchievementItem extends StatelessWidget {
final Achievement achievement;
const _AchievementItem({required this.achievement});
@override
Widget build(BuildContext context) {
final achievedDate = achievement.achievedAt != null
? DateTime.parse(achievement.achievedAt!)
: null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.emoji_events,
color: AppTheme.successColor,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
achievement.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
achievement.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
if (achievedDate != null)
Text(
'+${achievement.xpReward} XP • ${_formatDate(achievedDate)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.successColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
String _formatDate(DateTime date) {
return '${date.month}/${date.day}/${date.year}';
}
}
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class ClassificationCard extends StatefulWidget {
final List<ApplicationClassification> classifications;
final List<UnclassifiedApplication> unclassified;
final Future<void> Function(String, String) onSaveClassification;
final Future<void> Function(String) onDeleteClassification;
const ClassificationCard({
super.key,
required this.classifications,
required this.unclassified,
required this.onSaveClassification,
required this.onDeleteClassification,
});
@override
State<ClassificationCard> createState() => _ClassificationCardState();
}
class _ClassificationCardState extends State<ClassificationCard> with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.category, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'App Classifications',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: Colors.grey,
indicatorColor: AppTheme.primaryColor,
tabs: [
Tab(text: 'Classified (${widget.classifications.length})'),
Tab(text: 'Unclassified (${widget.unclassified.length})'),
],
),
const SizedBox(height: 16),
SizedBox(
height: 400,
child: TabBarView(controller: _tabController, children: [_buildClassifiedTab(), _buildUnclassifiedTab()]),
),
],
),
),
);
}
Widget _buildClassifiedTab() {
if (widget.classifications.isEmpty) {
return const Center(child: Text('No classified applications yet'));
}
return ListView.builder(
itemCount: widget.classifications.length,
itemBuilder: (context, index) {
final classification = widget.classifications[index];
return _ClassifiedItem(classification: classification, onDelete: widget.onDeleteClassification);
},
);
}
Widget _buildUnclassifiedTab() {
if (widget.unclassified.isEmpty) {
return const Center(child: Text('No unclassified applications'));
}
return ListView.builder(
itemCount: widget.unclassified.length,
itemBuilder: (context, index) {
final app = widget.unclassified[index];
return _UnclassifiedItem(app: app, onClassify: widget.onSaveClassification);
},
);
}
}
class _ClassifiedItem extends StatelessWidget {
final ApplicationClassification classification;
final Future<void> Function(String) onDelete;
const _ClassifiedItem({required this.classification, required this.onDelete});
@override
Widget build(BuildContext context) {
final categoryIcon = _getCategoryIcon(classification.categoryId);
final categoryName = _formatCategoryName(classification.categoryId);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: Text(categoryIcon, style: const TextStyle(fontSize: 20)),
title: Text(classification.applicationName),
subtitle: Text(categoryName),
trailing: IconButton(
icon: const Icon(Icons.delete, color: AppTheme.errorColor),
onPressed: () => _showDeleteDialog(context),
),
),
);
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete Classification'),
content: Text('Are you sure you want to remove the classification for ${classification.applicationName}?'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onDelete(classification.applicationName);
},
child: const Text('Delete'),
),
],
),
);
}
String _getCategoryIcon(String categoryId) {
const icons = {
'coding': '💻',
'focused_browsing': '🔍',
'collaboration': '🤝',
'meetings': '📅',
'misc': '📝',
'uncategorized': '',
};
return icons[categoryId] ?? '📊';
}
String _formatCategoryName(String categoryId) {
const names = {
'coding': 'Coding',
'focused_browsing': 'Focused Browsing',
'collaboration': 'Collaboration',
'meetings': 'Meetings',
'misc': 'Miscellaneous',
'uncategorized': 'Uncategorized',
};
return names[categoryId] ?? categoryId;
}
}
class _UnclassifiedItem extends StatefulWidget {
final UnclassifiedApplication app;
final Future<void> Function(String, String) onClassify;
const _UnclassifiedItem({required this.app, required this.onClassify});
@override
State<_UnclassifiedItem> createState() => _UnclassifiedItemState();
}
class _UnclassifiedItemState extends State<_UnclassifiedItem> {
String? _selectedCategory;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final lastSeen = DateTime.parse(widget.app.lastSeen);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.app.applicationName,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 16),
),
Text(
'${widget.app.occurrenceCount} times • Last: ${_formatDate(lastSeen)}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Select category',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: const [
DropdownMenuItem(value: 'coding', child: Text('💻 Coding')),
DropdownMenuItem(value: 'focused_browsing', child: Text('🔍 Focused Browsing')),
DropdownMenuItem(value: 'collaboration', child: Text('🤝 Collaboration')),
DropdownMenuItem(value: 'meetings', child: Text('📅 Meetings')),
DropdownMenuItem(value: 'misc', child: Text('📝 Miscellaneous')),
DropdownMenuItem(value: 'uncategorized', child: Text('❓ Uncategorized')),
],
onChanged: (value) {
setState(() {
_selectedCategory = value;
});
},
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _selectedCategory == null || _isLoading ? null : _classifyApp,
child:
_isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Classify'),
),
],
),
],
),
),
);
}
Future<void> _classifyApp() async {
if (_selectedCategory == null) return;
setState(() {
_isLoading = true;
});
try {
await widget.onClassify(widget.app.applicationName, _selectedCategory!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.app.applicationName} classified successfully'),
backgroundColor: AppTheme.successColor,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to classify: $e'), backgroundColor: AppTheme.errorColor));
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
String _formatDate(DateTime date) {
return '${date.month}/${date.day}/${date.year}';
}
}
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
class ConfigCard extends StatefulWidget {
final Map<String, dynamic> config;
final Future<void> Function(Map<String, dynamic>) onConfigUpdate;
const ConfigCard({
super.key,
required this.config,
required this.onConfigUpdate,
});
@override
State<ConfigCard> createState() => _ConfigCardState();
}
class _ConfigCardState extends State<ConfigCard> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _codingXpController;
late TextEditingController _researchXpController;
late TextEditingController _meetingXpController;
late TextEditingController _focusBonusController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_initializeControllers();
}
void _initializeControllers() {
final xpRewards = widget.config['xp_rewards'] as Map<String, dynamic>? ?? {};
final baseMultipliers = xpRewards['base_multipliers'] as Map<String, dynamic>? ?? {};
final focusBonuses = xpRewards['focus_session_bonuses'] as Map<String, dynamic>? ?? {};
_codingXpController = TextEditingController(
text: (baseMultipliers['coding'] ?? 10).toString(),
);
_researchXpController = TextEditingController(
text: (baseMultipliers['research'] ?? 8).toString(),
);
_meetingXpController = TextEditingController(
text: (baseMultipliers['meeting'] ?? 3).toString(),
);
_focusBonusController = TextEditingController(
text: (focusBonuses['base_xp_per_minute'] ?? 5).toString(),
);
}
@override
void didUpdateWidget(ConfigCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.config != oldWidget.config) {
_initializeControllers();
}
}
@override
void dispose() {
_codingXpController.dispose();
_researchXpController.dispose();
_meetingXpController.dispose();
_focusBonusController.dispose();
super.dispose();
}
Future<void> _saveConfig() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
});
try {
final updates = {
'xp_rewards.base_multipliers.coding': int.parse(_codingXpController.text),
'xp_rewards.base_multipliers.research': int.parse(_researchXpController.text),
'xp_rewards.base_multipliers.meeting': int.parse(_meetingXpController.text),
'xp_rewards.focus_session_bonuses.base_xp_per_minute': int.parse(_focusBonusController.text),
};
await widget.onConfigUpdate(updates);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Configuration saved successfully!'),
backgroundColor: AppTheme.successColor,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save configuration: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.settings, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'XP Configuration',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
_ConfigField(
label: 'Coding XP Multiplier',
controller: _codingXpController,
icon: Icons.code,
),
const SizedBox(height: 12),
_ConfigField(
label: 'Research XP Multiplier',
controller: _researchXpController,
icon: Icons.search,
),
const SizedBox(height: 12),
_ConfigField(
label: 'Meeting XP Multiplier',
controller: _meetingXpController,
icon: Icons.groups,
),
const SizedBox(height: 12),
_ConfigField(
label: 'Focus Bonus (XP/min)',
controller: _focusBonusController,
icon: Icons.psychology,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _saveConfig,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Save Configuration'),
),
),
],
),
),
),
);
}
}
class _ConfigField extends StatelessWidget {
final String label;
final TextEditingController controller;
final IconData icon;
const _ConfigField({
required this.label,
required this.controller,
required this.icon,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: AppTheme.primaryColor),
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a value';
}
final intValue = int.tryParse(value);
if (intValue == null || intValue < 0) {
return 'Please enter a valid positive number';
}
return null;
},
);
}
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class LogsCard extends StatefulWidget {
final SystemLogResponse? logs;
final Future<void> Function({int count, LogLevel? level}) onRefresh;
const LogsCard({
super.key,
required this.logs,
required this.onRefresh,
});
@override
State<LogsCard> createState() => _LogsCardState();
}
class _LogsCardState extends State<LogsCard> {
LogLevel? _selectedLevel;
bool _isRefreshing = false;
Future<void> _refreshLogs() async {
setState(() {
_isRefreshing = true;
});
try {
await widget.onRefresh(count: 50, level: _selectedLevel);
} finally {
if (mounted) {
setState(() {
_isRefreshing = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.article, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Expanded(
child: Text(
'System Logs',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
DropdownButton<LogLevel?>(
value: _selectedLevel,
hint: const Text('All'),
items: [
const DropdownMenuItem<LogLevel?>(
value: null,
child: Text('All'),
),
...LogLevel.values.map((level) => DropdownMenuItem<LogLevel?>(
value: level,
child: Text(level.name.toUpperCase()),
)),
],
onChanged: (LogLevel? newLevel) {
setState(() {
_selectedLevel = newLevel;
});
_refreshLogs();
},
),
const SizedBox(width: 8),
IconButton(
icon: _isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isRefreshing ? null : _refreshLogs,
tooltip: 'Refresh Logs',
),
],
),
const SizedBox(height: 16),
Container(
height: 300,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: widget.logs == null
? const Center(child: CircularProgressIndicator())
: widget.logs!.logs.isEmpty
? const Center(child: Text('No logs available'))
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: widget.logs!.logs.length,
itemBuilder: (context, index) {
final log = widget.logs!.logs[index];
return _LogEntry(log: log);
},
),
),
],
),
),
);
}
}
class _LogEntry extends StatelessWidget {
final String log;
const _LogEntry({required this.log});
@override
Widget build(BuildContext context) {
final color = _getLogColor(log);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
log,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: color,
),
),
);
}
Color _getLogColor(String logEntry) {
if (logEntry.contains('[ERROR]')) return AppTheme.errorColor;
if (logEntry.contains('[WARN]')) return AppTheme.warningColor;
if (logEntry.contains('[INFO]')) return AppTheme.infoColor;
if (logEntry.contains('[DEBUG]')) return Colors.grey;
return Colors.black87;
}
}
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class ProgressCard extends StatelessWidget {
final DashboardStats? stats;
const ProgressCard({
super.key,
required this.stats,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.analytics, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Today\'s Progress',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
if (stats != null) ...[
_ProgressItem(
label: 'Focus Time',
value: _formatDuration(stats!.today.focusTime),
progress: _calculateProgress(stats!.today.focusTime, 8 * 3600), // 8 hours target
color: AppTheme.successColor,
icon: Icons.psychology,
),
const SizedBox(height: 16),
_ProgressItem(
label: 'Meeting Time',
value: _formatDuration(stats!.today.meetingTime),
progress: _calculateProgress(stats!.today.meetingTime, 4 * 3600), // 4 hours target
color: AppTheme.infoColor,
icon: Icons.groups,
),
const SizedBox(height: 16),
_ProgressItem(
label: 'Focus Sessions',
value: stats!.today.focusSessions.toString(),
progress: _calculateProgress(stats!.today.focusSessions, 8), // 8 sessions target
color: AppTheme.warningColor,
icon: Icons.timer,
isCount: true,
),
] else ...[
const Center(
child: CircularProgressIndicator(),
),
],
],
),
),
);
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
return '${hours}h ${minutes}m';
}
double _calculateProgress(int current, int target) {
if (target == 0) return 0.0;
return (current / target).clamp(0.0, 1.0);
}
}
class _ProgressItem extends StatelessWidget {
final String label;
final String value;
final double progress;
final Color color;
final IconData icon;
final bool isCount;
const _ProgressItem({
required this.label,
required this.value,
required this.progress,
required this.color,
required this.icon,
this.isCount = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 8),
Stack(
children: [
Container(
height: 8,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
height: 8,
width: MediaQuery.of(context).size.width * progress * 0.8, // Approximate width
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
colors: [color, color.withOpacity(0.7)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${(progress * 100).toInt()}% of target',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class RecentActivityCard extends StatelessWidget {
final List<RecentActivity> activities;
const RecentActivityCard({
super.key,
required this.activities,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.history, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Recent Activity',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
if (activities.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No recent activity'),
),
)
else
...activities.take(5).map((activity) => _ActivityItem(activity: activity)),
],
),
),
);
}
}
class _ActivityItem extends StatelessWidget {
final RecentActivity activity;
const _ActivityItem({required this.activity});
@override
Widget build(BuildContext context) {
final timestamp = DateTime.parse(activity.timestamp);
final duration = Duration(seconds: activity.durationSeconds);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.getActivityTypeColor(activity.type),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.application,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
'${_capitalizeFirst(activity.type)}${_formatDuration(duration)}${_formatTime(timestamp)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
}
String _capitalizeFirst(String text) {
if (text.isEmpty) return text;
return text[0].toUpperCase() + text.substring(1);
}
String _formatDuration(Duration duration) {
if (duration.inHours > 0) {
return '${duration.inHours}h ${duration.inMinutes % 60}m';
} else if (duration.inMinutes > 0) {
return '${duration.inMinutes}m';
} else {
return '${duration.inSeconds}s';
}
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class StatsHeader extends StatelessWidget {
final DashboardStats? stats;
const StatsHeader({
super.key,
required this.stats,
});
@override
Widget build(BuildContext context) {
if (stats == null) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: AppTheme.primaryGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Expanded(
child: _StatCard(
icon: Icons.trending_up,
label: 'Level',
value: stats!.today.level.toString(),
color: Colors.white,
),
),
const SizedBox(width: 16),
Expanded(
child: _StatCard(
icon: Icons.stars,
label: 'XP',
value: _formatNumber(stats!.today.xp),
color: Colors.white,
),
),
const SizedBox(width: 16),
Expanded(
child: _StatCard(
icon: Icons.local_fire_department,
label: 'Streak',
value: stats!.streaks.currentStreak.toString(),
color: Colors.white,
),
),
],
),
);
}
String _formatNumber(int number) {
if (number >= 1000000) {
return '${(number / 1000000).toStringAsFixed(1)}M';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}K';
}
return number.toString();
}
}
class _StatCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _StatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(
icon,
size: 32,
color: color.withOpacity(0.8),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: color.withOpacity(0.8),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
color: color,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
);
}
}
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class XPBreakdownCard extends StatelessWidget {
final Map<String, int> breakdown;
const XPBreakdownCard({
super.key,
required this.breakdown,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.pie_chart, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'XP Sources Today',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
if (breakdown.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('No XP earned today'),
),
)
else
..._buildXPSourceItems(),
],
),
),
);
}
List<Widget> _buildXPSourceItems() {
final totalXP = breakdown.values.fold(0, (sum, xp) => sum + xp);
final sortedEntries = breakdown.entries
.where((entry) => entry.value > 0)
.toList();
sortedEntries.sort((a, b) => b.value.compareTo(a.value));
return sortedEntries
.take(5)
.map((entry) => _XPSourceItem(
source: entry.key,
xp: entry.value,
totalXP: totalXP,
))
.toList();
}
}
class _XPSourceItem extends StatelessWidget {
final String source;
final int xp;
final int totalXP;
const _XPSourceItem({
required this.source,
required this.xp,
required this.totalXP,
});
@override
Widget build(BuildContext context) {
final percentage = totalXP > 0 ? (xp / totalXP * 100) : 0.0;
final color = AppTheme.getXPSourceColor(source);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_formatSourceName(source),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
'+$xp XP',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Container(
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(2),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: percentage / 100,
child: Container(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(width: 8),
Text(
'${percentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
],
),
);
}
String _formatSourceName(String source) {
switch (source) {
case 'coding':
return 'Coding';
case 'focused_browsing':
return 'Focused Browsing';
case 'collaboration':
return 'Collaboration';
case 'meetings':
return 'Meetings';
case 'misc':
return 'Miscellaneous';
case 'uncategorized':
return 'Uncategorized';
case 'focus_session':
return 'Focus Sessions';
case 'achievement':
return 'Achievements';
case 'manual_boost':
return 'Manual Boosts';
default:
return source.replaceAll('_', ' ').split(' ').map((word) =>
word.isEmpty ? word : word[0].toUpperCase() + word.substring(1)
).join(' ');
}
}
}
+359
View File
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:xp_models/xp_models.dart';
import '../theme/app_theme.dart';
class XPChart extends StatelessWidget {
final List<StatsHistory> history;
const XPChart({
super.key,
required this.history,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.trending_up, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'XP Progress (7 Days)',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
_buildLegend(),
const SizedBox(height: 16),
SizedBox(
height: 300,
child: history.isEmpty
? const Center(
child: CircularProgressIndicator(),
)
: LineChart(
_buildChartData(),
),
),
],
),
),
);
}
LineChartData _buildChartData() {
final xpSpots = <FlSpot>[];
final levelSpots = <FlSpot>[];
for (int i = 0; i < history.length; i++) {
xpSpots.add(FlSpot(i.toDouble(), history[i].xp.toDouble()));
levelSpots.add(FlSpot(i.toDouble(), history[i].level.toDouble()));
}
final maxXP = history.map((h) => h.xp).reduce((a, b) => a > b ? a : b).toDouble();
final maxLevel = history.map((h) => h.level).reduce((a, b) => a > b ? a : b).toDouble();
return LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: true,
horizontalInterval: maxXP / 5,
verticalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.shade300,
strokeWidth: 1,
);
},
getDrawingVerticalLine: (value) {
return FlLine(
color: Colors.grey.shade300,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: maxLevel / 4,
getTitlesWidget: (value, meta) {
return Text(
'L${value.toInt()}',
style: const TextStyle(
color: AppTheme.secondaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < history.length) {
final date = DateTime.parse(history[index].date);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${date.month}/${date.day}',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: maxXP / 4,
reservedSize: 50,
getTitlesWidget: (value, meta) {
return Text(
_formatXP(value.toInt()),
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
minX: 0,
maxX: (history.length - 1).toDouble(),
minY: 0,
maxY: maxXP * 1.1,
lineBarsData: [
// XP Line
LineChartBarData(
spots: xpSpots,
isCurved: true,
gradient: const LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.accentColor],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor.withOpacity(0.3),
AppTheme.primaryColor.withOpacity(0.1),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// Level Line (scaled to fit)
LineChartBarData(
spots: levelSpots.map((spot) => FlSpot(spot.x, spot.y * (maxXP / maxLevel))).toList(),
isCurved: true,
color: AppTheme.secondaryColor,
barWidth: 2,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 3,
color: AppTheme.secondaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
dashArray: [5, 5],
),
],
lineTouchData: LineTouchData(
enabled: true,
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.blueGrey.withOpacity(0.8),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final flSpot = barSpot;
final index = flSpot.x.toInt();
if (index >= 0 && index < history.length) {
final historyItem = history[index];
final date = DateTime.parse(historyItem.date);
if (barSpot.barIndex == 0) {
// XP line
return LineTooltipItem(
'${date.month}/${date.day}\nXP: ${historyItem.xp}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
} else {
// Level line
return LineTooltipItem(
'Level: ${historyItem.level}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}
}
return null;
}).toList();
},
),
),
);
}
Widget _buildLegend() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildLegendItem(
color: AppTheme.primaryColor,
label: 'Experience Points',
isDashed: false,
hasGradient: true,
),
const SizedBox(width: 24),
_buildLegendItem(
color: AppTheme.secondaryColor,
label: 'Level',
isDashed: true,
hasGradient: false,
),
],
),
);
}
Widget _buildLegendItem({
required Color color,
required String label,
required bool isDashed,
required bool hasGradient,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 3,
decoration: BoxDecoration(
gradient: hasGradient
? const LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.accentColor],
)
: null,
color: hasGradient ? null : color,
borderRadius: BorderRadius.circular(2),
),
child: isDashed
? CustomPaint(
painter: DashedLinePainter(color: color),
size: const Size(24, 3),
)
: null,
),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
);
}
String _formatXP(int xp) {
if (xp >= 1000000) {
return '${(xp / 1000000).toStringAsFixed(1)}M';
} else if (xp >= 1000) {
return '${(xp / 1000).toStringAsFixed(1)}K';
}
return xp.toString();
}
}
class DashedLinePainter extends CustomPainter {
final Color color;
DashedLinePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
const dashWidth = 3.0;
const dashSpace = 2.0;
double startX = 0;
while (startX < size.width) {
canvas.drawLine(
Offset(startX, size.height / 2),
Offset(startX + dashWidth, size.height / 2),
paint,
);
startX += dashWidth + dashSpace;
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}