More tests, better functionality, the server serves the dashboard now on configurable ports. websocket stuff fixed, though I dont think data is really being sent / recieved...

This commit is contained in:
Nate Anderson
2025-06-13 17:09:08 -06:00
parent b373a93f0e
commit d34cccc253
65 changed files with 166413 additions and 1801 deletions
+68 -42
View File
@@ -3,15 +3,49 @@ 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';
import '../utils/port_detector.dart';
class ApiService {
static const String _baseUrl = 'http://[::1]:8080';
static const String _wsUrl = 'ws://[::1]:8080/ws';
static const String _defaultBaseUrl = 'http://[::1]:8080';
static const String _defaultWsUrl = 'ws://[::1]:8080/ws';
final String _baseUrl;
final String _wsUrl;
final http.Client _httpClient;
WebSocketChannel? _wsChannel;
ApiService({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client();
ApiService({String? baseUrl, String? wsUrl, http.Client? httpClient})
: _baseUrl = baseUrl ?? _buildDynamicBaseUrl(),
_wsUrl = wsUrl ?? _buildDynamicWsUrl(),
_httpClient = httpClient ?? http.Client();
/// Builds the base URL using dynamic port detection or falls back to default
static String _buildDynamicBaseUrl() {
try {
return PortDetector.buildBaseUrl(
defaultHost: '[::1]',
defaultPort: '8080',
defaultProtocol: 'http',
);
} catch (e) {
// If port detection fails, fall back to the original default
return _defaultBaseUrl;
}
}
/// Builds the WebSocket URL using dynamic port detection or falls back to default
static String _buildDynamicWsUrl() {
try {
return PortDetector.buildWebSocketUrl(
defaultHost: '[::1]',
defaultPort: '8080',
defaultProtocol: 'ws',
);
} catch (e) {
// If port detection fails, fall back to the original default
return _defaultWsUrl;
}
}
// HTTP API Methods
Future<DashboardStats> getStats() async {
@@ -19,7 +53,7 @@ class ApiService {
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);
@@ -33,7 +67,7 @@ class ApiService {
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();
@@ -47,7 +81,7 @@ class ApiService {
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();
@@ -61,7 +95,7 @@ class ApiService {
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();
@@ -75,7 +109,7 @@ class ApiService {
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();
@@ -85,15 +119,10 @@ class ApiService {
}
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'},
);
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));
@@ -103,15 +132,12 @@ class ApiService {
}
Future<SystemLogResponse> getLogs({int count = 100, LogLevel? level}) async {
final url = level != null
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'},
);
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);
@@ -125,7 +151,7 @@ class ApiService {
Uri.parse('$_baseUrl/api/config'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
@@ -139,7 +165,7 @@ class ApiService {
headers: {'Content-Type': 'application/json'},
body: jsonEncode(updates),
);
if (response.statusCode != 200) {
throw ApiException('Failed to update config: ${response.statusCode}');
}
@@ -150,7 +176,7 @@ class ApiService {
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();
@@ -165,7 +191,7 @@ class ApiService {
headers: {'Content-Type': 'application/json'},
body: jsonEncode(request.toJson()),
);
if (response.statusCode != 200) {
throw ApiException('Failed to save classification: ${response.statusCode}');
}
@@ -177,7 +203,7 @@ class ApiService {
Uri.parse('$_baseUrl/api/classifications/$encodedName'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode != 200) {
throw ApiException('Failed to delete classification: ${response.statusCode}');
}
@@ -188,7 +214,7 @@ class ApiService {
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();
@@ -198,14 +224,14 @@ class ApiService {
}
// WebSocket Methods
WebSocketChannel connectWebSocket() {
_wsChannel?.sink.close();
Future<WebSocketChannel> connectWebSocket() async {
await _wsChannel?.sink.close();
_wsChannel = WebSocketChannel.connect(Uri.parse(_wsUrl));
return _wsChannel!;
}
void disconnectWebSocket() {
_wsChannel?.sink.close();
Future<void> disconnectWebSocket() async {
await _wsChannel?.sink.close();
_wsChannel = null;
}
@@ -213,7 +239,7 @@ class ApiService {
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);
@@ -224,21 +250,21 @@ class ApiService {
if (_wsChannel == null) {
throw StateError('WebSocket not connected. Call connectWebSocket() first.');
}
_wsChannel!.sink.add(jsonEncode(message.toJson()));
}
void dispose() {
Future<void> dispose() async {
_httpClient.close();
disconnectWebSocket();
await disconnectWebSocket();
}
}
class ApiException implements Exception {
final String message;
ApiException(this.message);
@override
String toString() => 'ApiException: $message';
}
@@ -5,7 +5,7 @@ import 'api_service.dart';
class DashboardProvider extends ChangeNotifier {
final ApiService _apiService;
// State variables
DashboardStats? _stats;
List<StatsHistory> _statsHistory = [];
@@ -17,7 +17,7 @@ class DashboardProvider extends ChangeNotifier {
Map<String, dynamic> _config = {};
List<ApplicationClassification> _classifications = [];
List<UnclassifiedApplication> _unclassified = [];
// Loading states
bool _isLoading = false;
bool _isStatsLoading = false;
@@ -30,7 +30,7 @@ class DashboardProvider extends ChangeNotifier {
bool _isConfigLoading = false;
bool _isClassificationsLoading = false;
bool _isUnclassifiedLoading = false;
// Error states
String? _error;
String? _statsError;
@@ -43,13 +43,16 @@ class DashboardProvider extends ChangeNotifier {
String? _configError;
String? _classificationsError;
String? _unclassifiedError;
// WebSocket
StreamSubscription<WebSocketMessage>? _wsSubscription;
bool _isWebSocketConnected = false;
// Disposal tracking
bool _disposed = false;
DashboardProvider(this._apiService);
// Getters
DashboardStats? get stats => _stats;
List<StatsHistory> get statsHistory => _statsHistory;
@@ -61,7 +64,7 @@ class DashboardProvider extends ChangeNotifier {
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;
@@ -74,7 +77,7 @@ class DashboardProvider extends ChangeNotifier {
bool get isConfigLoading => _isConfigLoading;
bool get isClassificationsLoading => _isClassificationsLoading;
bool get isUnclassifiedLoading => _isUnclassifiedLoading;
// Error getters
String? get error => _error;
String? get statsError => _statsError;
@@ -87,15 +90,15 @@ class DashboardProvider extends ChangeNotifier {
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(),
@@ -108,9 +111,9 @@ class DashboardProvider extends ChangeNotifier {
loadClassifications(),
loadUnclassified(),
]);
// Connect WebSocket after initial data load
connectWebSocket();
await connectWebSocket();
} catch (e) {
_error = e.toString();
} finally {
@@ -118,13 +121,13 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
// Load individual data sections
Future<void> loadStats() async {
_isStatsLoading = true;
_statsError = null;
notifyListeners();
try {
_stats = await _apiService.getStats();
} catch (e) {
@@ -134,12 +137,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadStatsHistory({int days = 7}) async {
_isHistoryLoading = true;
_historyError = null;
notifyListeners();
try {
_statsHistory = await _apiService.getStatsHistory(days: days);
} catch (e) {
@@ -149,12 +152,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadAchievements({int limit = 5}) async {
_isAchievementsLoading = true;
_achievementsError = null;
notifyListeners();
try {
_achievements = await _apiService.getAchievements(limit: limit);
} catch (e) {
@@ -164,12 +167,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadActivities({int limit = 100}) async {
_isActivitiesLoading = true;
_activitiesError = null;
notifyListeners();
try {
_activities = await _apiService.getActivities(limit: limit);
} catch (e) {
@@ -179,12 +182,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadFocusSessions({int limit = 50}) async {
_isFocusSessionsLoading = true;
_focusSessionsError = null;
notifyListeners();
try {
_focusSessions = await _apiService.getFocusSessions(limit: limit);
} catch (e) {
@@ -194,12 +197,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadXPBreakdown({String? date}) async {
_isXpBreakdownLoading = true;
_xpBreakdownError = null;
notifyListeners();
try {
_xpBreakdown = await _apiService.getXPBreakdown(date: date);
} catch (e) {
@@ -209,12 +212,12 @@ class DashboardProvider extends ChangeNotifier {
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) {
@@ -224,12 +227,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadConfig() async {
_isConfigLoading = true;
_configError = null;
notifyListeners();
try {
_config = await _apiService.getConfig();
} catch (e) {
@@ -239,7 +242,7 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> updateConfig(Map<String, dynamic> updates) async {
try {
await _apiService.updateConfig(updates);
@@ -250,12 +253,12 @@ class DashboardProvider extends ChangeNotifier {
rethrow;
}
}
Future<void> loadClassifications() async {
_isClassificationsLoading = true;
_classificationsError = null;
notifyListeners();
try {
_classifications = await _apiService.getClassifications();
} catch (e) {
@@ -265,12 +268,12 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> loadUnclassified() async {
_isUnclassifiedLoading = true;
_unclassifiedError = null;
notifyListeners();
try {
_unclassified = await _apiService.getUnclassified();
} catch (e) {
@@ -280,47 +283,38 @@ class DashboardProvider extends ChangeNotifier {
notifyListeners();
}
}
Future<void> saveClassification(String applicationName, String categoryId) async {
try {
final request = ClassificationRequest(
applicationName: applicationName,
categoryId: categoryId,
);
final request = ClassificationRequest(applicationName: applicationName, categoryId: categoryId);
await _apiService.saveClassification(request);
// Reload classifications and unclassified after saving
await Future.wait([
loadClassifications(),
loadUnclassified(),
]);
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(),
]);
await Future.wait([loadClassifications(), loadUnclassified()]);
} catch (e) {
_classificationsError = e.toString();
notifyListeners();
rethrow;
}
}
// WebSocket methods
void connectWebSocket() {
Future<void> connectWebSocket() async {
try {
_apiService.connectWebSocket();
await _apiService.connectWebSocket();
_wsSubscription = _apiService.webSocketStream.listen(
_handleWebSocketMessage,
onError: _handleWebSocketError,
@@ -332,15 +326,15 @@ class DashboardProvider extends ChangeNotifier {
debugPrint('Failed to connect WebSocket: $e');
}
}
void disconnectWebSocket() {
_wsSubscription?.cancel();
Future<void> disconnectWebSocket() async {
await _wsSubscription?.cancel();
_wsSubscription = null;
_apiService.disconnectWebSocket();
await _apiService.disconnectWebSocket();
_isWebSocketConnected = false;
notifyListeners();
}
void _handleWebSocketMessage(WebSocketMessage message) {
switch (message.type) {
case WebSocketMessageType.statsUpdate:
@@ -365,10 +359,7 @@ class DashboardProvider extends ChangeNotifier {
break;
case WebSocketMessageType.focusSessionComplete:
// Reload focus sessions and stats
Future.wait([
loadFocusSessions(),
loadStats(),
]);
Future.wait([loadFocusSessions(), loadStats()]);
break;
case WebSocketMessageType.ping:
// Respond to ping with pong
@@ -379,42 +370,50 @@ class DashboardProvider extends ChangeNotifier {
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();
}
});
// Only notify listeners if not disposed
if (!_disposed) {
notifyListeners();
// Try to reconnect after a delay
Timer(const Duration(seconds: 5), () {
if (!_disposed && !_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();
}
});
// Only notify listeners if not disposed
if (!_disposed) {
notifyListeners();
// Try to reconnect after a delay
Timer(const Duration(seconds: 5), () {
if (!_disposed && !_isWebSocketConnected) {
connectWebSocket();
}
});
}
}
// Refresh all data
Future<void> refresh() async {
await initialize();
}
@override
void dispose() {
disconnectWebSocket();
_apiService.dispose();
Future<void> dispose() async {
_disposed = true;
await _apiService.dispose();
super.dispose();
}
}
@@ -0,0 +1,102 @@
import 'dart:html' as html;
import 'package:flutter/foundation.dart';
class PortDetector {
/// Detects the current port from the browser's window.location
/// Returns null if not running on web platform or if detection fails
static String? getCurrentPort() {
if (!kIsWeb) {
return null;
}
try {
final location = html.window.location;
final port = location.port;
// If port is empty, it means we're using default ports
if (port.isEmpty) {
// Return default ports based on protocol
return location.protocol == 'https:' ? '443' : '80';
}
return port;
} catch (e) {
// If anything goes wrong, return null to fall back to defaults
return null;
}
}
/// Gets the current hostname from the browser
/// Returns null if not running on web platform or if detection fails
static String? getCurrentHostname() {
if (!kIsWeb) {
return null;
}
try {
return html.window.location.hostname;
} catch (e) {
return null;
}
}
/// Gets the current protocol from the browser
/// Returns null if not running on web platform or if detection fails
static String? getCurrentProtocol() {
if (!kIsWeb) {
return null;
}
try {
final protocol = html.window.location.protocol;
// Remove the trailing colon
return protocol.endsWith(':') ? protocol.substring(0, protocol.length - 1) : protocol;
} catch (e) {
return null;
}
}
/// Builds the base URL using detected browser information
/// Falls back to provided defaults if detection fails
static String buildBaseUrl({
String defaultHost = '[::1]',
String defaultPort = '8080',
String defaultProtocol = 'http',
}) {
final detectedProtocol = getCurrentProtocol() ?? defaultProtocol;
final detectedHost = getCurrentHostname() ?? defaultHost;
final detectedPort = getCurrentPort() ?? defaultPort;
// For IPv6 addresses, we need to wrap them in brackets
final host = detectedHost.contains(':') && !detectedHost.startsWith('[')
? '[$detectedHost]'
: detectedHost;
return '$detectedProtocol://$host:$detectedPort';
}
/// Builds the WebSocket URL using detected browser information
/// Falls back to provided defaults if detection fails
static String buildWebSocketUrl({
String defaultHost = '[::1]',
String defaultPort = '8080',
String defaultProtocol = 'ws',
}) {
final detectedProtocol = getCurrentProtocol();
final detectedHost = getCurrentHostname() ?? defaultHost;
final detectedPort = getCurrentPort() ?? defaultPort;
// Convert HTTP protocol to WebSocket protocol
String wsProtocol = defaultProtocol;
if (detectedProtocol != null) {
wsProtocol = detectedProtocol == 'https' ? 'wss' : 'ws';
}
// For IPv6 addresses, we need to wrap them in brackets
final host = detectedHost.contains(':') && !detectedHost.startsWith('[')
? '[$detectedHost]'
: detectedHost;
return '$wsProtocol://$host:$detectedPort/ws';
}
}
@@ -54,9 +54,7 @@ class _AchievementItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final achievedDate = achievement.achievedAt != null
? DateTime.parse(achievement.achievedAt!)
: null;
final achievedDate = achievement.achievedAt;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -191,7 +191,7 @@ class _UnclassifiedItemState extends State<_UnclassifiedItem> {
@override
Widget build(BuildContext context) {
final lastSeen = DateTime.parse(widget.app.lastSeen);
final lastSeen = widget.app.lastSeen;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
@@ -54,7 +54,7 @@ class _ActivityItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final timestamp = DateTime.parse(activity.timestamp);
final timestamp = activity.timestamp;
final duration = Duration(seconds: activity.durationSeconds);
return Padding(
@@ -66,7 +66,7 @@ class _ActivityItem extends StatelessWidget {
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.getActivityTypeColor(activity.type),
color: AppTheme.getActivityTypeColor(activity.type ?? 'uncategorized'),
),
),
const SizedBox(width: 12),
@@ -75,13 +75,13 @@ class _ActivityItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.application,
activity.application ?? 'Unknown App',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
'${_capitalizeFirst(activity.type)}${_formatDuration(duration)}${_formatTime(timestamp)}',
'${_capitalizeFirst(activity.type ?? 'uncategorized')}${_formatDuration(duration)}${_formatTime(timestamp)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
@@ -94,8 +94,8 @@ class _ActivityItem extends StatelessWidget {
);
}
String _capitalizeFirst(String text) {
if (text.isEmpty) return text;
String _capitalizeFirst(String? text) {
if (text == null || text.isEmpty) return 'Unknown';
return text[0].toUpperCase() + text.substring(1);
}
+23 -85
View File
@@ -6,10 +6,7 @@ import '../theme/app_theme.dart';
class XPChart extends StatelessWidget {
final List<StatsHistory> history;
const XPChart({
super.key,
required this.history,
});
const XPChart({super.key, required this.history});
@override
Widget build(BuildContext context) {
@@ -25,9 +22,7 @@ class XPChart extends StatelessWidget {
const SizedBox(width: 8),
Text(
'XP Progress (7 Days)',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
],
),
@@ -36,13 +31,7 @@ class XPChart extends StatelessWidget {
const SizedBox(height: 16),
SizedBox(
height: 300,
child: history.isEmpty
? const Center(
child: CircularProgressIndicator(),
)
: LineChart(
_buildChartData(),
),
child: history.isEmpty ? const Center(child: CircularProgressIndicator()) : LineChart(_buildChartData()),
),
],
),
@@ -53,7 +42,7 @@ class XPChart extends StatelessWidget {
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()));
@@ -69,16 +58,10 @@ class XPChart extends StatelessWidget {
horizontalInterval: maxXP / 5,
verticalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.shade300,
strokeWidth: 1,
);
return FlLine(color: Colors.grey.shade300, strokeWidth: 1);
},
getDrawingVerticalLine: (value) {
return FlLine(
color: Colors.grey.shade300,
strokeWidth: 1,
);
return FlLine(color: Colors.grey.shade300, strokeWidth: 1);
},
),
titlesData: FlTitlesData(
@@ -87,22 +70,16 @@ class XPChart extends StatelessWidget {
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: maxLevel / 4,
// interval: 4,
getTitlesWidget: (value, meta) {
return Text(
'L${value.toInt()}',
style: const TextStyle(
color: AppTheme.secondaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
'L${value.toInt() / 100}',
style: const TextStyle(color: AppTheme.secondaryColor, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
@@ -116,11 +93,7 @@ class XPChart extends StatelessWidget {
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'${date.month}/${date.day}',
style: const TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
fontSize: 12,
),
style: const TextStyle(color: Colors.grey, fontWeight: FontWeight.bold, fontSize: 12),
),
);
}
@@ -131,25 +104,18 @@ class XPChart extends StatelessWidget {
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: maxXP / 4,
// interval: maxXP / 4,
reservedSize: 50,
getTitlesWidget: (value, meta) {
return Text(
_formatXP(value.toInt()),
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
style: const TextStyle(color: AppTheme.primaryColor, fontWeight: FontWeight.bold, fontSize: 12),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
borderData: FlBorderData(show: true, border: Border.all(color: Colors.grey.shade300)),
minX: 0,
maxX: (history.length - 1).toDouble(),
minY: 0,
@@ -159,9 +125,7 @@ class XPChart extends StatelessWidget {
LineChartBarData(
spots: xpSpots,
isCurved: true,
gradient: const LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.accentColor],
),
gradient: const LinearGradient(colors: [AppTheme.primaryColor, AppTheme.accentColor]),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
@@ -178,10 +142,7 @@ class XPChart extends StatelessWidget {
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor.withOpacity(0.3),
AppTheme.primaryColor.withOpacity(0.1),
],
colors: [AppTheme.primaryColor.withOpacity(0.3), AppTheme.primaryColor.withOpacity(0.1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
@@ -219,24 +180,18 @@ class XPChart extends StatelessWidget {
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,
),
const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
);
} else {
// Level line
return LineTooltipItem(
'Level: ${historyItem.level}',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
);
}
}
@@ -266,12 +221,7 @@ class XPChart extends StatelessWidget {
hasGradient: true,
),
const SizedBox(width: 24),
_buildLegendItem(
color: AppTheme.secondaryColor,
label: 'Level',
isDashed: true,
hasGradient: false,
),
_buildLegendItem(color: AppTheme.secondaryColor, label: 'Level', isDashed: true, hasGradient: false),
],
),
);
@@ -290,11 +240,7 @@ class XPChart extends StatelessWidget {
width: 24,
height: 3,
decoration: BoxDecoration(
gradient: hasGradient
? const LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.accentColor],
)
: null,
gradient: hasGradient ? const LinearGradient(colors: [AppTheme.primaryColor, AppTheme.accentColor]) : null,
color: hasGradient ? null : color,
borderRadius: BorderRadius.circular(2),
),
@@ -308,11 +254,7 @@ class XPChart extends StatelessWidget {
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Colors.black87),
),
],
);
@@ -345,11 +287,7 @@ class DashedLinePainter extends CustomPainter {
double startX = 0;
while (startX < size.width) {
canvas.drawLine(
Offset(startX, size.height / 2),
Offset(startX + dashWidth, size.height / 2),
paint,
);
canvas.drawLine(Offset(startX, size.height / 2), Offset(startX + dashWidth, size.height / 2), paint);
startX += dashWidth + dashSpace;
}
}