Remade frontend dashboard as flutter dashboard, still WIP
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../logging/logger.dart';
|
||||
|
||||
class ConfigManager {
|
||||
static ConfigManager? _instance;
|
||||
static ConfigManager get instance => _instance ??= ConfigManager._();
|
||||
|
||||
ConfigManager._();
|
||||
|
||||
/// Reset the singleton instance (useful for testing)
|
||||
static void resetInstance() {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _config;
|
||||
File? _configFile;
|
||||
DateTime? _lastModified;
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> initialize([String configPath = 'config/xp_config.json']) async {
|
||||
_configFile = File(configPath);
|
||||
await _loadConfig();
|
||||
_isInitialized = true;
|
||||
|
||||
// Start watching for config changes
|
||||
_watchConfigFile();
|
||||
|
||||
Logger.info('Configuration manager initialized with ${_config!.keys.length} sections');
|
||||
}
|
||||
|
||||
void _ensureInitialized() {
|
||||
if (!_isInitialized || _config == null) {
|
||||
_config = _getDefaultConfig();
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
try {
|
||||
if (_configFile != null && !await _configFile!.exists()) {
|
||||
await _createDefaultConfig();
|
||||
}
|
||||
|
||||
if (_configFile != null) {
|
||||
final content = await _configFile!.readAsString();
|
||||
_config = jsonDecode(content);
|
||||
_lastModified = await _configFile!.lastModified();
|
||||
}
|
||||
|
||||
Logger.info('Configuration loaded successfully');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to load configuration: $e');
|
||||
_config = _getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createDefaultConfig() async {
|
||||
if (_configFile != null) {
|
||||
await _configFile!.parent.create(recursive: true);
|
||||
final defaultConfig = _getDefaultConfig();
|
||||
await _configFile!.writeAsString(jsonEncode(defaultConfig));
|
||||
Logger.info('Created default configuration file at ${_configFile!.path}');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getDefaultConfig() {
|
||||
return {
|
||||
"xp_rewards": {
|
||||
"base_multipliers": {
|
||||
"coding": 10,
|
||||
"focused_browsing": 6,
|
||||
"collaboration": 7,
|
||||
"meetings": 3,
|
||||
"misc": 2,
|
||||
"uncategorized": 1,
|
||||
},
|
||||
"time_multipliers": {
|
||||
"deep_work_hours": {
|
||||
"times": ["09:00-11:00", "14:00-16:00"],
|
||||
"multiplier": 1.5,
|
||||
},
|
||||
"late_night_penalty": {
|
||||
"times": ["22:00-06:00"],
|
||||
"multiplier": 0.8,
|
||||
},
|
||||
},
|
||||
"focus_session_bonuses": {
|
||||
"base_xp_per_minute": 5,
|
||||
"milestones": {"60": 100, "120": 200, "180": 500},
|
||||
},
|
||||
"zoom_multipliers": {"active_meeting": 8, "background_meeting": 5, "zoom_focused": 2, "zoom_background": 0},
|
||||
},
|
||||
"achievements": {
|
||||
"level_based": {
|
||||
"5": {
|
||||
"name": "Rising Star",
|
||||
"description": "Reached level 5 - Your journey begins to shine!",
|
||||
"xp_reward": 100,
|
||||
},
|
||||
"10": {
|
||||
"name": "Productivity Warrior",
|
||||
"description": "Reached level 10 - You've unlocked desktop blur effects!",
|
||||
"xp_reward": 250,
|
||||
},
|
||||
"15": {
|
||||
"name": "Focus Master",
|
||||
"description": "Reached level 15 - Your desktop now glows with productivity!",
|
||||
"xp_reward": 500,
|
||||
},
|
||||
"25": {
|
||||
"name": "Legendary Achiever",
|
||||
"description": "Reached level 25 - You have transcended ordinary productivity!",
|
||||
"xp_reward": 1000,
|
||||
},
|
||||
},
|
||||
"focus_based": {
|
||||
"deep_focus": {
|
||||
"name": "Deep Focus",
|
||||
"description": "Maintained a straight hour of focus time in a day",
|
||||
"xp_reward": 200,
|
||||
"threshold_hours": 1,
|
||||
},
|
||||
"focus_titan": {
|
||||
"name": "Focus Titan",
|
||||
"description": "Achieved 4 hours of pure focus - Incredible!",
|
||||
"xp_reward": 500,
|
||||
"threshold_hours": 4,
|
||||
},
|
||||
},
|
||||
"session_based": {
|
||||
"session_master": {
|
||||
"name": "Session Master",
|
||||
"description": "Completed 5+ focus sessions in one day",
|
||||
"xp_reward": 150,
|
||||
"threshold_sessions": 5,
|
||||
},
|
||||
},
|
||||
"meeting_based": {
|
||||
"communication_pro": {
|
||||
"name": "Communication Pro",
|
||||
"description": "Participated in 3+ hours of meetings, oof.",
|
||||
"xp_reward": 200,
|
||||
"threshold_hours": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"level_system": {"xp_per_level": 100, "max_level": 100},
|
||||
"monitoring": {
|
||||
"poll_interval_seconds": 30,
|
||||
"idle_threshold_minutes": 1,
|
||||
"minimum_activity_seconds": 10,
|
||||
"stats_display_interval_minutes": 10,
|
||||
},
|
||||
"logging": {"level": "INFO", "max_file_size_mb": 10, "max_files": 5, "log_directory": "logs"},
|
||||
};
|
||||
}
|
||||
|
||||
void _watchConfigFile() {
|
||||
// Check for config file changes every 5 seconds
|
||||
Timer.periodic(Duration(seconds: 5), (timer) async {
|
||||
try {
|
||||
if (_configFile != null && await _configFile!.exists()) {
|
||||
final lastModified = await _configFile!.lastModified();
|
||||
if (_lastModified == null || lastModified.isAfter(_lastModified!)) {
|
||||
Logger.info('Configuration file changed, reloading...');
|
||||
await _loadConfig();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error('Error checking config file: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Getters for different config sections
|
||||
Map<String, dynamic> get xpRewards {
|
||||
_ensureInitialized();
|
||||
return _config!['xp_rewards'] ?? {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get achievements {
|
||||
_ensureInitialized();
|
||||
return _config!['achievements'] ?? {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get levelSystem {
|
||||
_ensureInitialized();
|
||||
return _config!['level_system'] ?? {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get monitoring {
|
||||
_ensureInitialized();
|
||||
return _config!['monitoring'] ?? {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> get logging {
|
||||
_ensureInitialized();
|
||||
return _config!['logging'] ?? {};
|
||||
}
|
||||
|
||||
// Specific getters for commonly used values
|
||||
int getBaseXP(String activityType) {
|
||||
return xpRewards['base_multipliers']?[activityType] ?? 1;
|
||||
}
|
||||
|
||||
double getTimeMultiplier(DateTime time) {
|
||||
final hour = time.hour;
|
||||
final timeStr = '${hour.toString().padLeft(2, '0')}:00';
|
||||
|
||||
// Check deep work hours
|
||||
final deepWorkTimes = xpRewards['time_multipliers']?['deep_work_hours']?['times'] as List?;
|
||||
if (deepWorkTimes != null) {
|
||||
for (final timeRange in deepWorkTimes) {
|
||||
if (_isTimeInRange(timeStr, timeRange)) {
|
||||
return (xpRewards['time_multipliers']['deep_work_hours']['multiplier'] ?? 1.0).toDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check late night penalty
|
||||
final lateNightTimes = xpRewards['time_multipliers']?['late_night_penalty']?['times'] as List?;
|
||||
if (lateNightTimes != null) {
|
||||
for (final timeRange in lateNightTimes) {
|
||||
if (_isTimeInRange(timeStr, timeRange)) {
|
||||
return (xpRewards['time_multipliers']['late_night_penalty']['multiplier'] ?? 1.0).toDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
bool _isTimeInRange(String currentTime, String range) {
|
||||
final parts = range.split('-');
|
||||
if (parts.length != 2) return false;
|
||||
|
||||
final start = parts[0];
|
||||
final end = parts[1];
|
||||
|
||||
// Simple time comparison (assumes same day)
|
||||
return currentTime.compareTo(start) >= 0 && currentTime.compareTo(end) <= 0;
|
||||
}
|
||||
|
||||
int getFocusSessionBonus(int minutes) {
|
||||
final baseXP = (xpRewards['focus_session_bonuses']?['base_xp_per_minute'] ?? 5) * minutes;
|
||||
final milestones = xpRewards['focus_session_bonuses']?['milestones'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
int bonus = 0;
|
||||
for (final entry in milestones.entries) {
|
||||
final threshold = int.tryParse(entry.key) ?? 0;
|
||||
if (minutes >= threshold) {
|
||||
bonus += entry.value as int;
|
||||
}
|
||||
}
|
||||
|
||||
return baseXP + bonus;
|
||||
}
|
||||
|
||||
int getZoomXP(String status, int minutes) {
|
||||
final multiplier = xpRewards['zoom_multipliers']?[status] ?? 0;
|
||||
return (minutes * multiplier).toInt();
|
||||
}
|
||||
|
||||
int getXPPerLevel() {
|
||||
return levelSystem['xp_per_level'] ?? 100;
|
||||
}
|
||||
|
||||
int calculateLevel(int totalXP) {
|
||||
return (totalXP / getXPPerLevel()).floor() + 1;
|
||||
}
|
||||
|
||||
// Update configuration programmatically
|
||||
Future<void> updateConfig(String path, dynamic value) async {
|
||||
try {
|
||||
_ensureInitialized();
|
||||
_setNestedValue(_config!, path.split('.'), value);
|
||||
await _saveConfig();
|
||||
Logger.info('Configuration updated: $path = $value');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to update configuration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _setNestedValue(Map<String, dynamic> map, List<String> path, dynamic value) {
|
||||
if (path.length == 1) {
|
||||
map[path[0]] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
final key = path[0];
|
||||
if (!map.containsKey(key) || map[key] is! Map<String, dynamic>) {
|
||||
map[key] = <String, dynamic>{};
|
||||
}
|
||||
|
||||
_setNestedValue(map[key], path.sublist(1), value);
|
||||
}
|
||||
|
||||
Future<void> _saveConfig() async {
|
||||
try {
|
||||
if (_configFile != null) {
|
||||
await _configFile!.writeAsString(jsonEncode(_config));
|
||||
_lastModified = await _configFile!.lastModified();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error('Failed to save configuration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> getAllConfig() {
|
||||
_ensureInitialized();
|
||||
return Map.from(_config!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// Represents a parsed Hyprland configuration with base config and dynamic sections
|
||||
class HyprlandConfig {
|
||||
final String baseConfig;
|
||||
final Map<String, String> dynamicSections;
|
||||
final List<String> animations;
|
||||
|
||||
const HyprlandConfig({
|
||||
required this.baseConfig,
|
||||
required this.dynamicSections,
|
||||
required this.animations,
|
||||
});
|
||||
|
||||
/// Reconstructs the full config by combining base config with dynamic sections
|
||||
String buildFullConfig({
|
||||
String? decorationConfig,
|
||||
String? generalConfig,
|
||||
String? animationConfig,
|
||||
}) {
|
||||
final buffer = StringBuffer(baseConfig);
|
||||
|
||||
if (decorationConfig != null) {
|
||||
buffer.writeln(decorationConfig);
|
||||
}
|
||||
|
||||
if (generalConfig != null) {
|
||||
buffer.writeln(generalConfig);
|
||||
}
|
||||
|
||||
if (animationConfig != null) {
|
||||
buffer.writeln(animationConfig);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parser for Hyprland configuration files
|
||||
class HyprlandConfigParser {
|
||||
static const List<String> _dynamicSections = ['decoration', 'general'];
|
||||
|
||||
/// Parses a Hyprland config file and extracts base config from dynamic sections
|
||||
static HyprlandConfig parseConfig(String configContent) {
|
||||
final lines = configContent.split('\n');
|
||||
final baseLines = <String>[];
|
||||
final dynamicSections = <String, String>{};
|
||||
final animations = <String>[];
|
||||
|
||||
bool inDynamicSection = false;
|
||||
String currentSection = '';
|
||||
final currentSectionLines = <String>[];
|
||||
int braceDepth = 0;
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
|
||||
// Handle animation lines (they're not in blocks)
|
||||
if (trimmed.startsWith('animation=')) {
|
||||
animations.add(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section starts
|
||||
if (trimmed.endsWith('{') && !trimmed.startsWith('#')) {
|
||||
final sectionName = _extractSectionName(trimmed);
|
||||
|
||||
if (_isDynamicSection(sectionName)) {
|
||||
inDynamicSection = true;
|
||||
currentSection = sectionName;
|
||||
currentSectionLines.clear();
|
||||
currentSectionLines.add(line);
|
||||
braceDepth = 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inDynamicSection) {
|
||||
currentSectionLines.add(line);
|
||||
|
||||
// Track brace depth to handle nested sections
|
||||
if (trimmed.endsWith('{')) {
|
||||
braceDepth++;
|
||||
} else if (trimmed == '}') {
|
||||
braceDepth--;
|
||||
|
||||
if (braceDepth == 0) {
|
||||
// End of current dynamic section
|
||||
dynamicSections[currentSection] = currentSectionLines.join('\n');
|
||||
inDynamicSection = false;
|
||||
currentSection = '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Include line if not in dynamic section
|
||||
baseLines.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
return HyprlandConfig(
|
||||
baseConfig: baseLines.join('\n'),
|
||||
dynamicSections: dynamicSections,
|
||||
animations: animations,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses a config file from disk
|
||||
static Future<HyprlandConfig> parseConfigFile(String filePath) async {
|
||||
final file = File(filePath);
|
||||
if (!file.existsSync()) {
|
||||
throw FileSystemException('Config file not found', filePath);
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
return parseConfig(content);
|
||||
}
|
||||
|
||||
/// Extracts section name from a line like "decoration {" or "general {"
|
||||
static String _extractSectionName(String line) {
|
||||
final parts = line.trim().split(' ');
|
||||
return parts.isNotEmpty ? parts[0].toLowerCase() : '';
|
||||
}
|
||||
|
||||
/// Checks if a section should be dynamically generated
|
||||
static bool _isDynamicSection(String sectionName) {
|
||||
return _dynamicSections.contains(sectionName);
|
||||
}
|
||||
|
||||
/// Validates that all expected styling sections are present in the config
|
||||
static ConfigValidationResult validateConfig(String configContent) {
|
||||
final config = parseConfig(configContent);
|
||||
final issues = <String>[];
|
||||
final foundSections = <String>[];
|
||||
|
||||
// Check for expected sections
|
||||
for (final section in _dynamicSections) {
|
||||
if (config.dynamicSections.containsKey(section)) {
|
||||
foundSections.add(section);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for animation definitions
|
||||
final hasAnimations = config.animations.isNotEmpty;
|
||||
if (!hasAnimations) {
|
||||
// Check if animations are defined in the base config (inline)
|
||||
final hasInlineAnimations = config.baseConfig.contains('animation=');
|
||||
if (!hasInlineAnimations) {
|
||||
issues.add('No animation definitions found');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate decoration section content
|
||||
if (config.dynamicSections.containsKey('decoration')) {
|
||||
final decorationContent = config.dynamicSections['decoration']!;
|
||||
final decorationIssues = _validateDecorationSection(decorationContent);
|
||||
issues.addAll(decorationIssues);
|
||||
}
|
||||
|
||||
// Validate general section content
|
||||
if (config.dynamicSections.containsKey('general')) {
|
||||
final generalContent = config.dynamicSections['general']!;
|
||||
final generalIssues = _validateGeneralSection(generalContent);
|
||||
issues.addAll(generalIssues);
|
||||
}
|
||||
|
||||
return ConfigValidationResult(
|
||||
isValid: issues.isEmpty,
|
||||
issues: issues,
|
||||
foundSections: foundSections,
|
||||
hasAnimations: hasAnimations || config.baseConfig.contains('animation='),
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates decoration section for required styling properties
|
||||
static List<String> _validateDecorationSection(String decorationContent) {
|
||||
final issues = <String>[];
|
||||
|
||||
// Check for rounding property
|
||||
if (!decorationContent.contains('rounding =')) {
|
||||
issues.add('Missing decoration property: rounding');
|
||||
}
|
||||
|
||||
// Check for blur section (not just the word "blur")
|
||||
if (!decorationContent.contains('blur {')) {
|
||||
issues.add('Missing decoration property: blur');
|
||||
} else {
|
||||
// Check for blur sub-properties only if blur section exists
|
||||
final blurProperties = ['enabled', 'passes', 'size'];
|
||||
for (final prop in blurProperties) {
|
||||
if (!decorationContent.contains('$prop =')) {
|
||||
issues.add('Missing blur property: $prop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shadow section (not just the word "shadow")
|
||||
if (!decorationContent.contains('shadow {')) {
|
||||
issues.add('Missing decoration property: shadow');
|
||||
} else {
|
||||
// Check for shadow sub-properties only if shadow section exists
|
||||
final shadowProperties = ['enabled', 'range', 'render_power'];
|
||||
for (final prop in shadowProperties) {
|
||||
if (!decorationContent.contains('$prop =')) {
|
||||
issues.add('Missing shadow property: $prop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// Validates general section for required styling properties
|
||||
static List<String> _validateGeneralSection(String generalContent) {
|
||||
final issues = <String>[];
|
||||
final requiredProperties = [
|
||||
'border_size',
|
||||
'col.active_border',
|
||||
'col.inactive_border',
|
||||
'gaps_in',
|
||||
'gaps_out',
|
||||
];
|
||||
|
||||
for (final property in requiredProperties) {
|
||||
if (!generalContent.contains(property)) {
|
||||
issues.add('Missing general property: $property');
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// Extracts all styling-related properties from a config
|
||||
static Map<String, dynamic> extractStylingProperties(String configContent) {
|
||||
final config = parseConfig(configContent);
|
||||
final styling = <String, dynamic>{};
|
||||
|
||||
// Extract decoration properties
|
||||
if (config.dynamicSections.containsKey('decoration')) {
|
||||
styling['decoration'] = _extractDecorationProperties(
|
||||
config.dynamicSections['decoration']!
|
||||
);
|
||||
}
|
||||
|
||||
// Extract general properties
|
||||
if (config.dynamicSections.containsKey('general')) {
|
||||
styling['general'] = _extractGeneralProperties(
|
||||
config.dynamicSections['general']!
|
||||
);
|
||||
}
|
||||
|
||||
// Extract animations
|
||||
final allAnimations = <String>[];
|
||||
allAnimations.addAll(config.animations);
|
||||
|
||||
// Also check for inline animations in base config
|
||||
final baseLines = config.baseConfig.split('\n');
|
||||
for (final line in baseLines) {
|
||||
if (line.trim().startsWith('animation=')) {
|
||||
allAnimations.add(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (allAnimations.isNotEmpty) {
|
||||
styling['animations'] = allAnimations;
|
||||
}
|
||||
|
||||
return styling;
|
||||
}
|
||||
|
||||
/// Extracts decoration properties from decoration section
|
||||
static Map<String, dynamic> _extractDecorationProperties(String decorationContent) {
|
||||
final properties = <String, dynamic>{};
|
||||
final lines = decorationContent.split('\n');
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.contains('=') && !trimmed.startsWith('#')) {
|
||||
final parts = trimmed.split('=');
|
||||
if (parts.length >= 2) {
|
||||
final key = parts[0].trim();
|
||||
final value = parts.sublist(1).join('=').trim();
|
||||
properties[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/// Extracts general properties from general section
|
||||
static Map<String, dynamic> _extractGeneralProperties(String generalContent) {
|
||||
final properties = <String, dynamic>{};
|
||||
final lines = generalContent.split('\n');
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.contains('=') && !trimmed.startsWith('#')) {
|
||||
final parts = trimmed.split('=');
|
||||
if (parts.length >= 2) {
|
||||
final key = parts[0].trim();
|
||||
final value = parts.sublist(1).join('=').trim();
|
||||
properties[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of config validation
|
||||
class ConfigValidationResult {
|
||||
final bool isValid;
|
||||
final List<String> issues;
|
||||
final List<String> foundSections;
|
||||
final bool hasAnimations;
|
||||
|
||||
const ConfigValidationResult({
|
||||
required this.isValid,
|
||||
required this.issues,
|
||||
required this.foundSections,
|
||||
required this.hasAnimations,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Config Validation Result:');
|
||||
buffer.writeln(' Valid: $isValid');
|
||||
buffer.writeln(' Found sections: ${foundSections.join(', ')}');
|
||||
buffer.writeln(' Has animations: $hasAnimations');
|
||||
|
||||
if (issues.isNotEmpty) {
|
||||
buffer.writeln(' Issues:');
|
||||
for (final issue in issues) {
|
||||
buffer.writeln(' - $issue');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import '../models/activity_event.dart';
|
||||
|
||||
class DatabaseManager {
|
||||
final Database _db;
|
||||
|
||||
DatabaseManager(this._db);
|
||||
|
||||
void initDatabase() {
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS activity_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type_id TEXT NOT NULL,
|
||||
application_id TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
duration_seconds INTEGER DEFAULT 0
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS daily_stats (
|
||||
date TEXT PRIMARY KEY,
|
||||
total_xp INTEGER DEFAULT 0,
|
||||
level INTEGER DEFAULT 1,
|
||||
focus_time_seconds INTEGER DEFAULT 0,
|
||||
meeting_time_seconds INTEGER DEFAULT 0,
|
||||
level_up_timestamp INTEGER DEFAULT 0
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS focus_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
bonus_xp INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
xp_reward INTEGER NOT NULL,
|
||||
achieved_at INTEGER NOT NULL,
|
||||
level_at_achievement INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS theme_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level INTEGER NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS application_classifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_name TEXT NOT NULL UNIQUE,
|
||||
category_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS unclassified_applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_name TEXT NOT NULL UNIQUE,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
occurrence_count INTEGER DEFAULT 1
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
void saveActivityEvent(String type, String application, String? metadata, int timestamp, int durationSeconds) {
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO activity_events (type_id, application_id, metadata, timestamp, duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''',
|
||||
[type, application, metadata, timestamp, durationSeconds],
|
||||
);
|
||||
}
|
||||
|
||||
void updateDailyStats(int xp, int focusSeconds, int meetingSeconds) {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO daily_stats (date, total_xp, focus_time_seconds, meeting_time_seconds)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
total_xp = total_xp + ?,
|
||||
focus_time_seconds = focus_time_seconds + ?,
|
||||
meeting_time_seconds = meeting_time_seconds + ?
|
||||
''',
|
||||
[today, xp, focusSeconds, meetingSeconds, xp, focusSeconds, meetingSeconds],
|
||||
);
|
||||
|
||||
final stats = _db.select('SELECT total_xp FROM daily_stats WHERE date = ?', [today]);
|
||||
if (stats.isNotEmpty) {
|
||||
final totalXP = stats.first['total_xp'] as int;
|
||||
final newLevel = calculateLevel(totalXP);
|
||||
|
||||
_db.execute('UPDATE daily_stats SET level = ? WHERE date = ?', [newLevel, today]);
|
||||
}
|
||||
}
|
||||
|
||||
void saveFocusSession(String date, int durationMinutes, int bonusXP, int timestamp) {
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO focus_sessions (date, duration_minutes, bonus_xp, timestamp)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''',
|
||||
[date, durationMinutes, bonusXP, timestamp],
|
||||
);
|
||||
}
|
||||
|
||||
void saveAchievement(String name, String description, int xpReward, int achievedAt, int levelAtAchievement) {
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO achievements (name, description, xp_reward, achieved_at, level_at_achievement)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''',
|
||||
[name, description, xpReward, achievedAt, levelAtAchievement],
|
||||
);
|
||||
}
|
||||
|
||||
void recordThemeChange(int level, String themeName, int appliedAt) {
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO theme_history (level, theme_name, applied_at)
|
||||
VALUES (?, ?, ?)
|
||||
''',
|
||||
[level, themeName, appliedAt],
|
||||
);
|
||||
}
|
||||
|
||||
void updateLevelUpTimestamp(String date, int timestamp) {
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE daily_stats SET level_up_timestamp = ? WHERE date = ?
|
||||
''',
|
||||
[timestamp, date],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> getTodayStats() {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final stats = _db.select('SELECT * FROM daily_stats WHERE date = ?', [today]);
|
||||
|
||||
if (stats.isEmpty) {
|
||||
return {'level': 1, 'xp': 0, 'focus_time': 0, 'meeting_time': 0, 'focus_sessions': 0};
|
||||
}
|
||||
|
||||
final row = stats.first;
|
||||
final focusSessions = _db.select('SELECT COUNT(*) as count FROM focus_sessions WHERE date = ?', [today]);
|
||||
final sessionCount = focusSessions.isNotEmpty ? focusSessions.first['count'] : 0;
|
||||
|
||||
return {
|
||||
'level': row['level'],
|
||||
'xp': row['total_xp'],
|
||||
'focus_time': row['focus_time_seconds'],
|
||||
'meeting_time': row['meeting_time_seconds'],
|
||||
'focus_sessions': sessionCount,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> getStreakStats() {
|
||||
final result = _db.select('''
|
||||
SELECT date, focus_time_seconds
|
||||
FROM daily_stats
|
||||
WHERE focus_time_seconds > 0
|
||||
ORDER BY date DESC
|
||||
LIMIT 30
|
||||
''');
|
||||
|
||||
int currentStreak = 0;
|
||||
int longestStreak = 0;
|
||||
int tempStreak = 0;
|
||||
|
||||
final today = DateTime.now();
|
||||
|
||||
for (int i = 0; i < result.length; i++) {
|
||||
final dateStr = result[i]['date'] as String;
|
||||
final date = DateTime.parse(dateStr);
|
||||
final daysDiff = today.difference(date).inDays;
|
||||
|
||||
if (daysDiff == i) {
|
||||
tempStreak++;
|
||||
if (i == 0) currentStreak = tempStreak;
|
||||
} else {
|
||||
if (tempStreak > longestStreak) longestStreak = tempStreak;
|
||||
tempStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (tempStreak > longestStreak) longestStreak = tempStreak;
|
||||
|
||||
return {'current_streak': currentStreak, 'longest_streak': longestStreak};
|
||||
}
|
||||
|
||||
List<Row> getRecentAchievements() {
|
||||
return _db.select('''
|
||||
SELECT name FROM achievements
|
||||
WHERE DATE(achieved_at/1000, 'unixepoch') = DATE('now')
|
||||
ORDER BY achieved_at DESC LIMIT 3
|
||||
''');
|
||||
}
|
||||
|
||||
bool hasAchievement(String name) {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final result = _db.select(
|
||||
'''
|
||||
SELECT COUNT(*) as count FROM achievements
|
||||
WHERE name = ? AND DATE(achieved_at/1000, 'unixepoch') = ?
|
||||
''',
|
||||
[name, today],
|
||||
);
|
||||
return result.isNotEmpty && (result.first['count'] as int) > 0;
|
||||
}
|
||||
|
||||
int calculateLevel(int totalXP) {
|
||||
return (totalXP / 100).floor() + 1;
|
||||
}
|
||||
|
||||
// Dashboard query methods
|
||||
List<Row> getStatsHistory(int days) {
|
||||
final history = <Row>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = days - 1; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final dateStr = date.toIso8601String().substring(0, 10);
|
||||
|
||||
final stats = _db.select('SELECT * FROM daily_stats WHERE date = ?', [dateStr]);
|
||||
|
||||
if (stats.isNotEmpty) {
|
||||
history.addAll(stats);
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
List<Row> getAllAchievements([int limit = 50]) {
|
||||
return _db.select(
|
||||
'''
|
||||
SELECT * FROM achievements
|
||||
ORDER BY achieved_at DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
[limit],
|
||||
);
|
||||
}
|
||||
|
||||
List<Row> getRecentActivities([int limit = 100]) {
|
||||
return _db.select(
|
||||
'''
|
||||
SELECT * FROM activity_events
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
[limit],
|
||||
);
|
||||
}
|
||||
|
||||
List<Row> getRecentFocusSessions([int limit = 50]) {
|
||||
return _db.select(
|
||||
'''
|
||||
SELECT * FROM focus_sessions
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
[limit],
|
||||
);
|
||||
}
|
||||
|
||||
List<Row> getRecentActivity([int limit = 5]) {
|
||||
final today = DateTime.now();
|
||||
final startOfDay = DateTime(today.year, today.month, today.day).millisecondsSinceEpoch;
|
||||
final endOfDay = DateTime(today.year, today.month, today.day, 23, 59, 59, 999).millisecondsSinceEpoch;
|
||||
|
||||
return _db.select(
|
||||
'''
|
||||
SELECT type_id, application_id, timestamp, duration_seconds
|
||||
FROM activity_events
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
[startOfDay, endOfDay, limit],
|
||||
);
|
||||
}
|
||||
|
||||
List<Row> getDailyStatsForDate(String date) {
|
||||
return _db.select('SELECT * FROM daily_stats WHERE date = ?', [date]);
|
||||
}
|
||||
|
||||
Map<String, int> getXPBreakdownForDate(String date) {
|
||||
final breakdown = <String, int>{};
|
||||
|
||||
// Get activities for the date
|
||||
final startOfDay = DateTime.parse('${date}T00:00:00').millisecondsSinceEpoch;
|
||||
final endOfDay = DateTime.parse('${date}T23:59:59').millisecondsSinceEpoch;
|
||||
|
||||
final activities = _db.select(
|
||||
'''
|
||||
SELECT type_id, application_id, duration_seconds
|
||||
FROM activity_events
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
''',
|
||||
[startOfDay, endOfDay],
|
||||
);
|
||||
|
||||
// Calculate XP for each activity category using the new ActivityCategory system
|
||||
for (final activity in activities) {
|
||||
String type = activity['type_id'] as String;
|
||||
final application = activity['application_id'] as String;
|
||||
final durationSeconds = activity['duration_seconds'] as int;
|
||||
final durationMinutes = (durationSeconds / 60).ceil();
|
||||
|
||||
// Determine category using the new ActivityCategory system
|
||||
String category;
|
||||
int xpPerMinute;
|
||||
|
||||
// Check if user has classified this application
|
||||
final userClassification = getApplicationClassification(application);
|
||||
if (userClassification != null) {
|
||||
type = userClassification;
|
||||
}
|
||||
final activityCategory = ActivityEventType.categorize(eventId: type, applicationId: application);
|
||||
category = activityCategory.id;
|
||||
xpPerMinute = _getXPForActivityEventType(activityCategory);
|
||||
|
||||
final xpEarned = durationMinutes * xpPerMinute;
|
||||
breakdown[category] = (breakdown[category] ?? 0) + xpEarned;
|
||||
}
|
||||
|
||||
// Add focus session bonuses
|
||||
final focusSessions = _db.select(
|
||||
'''
|
||||
SELECT bonus_xp FROM focus_sessions WHERE date = ?
|
||||
''',
|
||||
[date],
|
||||
);
|
||||
|
||||
if (focusSessions.isNotEmpty) {
|
||||
final totalFocusBonus = focusSessions.fold<int>(0, (sum, session) => sum + (session['bonus_xp'] as int));
|
||||
breakdown['focus_session'] = totalFocusBonus;
|
||||
}
|
||||
|
||||
// Add achievement XP
|
||||
final achievements = _db.select(
|
||||
'''
|
||||
SELECT xp_reward FROM achievements
|
||||
WHERE DATE(achieved_at/1000, 'unixepoch') = ?
|
||||
''',
|
||||
[date],
|
||||
);
|
||||
|
||||
if (achievements.isNotEmpty) {
|
||||
final totalAchievementXP = achievements.fold<int>(
|
||||
0,
|
||||
(sum, achievement) => sum + (achievement['xp_reward'] as int),
|
||||
);
|
||||
breakdown['achievement'] = totalAchievementXP;
|
||||
}
|
||||
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
/// Get XP per minute for a given ActivityEventType using exhaustive matching
|
||||
int _getXPForActivityEventType(ActivityEventType eventType) {
|
||||
switch (eventType) {
|
||||
case ActivityEventType.coding:
|
||||
return 10;
|
||||
case ActivityEventType.focusedBrowsing:
|
||||
return 6;
|
||||
case ActivityEventType.collaboration:
|
||||
return 7;
|
||||
case ActivityEventType.meetings:
|
||||
return 3;
|
||||
case ActivityEventType.misc:
|
||||
return 2;
|
||||
case ActivityEventType.uncategorized:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, int> getTodayXPBreakdown() {
|
||||
final today = DateTime.now().toIso8601String().substring(0, 10);
|
||||
return getXPBreakdownForDate(today);
|
||||
}
|
||||
|
||||
// Application classification methods
|
||||
void saveApplicationClassification(String applicationName, String categoryId) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO application_classifications (application_name, category_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(application_name) DO UPDATE SET
|
||||
category_id = ?,
|
||||
updated_at = ?
|
||||
''',
|
||||
[applicationName, categoryId, now, now, categoryId, now],
|
||||
);
|
||||
|
||||
// Remove from unclassified if it exists
|
||||
_db.execute('DELETE FROM unclassified_applications WHERE application_name = ?', [applicationName]);
|
||||
}
|
||||
|
||||
String? getApplicationClassification(String applicationName) {
|
||||
final result = _db.select('SELECT category_id FROM application_classifications WHERE application_name = ?', [
|
||||
applicationName,
|
||||
]);
|
||||
return result.isNotEmpty ? result.first['category_id'] as String : null;
|
||||
}
|
||||
|
||||
List<Row> getAllApplicationClassifications() {
|
||||
return _db.select('''
|
||||
SELECT * FROM application_classifications
|
||||
ORDER BY application_name ASC
|
||||
''');
|
||||
}
|
||||
|
||||
void trackUnclassifiedApplication(String applicationName) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO unclassified_applications (application_name, first_seen, last_seen, occurrence_count)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(application_name) DO UPDATE SET
|
||||
last_seen = ?,
|
||||
occurrence_count = occurrence_count + 1
|
||||
''',
|
||||
[applicationName, now, now, now],
|
||||
);
|
||||
}
|
||||
|
||||
List<Row> getUnclassifiedApplications() {
|
||||
return _db.select('''
|
||||
SELECT * FROM unclassified_applications
|
||||
ORDER BY occurrence_count DESC, last_seen DESC
|
||||
''');
|
||||
}
|
||||
|
||||
void deleteApplicationClassification(String applicationName) {
|
||||
_db.execute('DELETE FROM application_classifications WHERE application_name = ?', [applicationName]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../interfaces/i_activity_detector.dart';
|
||||
import '../models/activity_event.dart';
|
||||
|
||||
/// Hyprland-specific activity detector that polls the active window
|
||||
class HyprlandActivityDetector implements IActivityDetector {
|
||||
final StreamController<ActivityEvent> _activityController = StreamController<ActivityEvent>.broadcast();
|
||||
Timer? _pollTimer;
|
||||
String? _lastActiveWindow;
|
||||
String? _lastActiveWindowTitle;
|
||||
DateTime? _lastActivityTime;
|
||||
bool _isRunning = false;
|
||||
|
||||
@override
|
||||
Stream<ActivityEvent> get activityStream => _activityController.stream;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
if (_isRunning) return;
|
||||
|
||||
_isRunning = true;
|
||||
_pollTimer = Timer.periodic(Duration(seconds: 5), (_) => _pollActivity());
|
||||
print('🔍 Started Hyprland activity detection...');
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
_isRunning = false;
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
|
||||
// Flush any remaining activity
|
||||
if (_lastActiveWindow != null && _lastActivityTime != null) {
|
||||
final duration = DateTime.now().difference(_lastActivityTime!).inSeconds;
|
||||
if (duration >= 10) {
|
||||
_emitActivityEvent(_lastActiveWindow!, _lastActiveWindowTitle ?? '', duration);
|
||||
}
|
||||
}
|
||||
|
||||
_activityController.close();
|
||||
print('🛑 Stopped Hyprland activity detection');
|
||||
}
|
||||
|
||||
Future<void> _pollActivity() async {
|
||||
try {
|
||||
final result = await Process.run('hyprctl', ['activewindow', '-j']);
|
||||
if (result.exitCode != 0) return;
|
||||
|
||||
final windowData = jsonDecode(result.stdout);
|
||||
final currentApp = windowData['class'] as String? ?? 'unknown';
|
||||
final currentWindowTitle = windowData['title'] as String? ?? '';
|
||||
final now = DateTime.now();
|
||||
|
||||
// If this is a different activity from the last one, emit the previous activity
|
||||
if (_lastActiveWindow != null &&
|
||||
(_lastActiveWindow != currentApp || _lastActiveWindowTitle != currentWindowTitle)) {
|
||||
final duration = now.difference(_lastActivityTime ?? now).inSeconds;
|
||||
if (duration >= 10) {
|
||||
_emitActivityEvent(_lastActiveWindow!, _lastActiveWindowTitle ?? '', duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Update current activity
|
||||
_lastActiveWindow = currentApp;
|
||||
_lastActiveWindowTitle = currentWindowTitle;
|
||||
_lastActivityTime = now;
|
||||
} catch (e) {
|
||||
print('Error polling Hyprland activity: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _emitActivityEvent(String application, String title, int durationSeconds) {
|
||||
final event = ActivityEvent(
|
||||
type: ActivityEventType.categorize(applicationId: application, applicationTitle: title),
|
||||
application: application,
|
||||
metadata: title,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
_activityController.add(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActivityEvent> getCurrentActivity() {
|
||||
return _activityController.stream.single;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../interfaces/i_idle_monitor.dart';
|
||||
|
||||
class IdleMonitor implements IIdleMonitor {
|
||||
static const String _idleLightStartKey = 'IDLE_LIGHT_START';
|
||||
static const String _idleDeepStartKey = 'IDLE_DEEP_START';
|
||||
static const String _idleEndKey = 'IDLE_END';
|
||||
|
||||
Process? _hypridleProcess;
|
||||
final StreamController<IdleStatus> _idleStateController = StreamController<IdleStatus>.broadcast();
|
||||
IdleStatus _currentIdleStatus = IdleStatus.active;
|
||||
|
||||
@override
|
||||
Stream<IdleStatus> get idleStateStream => _idleStateController.stream;
|
||||
|
||||
@override
|
||||
IdleStatus get status => _currentIdleStatus;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
// Create a simple hypridle config for tracking
|
||||
final configContent = '''
|
||||
listener {
|
||||
timeout = 20
|
||||
on-timeout = echo "$_idleLightStartKey"
|
||||
on-resume = echo "$_idleEndKey"
|
||||
}
|
||||
|
||||
listener {
|
||||
timeout = 120
|
||||
on-timeout = echo "$_idleDeepStartKey"
|
||||
on-resume = echo "$_idleEndKey"
|
||||
}
|
||||
''';
|
||||
|
||||
final configFile = File('/tmp/productivity_hypridle.conf');
|
||||
await configFile.writeAsString(configContent);
|
||||
|
||||
// Start active
|
||||
_idleStateController.add(IdleStatus.active);
|
||||
|
||||
// Start hypridle with our config
|
||||
_hypridleProcess = await Process.start('hypridle', ['-c', configFile.path]);
|
||||
|
||||
_hypridleProcess!.stdout.transform(utf8.decoder).transform(LineSplitter()).listen(_handleHypridleOutput);
|
||||
}
|
||||
|
||||
void _handleHypridleOutput(String line) {
|
||||
switch (line) {
|
||||
case String s when s.contains(_idleLightStartKey):
|
||||
_idleStateController.add(IdleStatus.lightIdle);
|
||||
_currentIdleStatus = IdleStatus.lightIdle;
|
||||
print('User went light idle');
|
||||
case String s when s.contains(_idleDeepStartKey):
|
||||
_idleStateController.add(IdleStatus.deepIdle);
|
||||
_currentIdleStatus = IdleStatus.deepIdle;
|
||||
print('User went deep idle');
|
||||
case String s when s.contains(_idleEndKey):
|
||||
_idleStateController.add(IdleStatus.active);
|
||||
_currentIdleStatus = IdleStatus.active;
|
||||
print('User went active');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
_hypridleProcess?.kill();
|
||||
_idleStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../models/activity_event.dart';
|
||||
|
||||
class ZoomDetector {
|
||||
Future<ZoomStatus> getZoomStatus() async {
|
||||
final hasZoomProcess = await _hasZoomProcess();
|
||||
if (!hasZoomProcess) return ZoomStatus.none;
|
||||
|
||||
// Zoom is running, now determine engagement level
|
||||
final isActiveWindow = await _isZoomActiveWindow();
|
||||
final hasMediaActivity = await _hasMediaActivity();
|
||||
|
||||
if (isActiveWindow && hasMediaActivity) {
|
||||
return ZoomStatus.activeMeeting;
|
||||
} else if (hasMediaActivity) {
|
||||
return ZoomStatus.backgroundMeeting;
|
||||
} else if (isActiveWindow) {
|
||||
return ZoomStatus.zoomFocused;
|
||||
} else {
|
||||
return ZoomStatus.zoomBackground;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasZoomProcess() async {
|
||||
try {
|
||||
final result = await Process.run('pgrep', ['-f', 'zoom']);
|
||||
return result.exitCode == 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isZoomActiveWindow() async {
|
||||
try {
|
||||
final result = await Process.run('hyprctl', ['activewindow', '-j']);
|
||||
if (result.exitCode != 0) return false;
|
||||
|
||||
final windowData = jsonDecode(result.stdout);
|
||||
final className = windowData['class'] as String? ?? '';
|
||||
return className.toLowerCase().contains('zoom');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasMediaActivity() async {
|
||||
// Check both camera and microphone
|
||||
final cameraActive = await _isCameraInUse();
|
||||
final micActive = await _isMicrophoneInUse();
|
||||
return cameraActive || micActive;
|
||||
}
|
||||
|
||||
Future<bool> _isCameraInUse() async {
|
||||
try {
|
||||
// Try lsof first, fallback to checking video device existence
|
||||
final lsofResult = await Process.run('lsof', [
|
||||
'/dev/video0',
|
||||
]).timeout(Duration(seconds: 2), onTimeout: () => ProcessResult(0, 1, '', 'timeout'));
|
||||
if (lsofResult.exitCode == 0 && lsofResult.stdout.contains('zoom')) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// lsof not available, try alternative detection
|
||||
print('Warning: lsof not available, using fallback camera detection');
|
||||
}
|
||||
|
||||
// Fallback: check if video device is accessible (rough indicator)
|
||||
try {
|
||||
final videoDevice = File('/dev/video0');
|
||||
return videoDevice.existsSync();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isMicrophoneInUse() async {
|
||||
try {
|
||||
final result = await Process.run('pactl', ['list', 'source-outputs']);
|
||||
return result.stdout.toLowerCase().contains('zoom');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import 'dart:io';
|
||||
import 'package:xp_nix/src/config/hyprland_config_parser.dart';
|
||||
|
||||
import '../interfaces/i_desktop_enhancer.dart';
|
||||
|
||||
class HyprlandEnhancer implements IDesktopEnhancer {
|
||||
String _currentTheme = 'default';
|
||||
final String _configPath = '/home/nate/.config/hypr/hyprland.conf';
|
||||
final String _backupPath = '/home/nate/.config/hypr/hyprland.conf.backup';
|
||||
String? _cachedBaseConfig;
|
||||
|
||||
// Parse the current system config and extract base configuration
|
||||
Future<String> _getBaseConfig() async {
|
||||
if (_cachedBaseConfig != null) {
|
||||
return _cachedBaseConfig!;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsedConfig = await HyprlandConfigParser.parseConfigFile(_configPath);
|
||||
_cachedBaseConfig = parsedConfig.baseConfig;
|
||||
return _cachedBaseConfig!;
|
||||
} catch (e) {
|
||||
print('Warning: Could not read system config, $e\nAborting.');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> celebrateLevelUp(int level) async {
|
||||
print('🎉 Celebrating level up to $level!');
|
||||
await applyThemeForLevel(level);
|
||||
|
||||
// Send celebration notification
|
||||
try {
|
||||
await Process.run('notify-send', [
|
||||
'🎉 LEVEL UP!',
|
||||
'Welcome to Level $level!\nYour desktop has been enhanced!',
|
||||
'--urgency=normal',
|
||||
'--expire-time=5000',
|
||||
]);
|
||||
} catch (e) {
|
||||
print('Could not send level up notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyThemeForLevel(int level) async {
|
||||
String theme = 'default';
|
||||
|
||||
if (level >= 25) {
|
||||
theme = 'legendary';
|
||||
} else if (level >= 15) {
|
||||
theme = 'master';
|
||||
} else if (level >= 10) {
|
||||
theme = 'advanced';
|
||||
} else if (level >= 5) {
|
||||
theme = 'intermediate';
|
||||
}
|
||||
|
||||
_currentTheme = theme;
|
||||
print('🎨 Applied theme: $theme for level $level');
|
||||
|
||||
// Apply visual enhancements based on level
|
||||
await _applyVisualEffects(level);
|
||||
}
|
||||
|
||||
Future<void> _applyVisualEffects(int level) async {
|
||||
try {
|
||||
// Create backup if it doesn't exist
|
||||
await _createBackupIfNeeded();
|
||||
|
||||
// Generate config for this level
|
||||
final config = await _generateConfigForLevel(level);
|
||||
|
||||
// Try to apply via hyprctl first (for dynamic changes)
|
||||
await _applyDynamicChanges(level);
|
||||
|
||||
// Write the full config file
|
||||
await _writeConfig(config);
|
||||
|
||||
// Reload Hyprland to apply changes
|
||||
await _reloadHyprland();
|
||||
|
||||
print('✅ Successfully applied level $level configuration');
|
||||
} catch (e) {
|
||||
print('❌ Could not apply visual effects: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createBackupIfNeeded() async {
|
||||
final backupFile = File(_backupPath);
|
||||
if (!backupFile.existsSync()) {
|
||||
final configFile = File(_configPath);
|
||||
if (configFile.existsSync()) {
|
||||
await configFile.copy(_backupPath);
|
||||
print('📋 Created backup at $_backupPath');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _generateConfigForLevel(int level) async {
|
||||
final baseConfig = await _getBaseConfig();
|
||||
final decorationConfig = _getDecorationConfig(level);
|
||||
final generalConfig = _getGeneralConfig(level);
|
||||
final animationConfig = _getAnimationConfig(level);
|
||||
|
||||
return baseConfig + decorationConfig + generalConfig + animationConfig;
|
||||
}
|
||||
|
||||
String _getDecorationConfig(int level) {
|
||||
if (level >= 25) {
|
||||
// Legendary - Maximum effects
|
||||
return '''
|
||||
decoration {
|
||||
blur {
|
||||
enabled=true
|
||||
passes=3
|
||||
size=15
|
||||
brightness=1.1
|
||||
contrast=1.2
|
||||
noise=0.02
|
||||
vibrancy=0.3
|
||||
vibrancy_darkness=0.2
|
||||
}
|
||||
|
||||
shadow {
|
||||
enabled=true
|
||||
range=20
|
||||
render_power=4
|
||||
color=rgba(7e5fddaa)
|
||||
offset=0 0
|
||||
}
|
||||
|
||||
dim_inactive=true
|
||||
dim_strength=0.15
|
||||
inactive_opacity=0.85
|
||||
active_opacity=1.0
|
||||
rounding=20
|
||||
|
||||
drop_shadow=true
|
||||
}
|
||||
''';
|
||||
} else if (level >= 15) {
|
||||
// Master - Advanced effects
|
||||
return '''
|
||||
decoration {
|
||||
blur {
|
||||
enabled=true
|
||||
passes=2
|
||||
size=12
|
||||
brightness=1.05
|
||||
contrast=1.1
|
||||
noise=0.01
|
||||
vibrancy=0.2
|
||||
}
|
||||
|
||||
shadow {
|
||||
enabled=true
|
||||
range=15
|
||||
render_power=3
|
||||
color=rgba(7e5fdd88)
|
||||
offset=0 0
|
||||
}
|
||||
|
||||
dim_inactive=true
|
||||
dim_strength=0.12
|
||||
inactive_opacity=0.88
|
||||
active_opacity=1.0
|
||||
rounding=16
|
||||
|
||||
drop_shadow=true
|
||||
}
|
||||
''';
|
||||
} else if (level >= 10) {
|
||||
// Advanced - Enhanced blur and shadows
|
||||
return '''
|
||||
decoration {
|
||||
blur {
|
||||
enabled=true
|
||||
passes=2
|
||||
size=10
|
||||
brightness=1.0
|
||||
contrast=1.05
|
||||
}
|
||||
|
||||
shadow {
|
||||
enabled=true
|
||||
range=10
|
||||
render_power=2
|
||||
color=rgba(7e5fdd66)
|
||||
}
|
||||
|
||||
dim_inactive=true
|
||||
dim_strength=0.10
|
||||
inactive_opacity=0.90
|
||||
rounding=12
|
||||
}
|
||||
''';
|
||||
} else if (level >= 5) {
|
||||
// Intermediate - Basic blur
|
||||
return '''
|
||||
decoration {
|
||||
blur {
|
||||
enabled=true
|
||||
passes=1
|
||||
size=8
|
||||
}
|
||||
|
||||
shadow {
|
||||
enabled=false
|
||||
}
|
||||
|
||||
dim_inactive=true
|
||||
dim_strength=0.08
|
||||
inactive_opacity=0.92
|
||||
rounding=8
|
||||
}
|
||||
''';
|
||||
} else {
|
||||
// Default - Minimal effects
|
||||
return '''
|
||||
decoration {
|
||||
blur {
|
||||
enabled=false
|
||||
}
|
||||
|
||||
shadow {
|
||||
enabled=false
|
||||
}
|
||||
|
||||
dim_inactive=false
|
||||
inactive_opacity=1.0
|
||||
rounding=4
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
String _getGeneralConfig(int level) {
|
||||
if (level >= 25) {
|
||||
// Legendary - Animated borders
|
||||
return '''
|
||||
general {
|
||||
border_size=4
|
||||
col.active_border=rgba(7e5fddff) rgba(ff5100ff) rgba(00ff88ff) 45deg
|
||||
col.inactive_border=rgba(595959aa)
|
||||
gaps_in=4
|
||||
gaps_out=8
|
||||
resize_on_border=true
|
||||
extend_border_grab_area=15
|
||||
}
|
||||
''';
|
||||
} else if (level >= 15) {
|
||||
// Master - Gradient borders
|
||||
return '''
|
||||
general {
|
||||
border_size=3
|
||||
col.active_border=rgba(7e5fddff) rgba(ff5100ff) 60deg
|
||||
col.inactive_border=rgba(59595988)
|
||||
gaps_in=3
|
||||
gaps_out=6
|
||||
resize_on_border=true
|
||||
}
|
||||
''';
|
||||
} else if (level >= 10) {
|
||||
// Advanced - Colored borders
|
||||
return '''
|
||||
general {
|
||||
border_size=3
|
||||
col.active_border=rgba(7e5fddff)
|
||||
col.inactive_border=rgba(59595966)
|
||||
gaps_in=3
|
||||
gaps_out=5
|
||||
}
|
||||
''';
|
||||
} else if (level >= 5) {
|
||||
// Intermediate - Thin borders
|
||||
return '''
|
||||
general {
|
||||
border_size=2
|
||||
col.active_border=rgba(7e5fddcc)
|
||||
col.inactive_border=rgba(59595944)
|
||||
gaps_in=2
|
||||
gaps_out=4
|
||||
}
|
||||
''';
|
||||
} else {
|
||||
// Default - Minimal borders
|
||||
return '''
|
||||
general {
|
||||
border_size=1
|
||||
col.active_border=rgba(7e5fddaa)
|
||||
col.inactive_border=rgba(59595922)
|
||||
gaps_in=1
|
||||
gaps_out=2
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
String _getAnimationConfig(int level) {
|
||||
if (level >= 25) {
|
||||
// Legendary - Full animations
|
||||
return '''
|
||||
animation=windows, 1, 8, easeout, slide
|
||||
animation=windowsOut, 1, 8, easeout, slide
|
||||
animation=border, 1, 10, easeout
|
||||
animation=borderangle, 1, 8, easeout
|
||||
animation=fade, 1, 7, easeout
|
||||
animation=workspaces, 1, 6, easeout, slide
|
||||
animation=specialWorkspace, 1, 6, easeout, slidevert
|
||||
''';
|
||||
} else if (level >= 15) {
|
||||
// Master - Enhanced animations
|
||||
return '''
|
||||
animation=windows, 1, 6, easeout, slide
|
||||
animation=windowsOut, 1, 6, easeout, slide
|
||||
animation=border, 1, 8, easeout
|
||||
animation=fade, 1, 5, easeout
|
||||
animation=workspaces, 1, 4, easeout, slide
|
||||
''';
|
||||
} else if (level >= 10) {
|
||||
// Advanced - Smooth animations
|
||||
return '''
|
||||
animation=windows, 1, 4, easeout
|
||||
animation=windowsOut, 1, 4, easeout
|
||||
animation=fade, 1, 4, easeout
|
||||
animation=workspaces, 1, 3, easeout
|
||||
''';
|
||||
} else if (level >= 5) {
|
||||
// Intermediate - Basic animations
|
||||
return '''
|
||||
animation=windows, 1, 3, easeout
|
||||
animation=fade, 1, 3, easeout
|
||||
animation=workspaces, 1, 2, easeout
|
||||
''';
|
||||
} else {
|
||||
// Default - Minimal animations
|
||||
return '''
|
||||
animation=workspaces, 1, 1, easeout
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyDynamicChanges(int level) async {
|
||||
try {
|
||||
// Apply some changes via hyprctl for immediate effect
|
||||
if (level >= 10) {
|
||||
await Process.run('hyprctl', ['keyword', 'decoration:blur:enabled', 'true']);
|
||||
await Process.run('hyprctl', [
|
||||
'keyword',
|
||||
'decoration:blur:passes',
|
||||
level >= 25
|
||||
? '3'
|
||||
: level >= 15
|
||||
? '2'
|
||||
: '1',
|
||||
]);
|
||||
} else {
|
||||
await Process.run('hyprctl', ['keyword', 'decoration:blur:enabled', 'false']);
|
||||
}
|
||||
|
||||
if (level >= 15) {
|
||||
await Process.run('hyprctl', ['keyword', 'decoration:shadow:enabled', 'true']);
|
||||
} else {
|
||||
await Process.run('hyprctl', ['keyword', 'decoration:shadow:enabled', 'false']);
|
||||
}
|
||||
|
||||
// Set border size
|
||||
final borderSize =
|
||||
level >= 25
|
||||
? '4'
|
||||
: level >= 10
|
||||
? '3'
|
||||
: level >= 5
|
||||
? '2'
|
||||
: '1';
|
||||
await Process.run('hyprctl', ['keyword', 'general:border_size', borderSize]);
|
||||
|
||||
// Set rounding
|
||||
final rounding =
|
||||
level >= 25
|
||||
? '20'
|
||||
: level >= 15
|
||||
? '16'
|
||||
: level >= 10
|
||||
? '12'
|
||||
: level >= 5
|
||||
? '8'
|
||||
: '4';
|
||||
await Process.run('hyprctl', ['keyword', 'decoration:rounding', rounding]);
|
||||
} catch (e) {
|
||||
print('Warning: Could not apply some dynamic changes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeConfig(String config) async {
|
||||
final configFile = File(_configPath);
|
||||
await configFile.writeAsString(config);
|
||||
print('📝 Updated configuration file');
|
||||
}
|
||||
|
||||
Future<void> _reloadHyprland() async {
|
||||
try {
|
||||
await Process.run('hyprctl', ['reload']);
|
||||
print('🔄 Reloaded Hyprland configuration');
|
||||
} catch (e) {
|
||||
print('Warning: Could not reload Hyprland: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String getCurrentThemeInfo() {
|
||||
return 'Theme: $_currentTheme';
|
||||
}
|
||||
|
||||
// Refresh the cached base config from the current system config
|
||||
@override
|
||||
Future<void> refreshBaseConfig() async {
|
||||
_cachedBaseConfig = null; // Clear cache
|
||||
try {
|
||||
final baseConfig = await _getBaseConfig();
|
||||
print('🔄 Refreshed base config from system (${baseConfig.split('\n').length} lines)');
|
||||
} catch (e) {
|
||||
print('❌ Could not refresh base config: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restoreBackup() async {
|
||||
try {
|
||||
final backupFile = File(_backupPath);
|
||||
if (backupFile.existsSync()) {
|
||||
await backupFile.copy(_configPath);
|
||||
await _reloadHyprland();
|
||||
print('🔧 Restored desktop from backup');
|
||||
_currentTheme = 'default';
|
||||
// Clear cached config since we restored from backup
|
||||
_cachedBaseConfig = null;
|
||||
} else {
|
||||
print('❌ No backup found at $_backupPath');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ Could not restore backup: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'package:xp_nix/src/models/activity_event.dart';
|
||||
|
||||
/// Abstract interface for activity detection to enable dependency injection and testing
|
||||
abstract class IActivityDetector {
|
||||
/// Stream that emits activity events when window/application changes occur
|
||||
Stream<ActivityEvent> get activityStream;
|
||||
|
||||
/// Start detecting activity changes
|
||||
Future<void> start();
|
||||
|
||||
/// Stop detecting activity changes
|
||||
void stop();
|
||||
|
||||
/// Get current active application info
|
||||
Future<ActivityEvent> getCurrentActivity();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Interface for desktop enhancement functionality
|
||||
abstract class IDesktopEnhancer {
|
||||
/// Apply theme for the given level
|
||||
Future<void> applyThemeForLevel(int level);
|
||||
|
||||
/// Celebrate level up with visual effects
|
||||
Future<void> celebrateLevelUp(int level);
|
||||
|
||||
/// Get current theme information
|
||||
String getCurrentThemeInfo();
|
||||
|
||||
/// Restore desktop backup
|
||||
Future<void> restoreBackup();
|
||||
|
||||
/// Refresh base config from system
|
||||
Future<void> refreshBaseConfig();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
enum IdleStatus { active, lightIdle, deepIdle }
|
||||
|
||||
/// Abstract interface for idle monitoring to enable dependency injection and testing
|
||||
abstract class IIdleMonitor {
|
||||
/// Stream that emits true when user becomes idle, false when active
|
||||
Stream<IdleStatus> get idleStateStream;
|
||||
|
||||
/// Current idle state
|
||||
IdleStatus get status;
|
||||
|
||||
/// Start monitoring for idle state changes
|
||||
Future<void> start();
|
||||
|
||||
/// Stop monitoring
|
||||
void stop();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Abstract interface for time operations to enable controllable time in tests
|
||||
abstract class ITimeProvider {
|
||||
/// Get current date and time
|
||||
DateTime now();
|
||||
|
||||
/// Create a periodic timer
|
||||
Timer periodic(Duration duration, void Function(Timer) callback);
|
||||
|
||||
/// Create a one-time timer
|
||||
Timer timer(Duration duration, void Function() callback);
|
||||
}
|
||||
|
||||
/// Production implementation of ITimeProvider
|
||||
class SystemTimeProvider implements ITimeProvider {
|
||||
@override
|
||||
DateTime now() => DateTime.now();
|
||||
|
||||
@override
|
||||
Timer periodic(Duration duration, void Function(Timer) callback) {
|
||||
return Timer.periodic(duration, callback);
|
||||
}
|
||||
|
||||
@override
|
||||
Timer timer(Duration duration, void Function() callback) {
|
||||
return Timer(duration, callback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:xp_nix/src/models/activity_event.dart';
|
||||
|
||||
enum LogLevel {
|
||||
debug(0, 'DEBUG'),
|
||||
info(1, 'INFO'),
|
||||
warn(2, 'WARN'),
|
||||
error(3, 'ERROR');
|
||||
|
||||
const LogLevel(this.value, this.name);
|
||||
final int value;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
static Logger? _instance;
|
||||
static Logger get instance => _instance ??= Logger._();
|
||||
|
||||
Logger._();
|
||||
|
||||
LogLevel _currentLevel = LogLevel.info;
|
||||
String _logDirectory = 'logs';
|
||||
int _maxFileSizeMB = 10;
|
||||
int _maxFiles = 5;
|
||||
File? _currentLogFile;
|
||||
final StreamController<String> _logStreamController = StreamController<String>.broadcast();
|
||||
|
||||
Stream<String> get logStream => _logStreamController.stream;
|
||||
|
||||
Future<void> initialize({
|
||||
LogLevel level = LogLevel.info,
|
||||
String logDirectory = 'logs',
|
||||
int maxFileSizeMB = 10,
|
||||
int maxFiles = 5,
|
||||
}) async {
|
||||
_currentLevel = level;
|
||||
_logDirectory = logDirectory;
|
||||
_maxFileSizeMB = maxFileSizeMB;
|
||||
_maxFiles = maxFiles;
|
||||
|
||||
await _setupLogFile();
|
||||
_info('Logger initialized with level: ${level.name}');
|
||||
}
|
||||
|
||||
Future<void> _setupLogFile() async {
|
||||
final logDir = Directory(_logDirectory);
|
||||
if (!await logDir.exists()) {
|
||||
await logDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.')[0];
|
||||
_currentLogFile = File('$_logDirectory/xp_nix_$timestamp.log');
|
||||
|
||||
// Create the file if it doesn't exist
|
||||
if (!await _currentLogFile!.exists()) {
|
||||
await _currentLogFile!.create();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rotateLogIfNeeded() async {
|
||||
if (_currentLogFile == null) return;
|
||||
|
||||
try {
|
||||
final stats = await _currentLogFile!.stat();
|
||||
final fileSizeMB = stats.size / (1024 * 1024);
|
||||
|
||||
if (fileSizeMB > _maxFileSizeMB) {
|
||||
await _cleanupOldLogs();
|
||||
await _setupLogFile();
|
||||
_info('Log file rotated due to size limit');
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't check file size, just continue
|
||||
print('Could not check log file size: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupOldLogs() async {
|
||||
final logDir = Directory(_logDirectory);
|
||||
if (!await logDir.exists()) return;
|
||||
|
||||
try {
|
||||
final logFiles =
|
||||
await logDir.list().where((entity) => entity is File && entity.path.endsWith('.log')).cast<File>().toList();
|
||||
|
||||
logFiles.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
|
||||
|
||||
if (logFiles.length >= _maxFiles) {
|
||||
for (int i = _maxFiles - 1; i < logFiles.length; i++) {
|
||||
_info('Deleting old log file ${logFiles[i].toString()}');
|
||||
await logFiles[i].delete();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Could not cleanup old logs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _log(LogLevel level, String message, [Object? error, StackTrace? stackTrace]) {
|
||||
if (level.value < _currentLevel.value) return;
|
||||
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
final logEntry = '[$timestamp] [${level.name}] $message';
|
||||
|
||||
// Print to console
|
||||
print(logEntry);
|
||||
|
||||
// Add to stream for real-time monitoring
|
||||
_logStreamController.add(logEntry);
|
||||
|
||||
// Write to file asynchronously
|
||||
_writeToFileAsync(logEntry, error, stackTrace);
|
||||
}
|
||||
|
||||
void _writeToFileAsync(String logEntry, [Object? error, StackTrace? stackTrace]) {
|
||||
// Use Future.microtask to avoid blocking the current execution
|
||||
Future.microtask(() async {
|
||||
await _writeToFile(logEntry, error, stackTrace);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _writeToFile(String logEntry, [Object? error, StackTrace? stackTrace]) async {
|
||||
if (_currentLogFile == null) return;
|
||||
|
||||
try {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln(logEntry);
|
||||
|
||||
if (error != null) {
|
||||
buffer.writeln('Error: $error');
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
buffer.writeln('Stack trace: $stackTrace');
|
||||
}
|
||||
|
||||
// Write to file using writeAsString with append mode
|
||||
await _currentLogFile!.writeAsString(buffer.toString(), mode: FileMode.append, flush: true);
|
||||
|
||||
// Check if rotation is needed
|
||||
await _rotateLogIfNeeded();
|
||||
} catch (e) {
|
||||
// If writing fails, try to recreate the log file
|
||||
print('Log writing failed: $e');
|
||||
try {
|
||||
await _setupLogFile();
|
||||
} catch (setupError) {
|
||||
print('Could not recreate log file: $setupError');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Static convenience methods
|
||||
static void debug(String message) => instance._log(LogLevel.debug, message);
|
||||
static void info(String message) => instance._log(LogLevel.info, message);
|
||||
static void warn(String message) => instance._log(LogLevel.warn, message);
|
||||
static void error(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||
instance._log(LogLevel.error, message, error, stackTrace);
|
||||
|
||||
// Instance methods for when you have a logger instance
|
||||
// ignore: unused_element
|
||||
void _debug(String message) => _log(LogLevel.debug, message);
|
||||
// ignore: unused_element
|
||||
void _info(String message) => _log(LogLevel.info, message);
|
||||
// ignore: unused_element
|
||||
void _warn(String message) => _log(LogLevel.warn, message);
|
||||
// ignore: unused_element
|
||||
void _error(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||
_log(LogLevel.error, message, error, stackTrace);
|
||||
|
||||
// Activity-specific logging methods
|
||||
static void logActivity(String activityType, String application, int durationSeconds, int xpGained) {
|
||||
info('ACTIVITY: $activityType in $application for ${durationSeconds}s (+$xpGained XP)');
|
||||
}
|
||||
|
||||
static void logFocusSession(int minutes, int bonusXP) {
|
||||
info('FOCUS_SESSION: ${minutes}min session completed (+$bonusXP XP)');
|
||||
}
|
||||
|
||||
static void logLevelUp(int oldLevel, int newLevel, int totalXP) {
|
||||
info('LEVEL_UP: $oldLevel → $newLevel (Total XP: $totalXP)');
|
||||
}
|
||||
|
||||
static void logAchievement(String name, String description, int xpReward) {
|
||||
info('ACHIEVEMENT: $name - $description (+$xpReward XP)');
|
||||
}
|
||||
|
||||
static void logConfigChange(String path, dynamic oldValue, dynamic newValue) {
|
||||
info('CONFIG_CHANGE: $path changed from $oldValue to $newValue');
|
||||
}
|
||||
|
||||
static void logXPCalculation(ActivityEventType activityType, int baseXP, double multiplier, int finalXP) {
|
||||
debug('XP_CALC: ${activityType.displayName} base=$baseXP × $multiplier = $finalXP');
|
||||
}
|
||||
|
||||
static void logIdleStateChange(bool isIdle, Duration? idleDuration) {
|
||||
if (isIdle) {
|
||||
debug('IDLE: User became idle');
|
||||
} else {
|
||||
final duration = idleDuration?.inMinutes ?? 0;
|
||||
info('ACTIVE: User became active after ${duration}min idle');
|
||||
}
|
||||
}
|
||||
|
||||
static void logZoomStatusChange(String oldStatus, String newStatus) {
|
||||
info('ZOOM_STATUS: $oldStatus → $newStatus');
|
||||
}
|
||||
|
||||
static void logThemeChange(int level, String themeName) {
|
||||
info('THEME_CHANGE: Applied theme "$themeName" for level $level');
|
||||
}
|
||||
|
||||
static void logXPGain(String source, int xpGained, String activity, int currentXP, int currentLevel) {
|
||||
info('XP_GAIN: +$xpGained from $source ($activity) - Total: $currentXP XP, Level: $currentLevel');
|
||||
}
|
||||
|
||||
// Performance logging
|
||||
static void logPerformanceMetric(String operation, Duration duration) {
|
||||
debug('PERFORMANCE: $operation took ${duration.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
// Get recent log entries for dashboard
|
||||
Future<List<String>> getRecentLogs([int count = 100]) async {
|
||||
if (_currentLogFile == null || !await _currentLogFile!.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final lines = await _currentLogFile!.readAsLines();
|
||||
return lines.reversed.take(count).toList();
|
||||
} catch (e) {
|
||||
_error('Failed to read log file: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get logs by level
|
||||
Future<List<String>> getLogsByLevel(LogLevel level, [int count = 50]) async {
|
||||
final allLogs = await getRecentLogs(count * 2);
|
||||
return allLogs.where((log) => log.contains('[${level.name}]')).take(count).toList();
|
||||
}
|
||||
|
||||
// Update log level dynamically
|
||||
void setLogLevel(LogLevel level) {
|
||||
_currentLevel = level;
|
||||
info('Log level changed to: ${level.name}');
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _logStreamController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:xp_nix/src/config/config_manager.dart';
|
||||
|
||||
class ActivityEvent {
|
||||
final ActivityEventType type;
|
||||
final String application;
|
||||
final String? metadata;
|
||||
final DateTime timestamp;
|
||||
|
||||
ActivityEvent({required this.type, required this.application, this.metadata, required this.timestamp});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type.id,
|
||||
'application': application,
|
||||
'metadata': metadata,
|
||||
'timestamp': timestamp.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
enum ActivityEventType {
|
||||
coding('coding', 'Coding', '💻'),
|
||||
focusedBrowsing('focused_browsing', 'Focused Browsing', '🔍'),
|
||||
collaboration('collaboration', 'Collaboration', '🤝'),
|
||||
meetings('meetings', 'Meetings', '📅'),
|
||||
misc('misc', 'Miscellaneous', '📝'),
|
||||
uncategorized('uncategorized', 'Uncategorized', '❓');
|
||||
|
||||
const ActivityEventType(this.id, this.displayName, this.icon);
|
||||
|
||||
final String id;
|
||||
final String displayName;
|
||||
final String icon;
|
||||
|
||||
int get baseXp {
|
||||
return ConfigManager.instance.getBaseXP(id);
|
||||
}
|
||||
|
||||
static ActivityEventType categorize({String? eventId, String? applicationId, String? applicationTitle}) {
|
||||
// Direct type mappings
|
||||
if (eventId != null) {
|
||||
final matchType = ActivityEventType.values.where((e) => e.id == eventId).firstOrNull;
|
||||
if (matchType != null) {
|
||||
return matchType;
|
||||
}
|
||||
}
|
||||
|
||||
if (applicationId != null) {
|
||||
// Fallback to application-based categorization for 'other' type
|
||||
switch (applicationId.toLowerCase()) {
|
||||
case 'codium':
|
||||
case 'code':
|
||||
case 'vscode':
|
||||
case 'nvim':
|
||||
case 'vim':
|
||||
case 'emacs':
|
||||
return coding;
|
||||
case 'com.slack.slack':
|
||||
case 'slack':
|
||||
case 'discord':
|
||||
case 'teams':
|
||||
case 'mattermost':
|
||||
return collaboration;
|
||||
// terminals
|
||||
case 'com.mitchellh.ghostty':
|
||||
case 'alacritty':
|
||||
case 'kitty':
|
||||
case 'gnome-terminal':
|
||||
case 'konsole':
|
||||
case 'foot':
|
||||
switch (applicationTitle) {
|
||||
case String s when s.contains('git'):
|
||||
case String s when s == 'hx' || s == 'helix':
|
||||
return coding;
|
||||
case null:
|
||||
default:
|
||||
return misc;
|
||||
}
|
||||
case 'org.keepassxc.keepassxc':
|
||||
case 'keepassxc':
|
||||
case 'bitwarden':
|
||||
case '1password':
|
||||
return misc; // security tools now categorized as misc
|
||||
case 'firefox':
|
||||
case 'chrome':
|
||||
case 'safari':
|
||||
case 'edge':
|
||||
return focusedBrowsing;
|
||||
case 'zoom':
|
||||
case 'zoom.us':
|
||||
case 'us.zoom.xos':
|
||||
return meetings;
|
||||
default:
|
||||
return uncategorized;
|
||||
}
|
||||
}
|
||||
|
||||
return uncategorized;
|
||||
}
|
||||
}
|
||||
|
||||
enum ZoomStatus {
|
||||
none,
|
||||
zoomFocused, // Zoom window active, no media
|
||||
zoomBackground, // Zoom running, not focused, no media
|
||||
backgroundMeeting, // Meeting active but Zoom not focused
|
||||
activeMeeting, // Meeting active and Zoom focused
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:xp_nix/src/models/activity_event.dart';
|
||||
import 'package:xp_nix/src/testing/mock_idle_monitor.dart';
|
||||
import '../interfaces/i_idle_monitor.dart';
|
||||
import '../interfaces/i_activity_detector.dart';
|
||||
import '../interfaces/i_time_provider.dart';
|
||||
import '../interfaces/i_desktop_enhancer.dart';
|
||||
import '../detectors/zoom_detector.dart';
|
||||
import '../database/database_manager.dart';
|
||||
import '../config/config_manager.dart';
|
||||
import '../logging/logger.dart';
|
||||
import '../notifications/xp_notification_manager.dart';
|
||||
import '../web/websocket_manager.dart';
|
||||
|
||||
/// Unified ProductivityMonitor with dependency injection for both production and testing
|
||||
class ProductivityMonitor {
|
||||
final DatabaseManager _dbManager;
|
||||
ConfigManager get _configManager => ConfigManager.instance;
|
||||
final IIdleMonitor _idleMonitor;
|
||||
final IActivityDetector? _activityDetector;
|
||||
final ITimeProvider _timeProvider;
|
||||
final IDesktopEnhancer _desktopEnhancer;
|
||||
|
||||
late XPNotificationManager _xpNotificationManager;
|
||||
Timer? _pollTimer;
|
||||
String? _lastActiveWindow;
|
||||
String? _lastActiveWindowTitle;
|
||||
DateTime? _lastActivityTime;
|
||||
DateTime? _lastActiveTime;
|
||||
late ZoomDetector _zoomDetector;
|
||||
ZoomStatus _lastZoomStatus = ZoomStatus.none;
|
||||
DateTime? _lastZoomStatusTime;
|
||||
int _lastKnownLevel = 1;
|
||||
final int _pollFrequencySeconds = 5;
|
||||
// Only save activities longer than cutoff
|
||||
final int _activityDurationCutoffSeconds = 10;
|
||||
|
||||
StreamSubscription? _idleSubscription;
|
||||
StreamSubscription? _activitySubscription;
|
||||
|
||||
ProductivityMonitor({
|
||||
required Database db,
|
||||
required IIdleMonitor idleMonitor,
|
||||
required ITimeProvider timeProvider,
|
||||
required IDesktopEnhancer desktopEnhancer,
|
||||
IActivityDetector? activityDetector,
|
||||
}) : _dbManager = DatabaseManager(db),
|
||||
_idleMonitor = idleMonitor,
|
||||
_activityDetector = activityDetector,
|
||||
_timeProvider = timeProvider,
|
||||
_desktopEnhancer = desktopEnhancer;
|
||||
|
||||
void start() {
|
||||
_dbManager.initDatabase();
|
||||
|
||||
// Initialize XP notification manager
|
||||
_xpNotificationManager = XPNotificationManager(_dbManager);
|
||||
_xpNotificationManager.start();
|
||||
|
||||
// Start idle monitor
|
||||
_idleMonitor.start();
|
||||
|
||||
// Initialize zoom detector (only if not in test mode)
|
||||
if (_activityDetector == null) {
|
||||
_zoomDetector = ZoomDetector();
|
||||
}
|
||||
|
||||
// Listen to idle state changes
|
||||
_idleSubscription = _idleMonitor.idleStateStream.listen((idleStatus) {
|
||||
_handleIdleStateChange(idleStatus);
|
||||
});
|
||||
|
||||
// Listen to activity changes if activity detector is provided
|
||||
if (_activityDetector != null) {
|
||||
_activityDetector.start();
|
||||
_activitySubscription = _activityDetector.activityStream.listen((event) {
|
||||
_handleActivityEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
// Check for level changes and apply visual upgrades
|
||||
_timeProvider.periodic(Duration(minutes: 1), (_) => _checkForLevelUp());
|
||||
|
||||
// Apply initial theme based on current level
|
||||
_applyCurrentLevelTheme();
|
||||
|
||||
// Start polling for activity if no activity detector provided (legacy mode)
|
||||
if (_activityDetector == null) {
|
||||
_pollTimer = _timeProvider.periodic(Duration(seconds: _pollFrequencySeconds), (_) => _pollActivity());
|
||||
print('🚀 Started Enhanced Hyprland monitoring with desktop gamification...');
|
||||
} else {
|
||||
print('🧪 Started productivity monitor with injected dependencies...');
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_pollTimer?.cancel();
|
||||
_idleSubscription?.cancel();
|
||||
_activitySubscription?.cancel();
|
||||
_idleMonitor.stop();
|
||||
_activityDetector?.stop();
|
||||
_xpNotificationManager.stop();
|
||||
}
|
||||
|
||||
/// Handle idle state changes from the idle monitor
|
||||
void _handleIdleStateChange(IdleStatus idleStatus) {
|
||||
print('DEBUG: Idle state changed to: $idleStatus');
|
||||
|
||||
// When user goes deep idle, end the current activity and award XP
|
||||
if (idleStatus == IdleStatus.deepIdle) {
|
||||
print('😴 User went deep idle - ending current activity');
|
||||
_endCurrentActivityOnIdle();
|
||||
}
|
||||
// Note: We don't need to handle lightIdle or active states here
|
||||
// as they don't require ending the current activity
|
||||
}
|
||||
|
||||
/// End the current activity when user goes deep idle
|
||||
void _endCurrentActivityOnIdle() {
|
||||
if (_lastActiveWindow != null && _lastActivityTime != null) {
|
||||
final duration = _timeProvider.now().difference(_lastActivityTime!).inSeconds;
|
||||
print('DEBUG: Ending activity $_lastActiveWindow due to deep idle with duration ${duration}s');
|
||||
|
||||
// Save the activity if it meets the minimum duration requirement
|
||||
if (duration >= _activityDurationCutoffSeconds) {
|
||||
_saveActivityEvent(_lastActiveWindow!, duration, _lastActiveWindowTitle ?? '');
|
||||
print('💾 Saved activity due to deep idle: $_lastActiveWindow (${duration}s)');
|
||||
} else {
|
||||
print('DEBUG: Activity duration too short ($duration < $_activityDurationCutoffSeconds), not saving');
|
||||
}
|
||||
|
||||
// Clear the current activity state
|
||||
_lastActiveWindow = null;
|
||||
_lastActiveWindowTitle = null;
|
||||
_lastActivityTime = null;
|
||||
} else {
|
||||
print('DEBUG: No current activity to end on deep idle');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle activity events from the activity detector
|
||||
void _handleActivityEvent(ActivityEvent event) {
|
||||
final now = _timeProvider.now();
|
||||
|
||||
print('DEBUG: Handling activity event: ${event.application} - ${event.type}');
|
||||
print('DEBUG: Current state - window: $_lastActiveWindow, time: $_lastActivityTime');
|
||||
|
||||
// If this is a different activity from the last one, save the previous activity
|
||||
if (_lastActiveWindow != null &&
|
||||
(_lastActiveWindow != event.application || _lastActiveWindowTitle != event.metadata)) {
|
||||
final duration = now.difference(_lastActivityTime ?? now).inSeconds;
|
||||
print('DEBUG: Saving previous activity $_lastActiveWindow with duration ${duration}s');
|
||||
if (duration >= 10) {
|
||||
_saveActivityEvent(_lastActiveWindow!, duration, _lastActiveWindowTitle ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
_lastActiveWindow = event.application;
|
||||
_lastActiveWindowTitle = event.metadata ?? '';
|
||||
_lastActivityTime = now;
|
||||
|
||||
print('DEBUG: Updated state - window: $_lastActiveWindow, time: $_lastActivityTime');
|
||||
print('Activity: ${event.application} - ${event.type}');
|
||||
}
|
||||
|
||||
/// Force save current activity (useful for testing)
|
||||
void flushCurrentActivity() {
|
||||
if (_lastActiveWindow != null && _lastActivityTime != null) {
|
||||
final duration = _timeProvider.now().difference(_lastActivityTime!).inSeconds;
|
||||
print('DEBUG: Flushing activity $_lastActiveWindow with duration ${duration}s');
|
||||
if (duration >= _activityDurationCutoffSeconds) {
|
||||
_saveActivityEvent(_lastActiveWindow!, duration, _lastActiveWindowTitle ?? '');
|
||||
_lastActiveWindow = null;
|
||||
_lastActiveWindowTitle = null;
|
||||
_lastActivityTime = null;
|
||||
} else {
|
||||
print('DEBUG: Duration too short ($duration < $_activityDurationCutoffSeconds), not saving');
|
||||
}
|
||||
} else {
|
||||
print('DEBUG: No activity to flush (window: $_lastActiveWindow, time: $_lastActivityTime)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Force save current activity with minimum duration (useful for testing)
|
||||
void flushCurrentActivityForced() {
|
||||
if (_lastActiveWindow != null && _lastActivityTime != null) {
|
||||
final duration = _timeProvider.now().difference(_lastActivityTime!).inSeconds;
|
||||
print('DEBUG: Force flushing activity $_lastActiveWindow with duration ${duration}s');
|
||||
_saveActivityEvent(_lastActiveWindow!, duration, _lastActiveWindowTitle ?? '');
|
||||
_lastActiveWindow = null;
|
||||
_lastActiveWindowTitle = null;
|
||||
_lastActivityTime = null;
|
||||
} else {
|
||||
print('DEBUG: No activity to force flush (window: $_lastActiveWindow, time: $_lastActivityTime)');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkForLevelUp() async {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentLevel = stats['level'] as int;
|
||||
|
||||
if (currentLevel > _lastKnownLevel) {
|
||||
print('🎉 LEVEL UP DETECTED! $_lastKnownLevel → $currentLevel');
|
||||
|
||||
// Celebrate the level up with desktop enhancement
|
||||
await _desktopEnhancer.celebrateLevelUp(currentLevel);
|
||||
|
||||
// Record the theme change
|
||||
_recordThemeChange(currentLevel);
|
||||
|
||||
// Check for achievements
|
||||
await _checkAchievements(currentLevel, stats);
|
||||
|
||||
// Update timestamp for level up
|
||||
final today = _timeProvider.now().toIso8601String().substring(0, 10);
|
||||
_dbManager.updateLevelUpTimestamp(today, _timeProvider.now().millisecondsSinceEpoch);
|
||||
|
||||
_lastKnownLevel = currentLevel;
|
||||
|
||||
// Show detailed level up message
|
||||
_showLevelUpMessage(currentLevel, stats);
|
||||
|
||||
// Send level up notification
|
||||
await _xpNotificationManager.showLevelUp(newLevel: currentLevel, totalXP: stats['xp'] as int, stats: stats);
|
||||
|
||||
// Broadcast level up via WebSocket
|
||||
_broadcastLevelUp(currentLevel);
|
||||
}
|
||||
}
|
||||
|
||||
void _recordThemeChange(int level) {
|
||||
final themeName = _desktopEnhancer.getCurrentThemeInfo();
|
||||
_dbManager.recordThemeChange(level, themeName, _timeProvider.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
Future<void> _checkAchievements(int level, Map<String, dynamic> stats) async {
|
||||
final achievements = <Map<String, dynamic>>[];
|
||||
|
||||
// Level-based achievements
|
||||
if (level == 5) {
|
||||
achievements.add({
|
||||
'name': 'Rising Star',
|
||||
'description': 'Reached level 5 - Your journey begins to shine!',
|
||||
'xp_reward': 100,
|
||||
});
|
||||
} else if (level == 10) {
|
||||
achievements.add({
|
||||
'name': 'Productivity Warrior',
|
||||
'description': 'Reached level 10 - You\'ve unlocked desktop blur effects!',
|
||||
'xp_reward': 250,
|
||||
});
|
||||
} else if (level == 15) {
|
||||
achievements.add({
|
||||
'name': 'Focus Master',
|
||||
'description': 'Reached level 15 - Your desktop now glows with productivity!',
|
||||
'xp_reward': 500,
|
||||
});
|
||||
} else if (level == 25) {
|
||||
achievements.add({
|
||||
'name': 'Legendary Achiever',
|
||||
'description': 'Reached level 25 - You have transcended ordinary productivity!',
|
||||
'xp_reward': 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Focus-based achievements
|
||||
final focusHours = (stats['focus_time'] as int) / 3600;
|
||||
if (focusHours >= 4 && !_dbManager.hasAchievement('Deep Focus')) {
|
||||
achievements.add({
|
||||
'name': 'Deep Focus',
|
||||
'description': 'Maintained 4+ hours of focus time in a day',
|
||||
'xp_reward': 200,
|
||||
});
|
||||
}
|
||||
|
||||
if (focusHours >= 8 && !_dbManager.hasAchievement('Focus Titan')) {
|
||||
achievements.add({
|
||||
'name': 'Focus Titan',
|
||||
'description': 'Achieved 8+ hours of pure focus - Incredible!',
|
||||
'xp_reward': 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Session-based achievements
|
||||
final focusSessions = stats['focus_sessions'] as int;
|
||||
if (focusSessions >= 5 && !_dbManager.hasAchievement('Session Master')) {
|
||||
achievements.add({
|
||||
'name': 'Session Master',
|
||||
'description': 'Completed 5+ focus sessions in one day',
|
||||
'xp_reward': 150,
|
||||
});
|
||||
}
|
||||
|
||||
// Meeting achievements
|
||||
final meetingHours = (stats['meeting_time'] as int) / 3600;
|
||||
if (meetingHours >= 3 && !_dbManager.hasAchievement('Communication Pro')) {
|
||||
achievements.add({
|
||||
'name': 'Communication Pro',
|
||||
'description': 'Participated in 3+ hours of meetings',
|
||||
'xp_reward': 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Award achievements
|
||||
for (final achievement in achievements) {
|
||||
await _awardAchievement(achievement, level);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _awardAchievement(Map<String, dynamic> achievement, int level) async {
|
||||
_dbManager.saveAchievement(
|
||||
achievement['name'],
|
||||
achievement['description'],
|
||||
achievement['xp_reward'],
|
||||
_timeProvider.now().millisecondsSinceEpoch,
|
||||
level,
|
||||
);
|
||||
|
||||
// Add bonus XP
|
||||
_dbManager.updateDailyStats(achievement['xp_reward'] as int, 0, 0);
|
||||
|
||||
print('🏆 ACHIEVEMENT UNLOCKED: ${achievement['name']}');
|
||||
print(' ${achievement['description']}');
|
||||
print(' Bonus: +${achievement['xp_reward']} XP');
|
||||
|
||||
// Send achievement notification using notification manager
|
||||
await _xpNotificationManager.showAchievement(
|
||||
name: achievement['name'],
|
||||
description: achievement['description'],
|
||||
xpReward: achievement['xp_reward'],
|
||||
currentLevel: level,
|
||||
);
|
||||
|
||||
// Broadcast achievement via WebSocket
|
||||
_broadcastAchievementUnlocked(achievement);
|
||||
}
|
||||
|
||||
void _showLevelUpMessage(int level, Map<String, dynamic> stats) {
|
||||
final focusHours = ((stats['focus_time'] as int) / 3600).toStringAsFixed(1);
|
||||
final meetingHours = ((stats['meeting_time'] as int) / 3600).toStringAsFixed(1);
|
||||
|
||||
print('\n${'=' * 50}');
|
||||
print('🎮 LEVEL UP! Welcome to Level $level! 🎮');
|
||||
print('=' * 50);
|
||||
print('⭐ Total XP: ${stats['xp']}');
|
||||
print('🧠 Focus Time: ${focusHours}h');
|
||||
print('🤝 Meeting Time: ${meetingHours}h');
|
||||
print('🎯 Focus Sessions: ${stats['focus_sessions']}');
|
||||
print('🎨 Desktop Theme: ${_desktopEnhancer.getCurrentThemeInfo()}');
|
||||
print('=' * 50);
|
||||
print('');
|
||||
}
|
||||
|
||||
Future<void> _applyCurrentLevelTheme() async {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentLevel = stats['level'] as int;
|
||||
_lastKnownLevel = currentLevel;
|
||||
|
||||
await _desktopEnhancer.applyThemeForLevel(currentLevel);
|
||||
print('🎨 Applied theme for current level: $currentLevel');
|
||||
}
|
||||
|
||||
// Enhanced XP calculation with multipliers
|
||||
int _calculateXP(ActivityEventType activityType, int durationSeconds) {
|
||||
final minutes = (durationSeconds / 60).round();
|
||||
final baseXPPerMinute = _getBaseXPForType(activityType);
|
||||
final baseXP = minutes * baseXPPerMinute;
|
||||
final timeMultiplier = _configManager.getTimeMultiplier(_timeProvider.now());
|
||||
final finalXP = (baseXP * timeMultiplier).round();
|
||||
|
||||
Logger.logXPCalculation(activityType, baseXP, timeMultiplier, finalXP);
|
||||
return finalXP;
|
||||
}
|
||||
|
||||
int _getBaseXPForType(ActivityEventType category) {
|
||||
// Use exhaustive matching with ActivityEventType enum for consistent XP rewards
|
||||
switch (category) {
|
||||
case ActivityEventType.coding:
|
||||
return 10;
|
||||
case ActivityEventType.focusedBrowsing:
|
||||
return 6;
|
||||
case ActivityEventType.collaboration:
|
||||
return 7;
|
||||
case ActivityEventType.meetings:
|
||||
return 3;
|
||||
case ActivityEventType.misc:
|
||||
return 2;
|
||||
case ActivityEventType.uncategorized:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced focus session rewards
|
||||
void _awardFocusSessionXP(int focusMinutes) {
|
||||
final bonusXP = _configManager.getFocusSessionBonus(focusMinutes);
|
||||
final today = _timeProvider.now().toIso8601String().substring(0, 10);
|
||||
|
||||
_dbManager.saveFocusSession(today, focusMinutes, bonusXP, _timeProvider.now().millisecondsSinceEpoch);
|
||||
_dbManager.updateDailyStats(bonusXP, 0, 0);
|
||||
|
||||
String message = '🎯 Focus session complete: +$bonusXP XP for $focusMinutes min!';
|
||||
if (focusMinutes >= 180) {
|
||||
message = '🔥 LEGENDARY FOCUS! +$bonusXP XP for $focusMinutes min! 🔥';
|
||||
} else if (focusMinutes >= 120) {
|
||||
message = '⚡ EPIC FOCUS! +$bonusXP XP for $focusMinutes min! ⚡';
|
||||
} else if (focusMinutes >= 60) {
|
||||
message = '💪 POWER FOCUS! +$bonusXP XP for $focusMinutes min! 💪';
|
||||
}
|
||||
|
||||
Logger.logFocusSession(focusMinutes, bonusXP);
|
||||
print(message);
|
||||
|
||||
// Send focus session notification
|
||||
_xpNotificationManager.showFocusSession(durationMinutes: focusMinutes, bonusXP: bonusXP, sessionType: 'focus');
|
||||
|
||||
// Broadcast focus session completion via WebSocket
|
||||
_broadcastFocusSessionComplete(focusMinutes, bonusXP);
|
||||
|
||||
// Broadcast updated stats and XP breakdown
|
||||
_broadcastStatsUpdate();
|
||||
_broadcastXPBreakdownUpdate();
|
||||
}
|
||||
|
||||
// Enhanced stats display
|
||||
void printDetailedStats() {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final streaks = _dbManager.getStreakStats();
|
||||
final focusHours = (stats['focus_time'] / 3600).toStringAsFixed(1);
|
||||
final meetingHours = (stats['meeting_time'] / 3600).toStringAsFixed(1);
|
||||
|
||||
print('\n${'=' * 60}');
|
||||
print('🎮 PRODUCTIVITY DASHBOARD - Level ${stats['level']} 🎮');
|
||||
print('=' * 60);
|
||||
print('⭐ XP: ${stats['xp']} | 🔥 Streak: ${streaks['current_streak']} days');
|
||||
print('🧠 Focus: ${focusHours}h | 🤝 Meetings: ${meetingHours}h');
|
||||
print('🎯 Sessions: ${stats['focus_sessions']} | 📈 Best Streak: ${streaks['longest_streak']}');
|
||||
print('🎨 Theme: ${_desktopEnhancer.getCurrentThemeInfo()}');
|
||||
|
||||
// Show recent achievements
|
||||
final recentAchievements = _dbManager.getRecentAchievements();
|
||||
|
||||
if (recentAchievements.isNotEmpty) {
|
||||
print('🏆 Today\'s Achievements:');
|
||||
for (final achievement in recentAchievements) {
|
||||
print(' • ${achievement['name']}');
|
||||
}
|
||||
}
|
||||
|
||||
print('=' * 60);
|
||||
print('');
|
||||
}
|
||||
|
||||
// Add manual theme testing
|
||||
Future<void> testTheme(int level) async {
|
||||
print('🧪 Testing theme for level $level...');
|
||||
await _desktopEnhancer.applyThemeForLevel(level);
|
||||
}
|
||||
|
||||
// Emergency restore
|
||||
Future<void> restoreDesktop() async {
|
||||
print('🔧 Restoring desktop to backup...');
|
||||
await _desktopEnhancer.restoreBackup();
|
||||
}
|
||||
|
||||
// Refresh base config from system
|
||||
Future<void> refreshConfig() async {
|
||||
print('🔄 Refreshing base config from system...');
|
||||
await _desktopEnhancer.refreshBaseConfig();
|
||||
}
|
||||
|
||||
// Add productivity commands
|
||||
void boostXP(ActivityEvent reason, {int amount = 100}) {
|
||||
_dbManager.updateDailyStats(amount, 0, 0);
|
||||
print('🚀 Manual XP boost: +$amount XP for $reason');
|
||||
|
||||
// Send manual boost notification
|
||||
_xpNotificationManager.showXPGain(source: 'manual_boost', xpGained: amount, activity: reason);
|
||||
}
|
||||
|
||||
void addMilestone(String milestone, {int xpReward = 200}) {
|
||||
final achievement = {
|
||||
'name': 'Milestone: $milestone',
|
||||
'description': 'Completed: $milestone',
|
||||
'xp_reward': xpReward,
|
||||
};
|
||||
|
||||
final stats = _dbManager.getTodayStats();
|
||||
_awardAchievement(achievement, stats['level']);
|
||||
}
|
||||
|
||||
Future<void> _pollActivity() async {
|
||||
try {
|
||||
if (_idleMonitor.status != IdleStatus.active) return;
|
||||
|
||||
final result = await Process.run('hyprctl', ['activewindow', '-j']);
|
||||
if (result.exitCode != 0) return;
|
||||
|
||||
final windowData = jsonDecode(result.stdout);
|
||||
final currentApp = windowData['class'] as String? ?? 'unknown';
|
||||
final currentWindowTitle = windowData['title'] as String? ?? '';
|
||||
final now = _timeProvider.now();
|
||||
|
||||
await _checkZoomActivity(now);
|
||||
|
||||
if (_lastActiveWindow != currentApp || _lastActiveWindowTitle != currentWindowTitle) {
|
||||
if (_lastActiveWindow != null && _lastActivityTime != null) {
|
||||
_saveActivityEvent(
|
||||
_lastActiveWindow!,
|
||||
now.difference(_lastActivityTime!).inSeconds,
|
||||
_lastActiveWindowTitle ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
_lastActiveWindow = currentApp;
|
||||
_lastActiveWindowTitle = currentWindowTitle;
|
||||
_lastActivityTime = now;
|
||||
print('Switched to: $currentApp');
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error polling activity: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkZoomActivity(DateTime now) async {
|
||||
final currentZoomStatus = await _zoomDetector.getZoomStatus();
|
||||
|
||||
if (_lastZoomStatus != currentZoomStatus) {
|
||||
if (_lastZoomStatus != ZoomStatus.none && _lastZoomStatusTime != null) {
|
||||
final duration = now.difference(_lastZoomStatusTime!).inSeconds;
|
||||
if (duration > 10) {
|
||||
_saveZoomActivity(_lastZoomStatus, duration);
|
||||
}
|
||||
}
|
||||
|
||||
_lastZoomStatus = currentZoomStatus;
|
||||
_lastZoomStatusTime = now;
|
||||
|
||||
if (currentZoomStatus != ZoomStatus.none) {
|
||||
print('Zoom status changed to: $currentZoomStatus');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _saveZoomActivity(ZoomStatus status, int durationSeconds) {
|
||||
final event = ActivityEvent(
|
||||
type: ActivityEventType.meetings,
|
||||
application: 'zoom',
|
||||
metadata: jsonEncode({'status': status.name, 'duration': durationSeconds}),
|
||||
timestamp: _timeProvider.now(),
|
||||
);
|
||||
|
||||
_dbManager.saveActivityEvent(
|
||||
event.type.toString(),
|
||||
event.application,
|
||||
event.metadata,
|
||||
event.timestamp.millisecondsSinceEpoch,
|
||||
durationSeconds,
|
||||
);
|
||||
|
||||
final xp = _calculateZoomXP(status, durationSeconds);
|
||||
final meetingTime = _isMeetingActivityZoom(status) ? durationSeconds : 0;
|
||||
|
||||
_dbManager.updateDailyStats(xp, 0, meetingTime);
|
||||
print('Logged zoom activity: ${status.name} for ${durationSeconds}s (+$xp XP)');
|
||||
|
||||
// Send zoom activity XP notification
|
||||
if (xp > 0) {
|
||||
_xpNotificationManager.showXPGain(
|
||||
source: 'meeting',
|
||||
xpGained: xp,
|
||||
activity: event,
|
||||
durationMinutes: (durationSeconds / 60).round(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isMeetingActivityZoom(ZoomStatus status) {
|
||||
return status == ZoomStatus.activeMeeting || status == ZoomStatus.backgroundMeeting;
|
||||
}
|
||||
|
||||
int _calculateZoomXP(ZoomStatus status, int durationSeconds) {
|
||||
final minutes = (durationSeconds / 60).round();
|
||||
|
||||
switch (status) {
|
||||
case ZoomStatus.activeMeeting:
|
||||
return minutes * 8;
|
||||
case ZoomStatus.backgroundMeeting:
|
||||
return minutes * 5;
|
||||
case ZoomStatus.zoomFocused:
|
||||
return minutes * 2;
|
||||
case ZoomStatus.zoomBackground:
|
||||
case ZoomStatus.none:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _saveActivityEvent(String application, int durationSeconds, String title) {
|
||||
if (durationSeconds < _activityDurationCutoffSeconds) return;
|
||||
|
||||
// Check for user classification first
|
||||
final userClassification = _dbManager.getApplicationClassification(application);
|
||||
final activityType = ActivityEventType.categorize(
|
||||
eventId: userClassification,
|
||||
applicationId: application,
|
||||
applicationTitle: title,
|
||||
);
|
||||
|
||||
// If no user classification exists and it falls back to uncategorized, track as unclassified
|
||||
if (userClassification == null && activityType == ActivityEventType.uncategorized) {
|
||||
_dbManager.trackUnclassifiedApplication(application);
|
||||
}
|
||||
|
||||
final event = ActivityEvent(
|
||||
type: activityType,
|
||||
application: application,
|
||||
metadata: jsonEncode({'title': title, 'duration': durationSeconds}),
|
||||
timestamp: _timeProvider.now(),
|
||||
);
|
||||
|
||||
_dbManager.saveActivityEvent(
|
||||
event.type.id,
|
||||
event.application,
|
||||
event.metadata,
|
||||
event.timestamp.millisecondsSinceEpoch,
|
||||
durationSeconds,
|
||||
);
|
||||
|
||||
final xp = _calculateXP(event.type, durationSeconds);
|
||||
final focusTime = _isFocusActivity(event.type) ? durationSeconds : 0;
|
||||
final meetingTime = _isMeetingActivity(event.type) ? durationSeconds : 0;
|
||||
|
||||
_dbManager.updateDailyStats(xp, focusTime, meetingTime);
|
||||
print('Logged: ${event.type} for ${durationSeconds}s (+$xp XP)');
|
||||
|
||||
// Send activity XP notification
|
||||
_xpNotificationManager.showXPGain(
|
||||
source: 'activity',
|
||||
xpGained: xp,
|
||||
activity: event,
|
||||
durationMinutes: (durationSeconds / 60).round(),
|
||||
);
|
||||
|
||||
// Broadcast updated stats and XP breakdown via WebSocket
|
||||
_broadcastStatsUpdate();
|
||||
_broadcastXPBreakdownUpdate();
|
||||
}
|
||||
|
||||
bool _isFocusActivity(ActivityEventType activityType) {
|
||||
return activityType == ActivityEventType.coding || activityType == ActivityEventType.focusedBrowsing;
|
||||
}
|
||||
|
||||
bool _isMeetingActivity(ActivityEventType activityType) {
|
||||
return activityType == ActivityEventType.meetings;
|
||||
}
|
||||
|
||||
Map<String, dynamic> getTodayStats() => _dbManager.getTodayStats();
|
||||
Map<String, dynamic> getStreakStats() => _dbManager.getStreakStats();
|
||||
|
||||
/// Manually trigger level up check (useful for testing)
|
||||
Future<void> checkForLevelUpNow() async {
|
||||
await _checkForLevelUp();
|
||||
}
|
||||
|
||||
// WebSocket broadcasting methods
|
||||
void _broadcastLevelUp(int newLevel) {
|
||||
if (WebSocketManager.instance.hasConnections) {
|
||||
final message = {
|
||||
'type': 'level_up',
|
||||
'data': {'level': newLevel},
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
WebSocketManager.instance.broadcast(message);
|
||||
Logger.info('Broadcasted level up to WebSocket clients: Level $newLevel');
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastAchievementUnlocked(Map<String, dynamic> achievement) {
|
||||
if (WebSocketManager.instance.hasConnections) {
|
||||
final message = {
|
||||
'type': 'achievement_unlocked',
|
||||
'data': achievement,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
WebSocketManager.instance.broadcast(message);
|
||||
Logger.info('Broadcasted achievement to WebSocket clients: ${achievement['name']}');
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastStatsUpdate() {
|
||||
if (WebSocketManager.instance.hasConnections) {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final streaks = _dbManager.getStreakStats();
|
||||
final recentActivity = _dbManager.getRecentActivity(5);
|
||||
|
||||
final statsData = {
|
||||
'today': stats,
|
||||
'streaks': streaks,
|
||||
'recent_activity': recentActivity
|
||||
.map(
|
||||
(row) => {
|
||||
'type': row['type'],
|
||||
'application': row['application'],
|
||||
'timestamp': row['timestamp'],
|
||||
'duration_seconds': row['duration_seconds'],
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
final message = {
|
||||
'type': 'stats_update',
|
||||
'data': statsData,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
WebSocketManager.instance.broadcast(message);
|
||||
Logger.info('Broadcasted stats update to WebSocket clients');
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastXPBreakdownUpdate() {
|
||||
if (WebSocketManager.instance.hasConnections) {
|
||||
final breakdown = _dbManager.getTodayXPBreakdown();
|
||||
final message = {
|
||||
'type': 'xp_breakdown_update',
|
||||
'data': breakdown,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
WebSocketManager.instance.broadcast(message);
|
||||
Logger.info('Broadcasted XP breakdown update to WebSocket clients');
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastFocusSessionComplete(int durationMinutes, int bonusXP) {
|
||||
if (WebSocketManager.instance.hasConnections) {
|
||||
final sessionData = {
|
||||
'duration_minutes': durationMinutes,
|
||||
'bonus_xp': bonusXP,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
final message = {
|
||||
'type': 'focus_session_complete',
|
||||
'data': sessionData,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
WebSocketManager.instance.broadcast(message);
|
||||
Logger.info('Broadcasted focus session completion to WebSocket clients: ${durationMinutes}min, +${bonusXP}XP');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:xp_nix/src/models/activity_event.dart';
|
||||
|
||||
import 'package:xp_nix/src/database/database_manager.dart';
|
||||
import 'package:xp_nix/src/config/config_manager.dart';
|
||||
import 'package:xp_nix/src/logging/logger.dart';
|
||||
|
||||
class XPNotificationManager {
|
||||
final DatabaseManager _dbManager;
|
||||
ConfigManager get _configManager => ConfigManager.instance;
|
||||
Timer? _statusTimer;
|
||||
bool _isEnabled = true;
|
||||
|
||||
XPNotificationManager(this._dbManager);
|
||||
|
||||
void start() {
|
||||
// Show persistent XP status every 5 minutes
|
||||
_statusTimer = Timer.periodic(Duration(minutes: 5), (_) => _showXPStatus());
|
||||
|
||||
// Show initial status
|
||||
_showXPStatus();
|
||||
|
||||
Logger.info('XP Notification Manager started');
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_statusTimer?.cancel();
|
||||
Logger.info('XP Notification Manager stopped');
|
||||
}
|
||||
|
||||
void enable() {
|
||||
_isEnabled = true;
|
||||
}
|
||||
|
||||
void disable() {
|
||||
_isEnabled = false;
|
||||
}
|
||||
|
||||
/// Show persistent XP status notification
|
||||
Future<void> _showXPStatus() async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
try {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentXP = stats['xp'] as int;
|
||||
final currentLevel = stats['level'] as int;
|
||||
final focusHours = ((stats['focus_time'] as int) / 3600).toStringAsFixed(1);
|
||||
final meetingHours = ((stats['meeting_time'] as int) / 3600).toStringAsFixed(1);
|
||||
final focusSessions = stats['focus_sessions'] as int;
|
||||
|
||||
// Calculate progress to next level
|
||||
final xpPerLevel = _configManager.getXPPerLevel();
|
||||
final xpInCurrentLevel = currentXP % xpPerLevel;
|
||||
final progressPercent = ((xpInCurrentLevel / xpPerLevel) * 100).round();
|
||||
|
||||
final message =
|
||||
'Level $currentLevel • ${xpInCurrentLevel}/${xpPerLevel} XP\n'
|
||||
'Focus: ${focusHours}h • Meetings: ${meetingHours}h • Sessions: $focusSessions';
|
||||
|
||||
await _sendNotification(
|
||||
message: message,
|
||||
progressPercent: progressPercent,
|
||||
expireTime: 300000, // 5 minutes
|
||||
urgency: 'low',
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show XP status: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Show XP gain notification when XP is earned
|
||||
Future<void> showXPGain({
|
||||
required String source,
|
||||
required int xpGained,
|
||||
required ActivityEvent activity,
|
||||
int? durationMinutes,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
try {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentXP = stats['xp'] as int;
|
||||
final currentLevel = stats['level'] as int;
|
||||
|
||||
// Calculate progress to next level
|
||||
final xpPerLevel = _configManager.getXPPerLevel();
|
||||
final xpInCurrentLevel = currentXP % xpPerLevel;
|
||||
final progressPercent = ((xpInCurrentLevel / xpPerLevel) * 100).round();
|
||||
|
||||
String message = _formatXPGainMessage(source, xpGained, activity.type.displayName, durationMinutes, metadata);
|
||||
message += '\nLevel $currentLevel • $xpInCurrentLevel/$xpPerLevel XP';
|
||||
|
||||
await _sendNotification(
|
||||
message: message,
|
||||
progressPercent: progressPercent,
|
||||
expireTime: 5000, // 5 seconds
|
||||
urgency: 'normal',
|
||||
);
|
||||
|
||||
Logger.logXPGain(source, xpGained, activity.type.displayName, currentXP, currentLevel);
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show XP gain notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Show level up notification
|
||||
Future<void> showLevelUp({required int newLevel, required int totalXP, required Map<String, dynamic> stats}) async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
try {
|
||||
final focusHours = ((stats['focus_time'] as int) / 3600).toStringAsFixed(1);
|
||||
final meetingHours = ((stats['meeting_time'] as int) / 3600).toStringAsFixed(1);
|
||||
|
||||
final message =
|
||||
'🎉 LEVEL UP! Welcome to Level $newLevel! 🎉\n'
|
||||
'Total XP: $totalXP\n'
|
||||
'Focus: ${focusHours}h • Meetings: ${meetingHours}h';
|
||||
|
||||
await _sendNotification(
|
||||
message: message,
|
||||
progressPercent: 0, // Just leveled up, start fresh
|
||||
expireTime: 10000, // 10 seconds
|
||||
urgency: 'critical',
|
||||
);
|
||||
|
||||
Process.run('mpv', ['~/source/non-work/xp_nix/assets/levelup.mp3']).ignore();
|
||||
|
||||
Logger.info('Level up notification sent for level $newLevel');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show level up notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Show achievement notification
|
||||
Future<void> showAchievement({
|
||||
required String name,
|
||||
required String description,
|
||||
required int xpReward,
|
||||
required int currentLevel,
|
||||
}) async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
try {
|
||||
final message =
|
||||
'🏆 Achievement Unlocked!\n'
|
||||
'$name\n'
|
||||
'$description\n'
|
||||
'+$xpReward XP';
|
||||
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentXP = stats['xp'] as int;
|
||||
final xpPerLevel = _configManager.getXPPerLevel();
|
||||
final xpInCurrentLevel = currentXP % xpPerLevel;
|
||||
final progressPercent = ((xpInCurrentLevel / xpPerLevel) * 100).round();
|
||||
|
||||
await _sendNotification(
|
||||
message: message,
|
||||
progressPercent: progressPercent,
|
||||
expireTime: 8000, // 8 seconds
|
||||
urgency: 'normal',
|
||||
);
|
||||
|
||||
Logger.info('Achievement notification sent: $name');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show achievement notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Show focus session completion notification
|
||||
Future<void> showFocusSession({
|
||||
required int durationMinutes,
|
||||
required int bonusXP,
|
||||
required String sessionType,
|
||||
}) async {
|
||||
if (!_isEnabled) return;
|
||||
|
||||
try {
|
||||
String emoji = '🎯';
|
||||
String prefix = 'Focus session complete';
|
||||
|
||||
if (durationMinutes >= 180) {
|
||||
emoji = '🔥';
|
||||
prefix = 'LEGENDARY FOCUS';
|
||||
} else if (durationMinutes >= 120) {
|
||||
emoji = '⚡';
|
||||
prefix = 'EPIC FOCUS';
|
||||
} else if (durationMinutes >= 60) {
|
||||
emoji = '💪';
|
||||
prefix = 'POWER FOCUS';
|
||||
}
|
||||
|
||||
final message =
|
||||
'$emoji $prefix!\n'
|
||||
'${durationMinutes} minutes • +$bonusXP XP\n'
|
||||
'Session type: $sessionType';
|
||||
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentXP = stats['xp'] as int;
|
||||
final xpPerLevel = _configManager.getXPPerLevel();
|
||||
final xpInCurrentLevel = currentXP % xpPerLevel;
|
||||
final progressPercent = ((xpInCurrentLevel / xpPerLevel) * 100).round();
|
||||
|
||||
await _sendNotification(
|
||||
message: message,
|
||||
progressPercent: progressPercent,
|
||||
expireTime: 6000, // 6 seconds
|
||||
urgency: 'normal',
|
||||
);
|
||||
|
||||
Logger.info('Focus session notification sent: ${durationMinutes}min, +${bonusXP}XP');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to show focus session notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _formatXPGainMessage(
|
||||
String source,
|
||||
int xpGained,
|
||||
String activity,
|
||||
int? durationMinutes,
|
||||
Map<String, dynamic>? metadata,
|
||||
) {
|
||||
switch (source) {
|
||||
case 'activity':
|
||||
final duration = durationMinutes != null ? ' (${durationMinutes}m)' : '';
|
||||
return '+$xpGained XP from $activity$duration';
|
||||
|
||||
case 'focus_session':
|
||||
return '+$xpGained XP from focus session ($activity)';
|
||||
|
||||
case 'meeting':
|
||||
final duration = durationMinutes != null ? ' (${durationMinutes}m)' : '';
|
||||
return '+$xpGained XP from meeting$duration';
|
||||
|
||||
case 'achievement':
|
||||
return '+$xpGained XP from achievement: $activity';
|
||||
|
||||
case 'manual_boost':
|
||||
return '+$xpGained XP manual boost: $activity';
|
||||
|
||||
case 'time_multiplier':
|
||||
final multiplier = metadata?['multiplier'] ?? 1.0;
|
||||
return '+$xpGained XP from $activity (${multiplier}x bonus)';
|
||||
|
||||
default:
|
||||
return '+$xpGained XP from $activity';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendNotification({
|
||||
required String message,
|
||||
required int progressPercent,
|
||||
required int expireTime,
|
||||
required String urgency,
|
||||
}) async {
|
||||
try {
|
||||
// Clamp progress percent to valid range
|
||||
final validProgress = progressPercent.clamp(0, 100);
|
||||
|
||||
final result = await Process.run('notify-send', [
|
||||
'-h',
|
||||
'int:value:$validProgress',
|
||||
'-h',
|
||||
'string:synchronous:xp-nix',
|
||||
'-t',
|
||||
expireTime.toString(),
|
||||
'-u',
|
||||
urgency,
|
||||
'XP Nix',
|
||||
message,
|
||||
]);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
Logger.error('notify-send failed: ${result.stderr}');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error('Failed to send notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current XP status for external queries
|
||||
Map<String, dynamic> getCurrentStatus() {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final currentXP = stats['xp'] as int;
|
||||
final currentLevel = stats['level'] as int;
|
||||
final xpPerLevel = _configManager.getXPPerLevel();
|
||||
final xpInCurrentLevel = currentXP % xpPerLevel;
|
||||
final progressPercent = ((xpInCurrentLevel / xpPerLevel) * 100).round();
|
||||
|
||||
return {
|
||||
'level': currentLevel,
|
||||
'xp': currentXP,
|
||||
'xp_in_level': xpInCurrentLevel,
|
||||
'xp_per_level': xpPerLevel,
|
||||
'progress_percent': progressPercent,
|
||||
'focus_time': stats['focus_time'],
|
||||
'meeting_time': stats['meeting_time'],
|
||||
'focus_sessions': stats['focus_sessions'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'dart:async';
|
||||
import '../interfaces/i_time_provider.dart';
|
||||
|
||||
/// System implementation of time provider using real DateTime and Timer
|
||||
class SystemTimeProvider implements ITimeProvider {
|
||||
@override
|
||||
DateTime now() => DateTime.now();
|
||||
|
||||
@override
|
||||
Timer periodic(Duration period, void Function(Timer) callback) {
|
||||
return Timer.periodic(period, callback);
|
||||
}
|
||||
|
||||
@override
|
||||
Timer timer(Duration duration, void Function() callback) {
|
||||
// TODO: implement timer
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:xp_nix/src/interfaces/i_activity_detector.dart';
|
||||
import 'package:xp_nix/src/models/activity_event.dart';
|
||||
|
||||
/// Mock implementation of IActivityDetector for testing
|
||||
class MockActivityDetector implements IActivityDetector {
|
||||
final StreamController<ActivityEvent> _activityController = StreamController<ActivityEvent>.broadcast();
|
||||
final List<ActivitySequenceItem> _activitySequence = [];
|
||||
int _currentSequenceIndex = 0;
|
||||
Timer? _sequenceTimer;
|
||||
|
||||
String _currentApp = 'unknown';
|
||||
String _currentTitle = '';
|
||||
|
||||
@override
|
||||
Stream<ActivityEvent> get activityStream => _activityController.stream;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
// Start playing the activity sequence if one is set
|
||||
if (_activitySequence.isNotEmpty) {
|
||||
_playNextActivity();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
_sequenceTimer?.cancel();
|
||||
_activityController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActivityEvent> getCurrentActivity() async {
|
||||
return ActivityEvent(
|
||||
type: ActivityEventType.focusedBrowsing,
|
||||
application: '',
|
||||
metadata: '',
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set a sequence of activities to simulate
|
||||
void setActivitySequence(List<ActivitySequenceItem> sequence) {
|
||||
_activitySequence.clear();
|
||||
_activitySequence.addAll(sequence);
|
||||
_currentSequenceIndex = 0;
|
||||
}
|
||||
|
||||
/// Manually trigger an activity change
|
||||
void simulateActivity(String application, String title, {Duration? duration}) {
|
||||
_currentApp = application;
|
||||
_currentTitle = title;
|
||||
|
||||
final event = ActivityEvent(
|
||||
type: ActivityEventType.categorize(applicationId: application, applicationTitle: title),
|
||||
application: application,
|
||||
metadata: jsonEncode({'title': title, 'duration': duration?.inSeconds}),
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
print('MockActivityDetector: Emitting event for $application - ${event.type}');
|
||||
_activityController.add(event);
|
||||
}
|
||||
|
||||
/// Play the next activity in the sequence
|
||||
void _playNextActivity() {
|
||||
if (_currentSequenceIndex >= _activitySequence.length) {
|
||||
return; // Sequence complete
|
||||
}
|
||||
|
||||
final item = _activitySequence[_currentSequenceIndex];
|
||||
simulateActivity(item.application, item.title, duration: item.duration);
|
||||
|
||||
_currentSequenceIndex++;
|
||||
|
||||
// Schedule next activity
|
||||
if (_currentSequenceIndex < _activitySequence.length) {
|
||||
_sequenceTimer = Timer(item.duration, _playNextActivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an activity in a simulation sequence
|
||||
class ActivitySequenceItem {
|
||||
final String application;
|
||||
final String title;
|
||||
final Duration duration;
|
||||
|
||||
const ActivitySequenceItem({required this.application, required this.title, required this.duration});
|
||||
|
||||
/// Factory for coding activity
|
||||
factory ActivitySequenceItem.coding({
|
||||
String application = 'vscode',
|
||||
String title = 'main.dart - MyProject',
|
||||
required Duration duration,
|
||||
}) => ActivitySequenceItem(application: application, title: title, duration: duration);
|
||||
|
||||
/// Factory for browsing activity
|
||||
factory ActivitySequenceItem.browsing({
|
||||
String application = 'firefox',
|
||||
String title = 'Stack Overflow - How to...',
|
||||
required Duration duration,
|
||||
}) => ActivitySequenceItem(application: application, title: title, duration: duration);
|
||||
|
||||
/// Factory for meeting activity
|
||||
factory ActivitySequenceItem.meeting({
|
||||
String application = 'zoom',
|
||||
String title = 'Team Standup Meeting',
|
||||
required Duration duration,
|
||||
}) => ActivitySequenceItem(application: application, title: title, duration: duration);
|
||||
|
||||
/// Factory for collaboration activity
|
||||
factory ActivitySequenceItem.collaboration({
|
||||
String application = 'slack',
|
||||
String title = 'General Channel',
|
||||
required Duration duration,
|
||||
}) => ActivitySequenceItem(application: application, title: title, duration: duration);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:xp_nix/src/interfaces/i_desktop_enhancer.dart';
|
||||
|
||||
/// Mock desktop enhancer for testing that doesn't perform actual desktop operations
|
||||
class MockDesktopEnhancer implements IDesktopEnhancer {
|
||||
String _currentTheme = 'default';
|
||||
int _lastAppliedLevel = 1;
|
||||
List<String> _operations = [];
|
||||
|
||||
/// Get the operations that were performed (for testing verification)
|
||||
List<String> get operations => List.unmodifiable(_operations);
|
||||
|
||||
/// Clear the operations log
|
||||
void clearOperations() => _operations.clear();
|
||||
|
||||
@override
|
||||
Future<void> applyThemeForLevel(int level) async {
|
||||
_lastAppliedLevel = level;
|
||||
|
||||
if (level >= 25) {
|
||||
_currentTheme = 'legendary';
|
||||
} else if (level >= 15) {
|
||||
_currentTheme = 'master';
|
||||
} else if (level >= 10) {
|
||||
_currentTheme = 'advanced';
|
||||
} else if (level >= 5) {
|
||||
_currentTheme = 'intermediate';
|
||||
} else {
|
||||
_currentTheme = 'default';
|
||||
}
|
||||
|
||||
_operations.add('applyThemeForLevel($level) -> $_currentTheme');
|
||||
print('🎨 [MOCK] Applied theme: $_currentTheme for level $level');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> celebrateLevelUp(int level) async {
|
||||
_operations.add('celebrateLevelUp($level)');
|
||||
print('🎉 [MOCK] Celebrating level up to $level!');
|
||||
|
||||
// Apply the theme as part of celebration
|
||||
await applyThemeForLevel(level);
|
||||
}
|
||||
|
||||
@override
|
||||
String getCurrentThemeInfo() {
|
||||
return 'Theme: $_currentTheme (Level $_lastAppliedLevel)';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restoreBackup() async {
|
||||
_operations.add('restoreBackup()');
|
||||
_currentTheme = 'default';
|
||||
_lastAppliedLevel = 1;
|
||||
print('🔧 [MOCK] Restored desktop from backup');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refreshBaseConfig() async {
|
||||
_operations.add('refreshBaseConfig()');
|
||||
print('🔄 [MOCK] Refreshed base config from system');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:async';
|
||||
import 'package:xp_nix/src/interfaces/i_idle_monitor.dart';
|
||||
|
||||
/// Mock implementation of IIdleMonitor for testing
|
||||
class MockIdleMonitor implements IIdleMonitor {
|
||||
final StreamController<IdleStatus> _idleStateController = StreamController<IdleStatus>.broadcast();
|
||||
IdleStatus _status = IdleStatus.active;
|
||||
|
||||
@override
|
||||
Stream<IdleStatus> get idleStateStream => _idleStateController.stream;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
_idleStateController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
IdleStatus get status => _status;
|
||||
|
||||
/// Simulate user going to deep idle
|
||||
void simulateDeepIdle() {
|
||||
_status = IdleStatus.deepIdle;
|
||||
_idleStateController.add(IdleStatus.deepIdle);
|
||||
}
|
||||
|
||||
/// Simulate user going to light idle
|
||||
void simulateLightIdle() {
|
||||
_status = IdleStatus.lightIdle;
|
||||
_idleStateController.add(IdleStatus.lightIdle);
|
||||
}
|
||||
|
||||
/// Simulate user becoming active
|
||||
void simulateActive() {
|
||||
_status = IdleStatus.active;
|
||||
_idleStateController.add(IdleStatus.active);
|
||||
}
|
||||
|
||||
/// Simulate user going idle (backwards compatibility - defaults to deep idle)
|
||||
void simulateIdle() {
|
||||
simulateDeepIdle();
|
||||
}
|
||||
|
||||
/// Simulate a sequence of idle/active events with delays
|
||||
Future<void> simulateIdleSequence(List<IdleEvent> events) async {
|
||||
for (final event in events) {
|
||||
await Future.delayed(event.delay);
|
||||
if (event.isIdle) {
|
||||
simulateIdle();
|
||||
} else {
|
||||
simulateActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an idle state change event for simulation
|
||||
class IdleEvent {
|
||||
final bool isIdle;
|
||||
final Duration delay;
|
||||
|
||||
const IdleEvent({required this.isIdle, this.delay = Duration.zero});
|
||||
|
||||
/// Factory for idle event
|
||||
factory IdleEvent.idle({Duration delay = Duration.zero}) => IdleEvent(isIdle: true, delay: delay);
|
||||
|
||||
/// Factory for active event
|
||||
factory IdleEvent.active({Duration delay = Duration.zero}) => IdleEvent(isIdle: false, delay: delay);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:async';
|
||||
import 'package:xp_nix/src/interfaces/i_time_provider.dart';
|
||||
|
||||
/// Mock implementation of ITimeProvider for testing with controllable time
|
||||
class MockTimeProvider implements ITimeProvider {
|
||||
DateTime _currentTime = DateTime(2024, 1, 1, 9, 0); // Default to 9 AM on a workday
|
||||
final List<_MockTimer> _activeTimers = [];
|
||||
|
||||
@override
|
||||
DateTime now() => _currentTime;
|
||||
|
||||
@override
|
||||
Timer periodic(Duration duration, void Function(Timer) callback) {
|
||||
final timer = _MockPeriodicTimer(duration, callback);
|
||||
_activeTimers.add(timer);
|
||||
return timer;
|
||||
}
|
||||
|
||||
@override
|
||||
Timer timer(Duration duration, void Function() callback) {
|
||||
final timer = _MockTimer(duration, callback);
|
||||
_activeTimers.add(timer);
|
||||
return timer;
|
||||
}
|
||||
|
||||
/// Advance time by the specified duration and trigger any timers
|
||||
void advanceTime(Duration duration) {
|
||||
_currentTime = _currentTime.add(duration);
|
||||
|
||||
// Check and trigger timers
|
||||
final timersToTrigger = <_MockTimer>[];
|
||||
for (final timer in _activeTimers) {
|
||||
if (timer.shouldTrigger(_currentTime)) {
|
||||
timersToTrigger.add(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger timers and handle periodic vs one-time
|
||||
for (final timer in timersToTrigger) {
|
||||
timer.trigger();
|
||||
if (!timer.isActive) {
|
||||
_activeTimers.remove(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current time to a specific value
|
||||
void setTime(DateTime time) {
|
||||
_currentTime = time;
|
||||
}
|
||||
|
||||
/// Fast forward to a specific time, triggering all timers in between
|
||||
void fastForwardTo(DateTime targetTime) {
|
||||
while (_currentTime.isBefore(targetTime)) {
|
||||
final nextTimerTime = _getNextTimerTime();
|
||||
if (nextTimerTime != null && nextTimerTime.isBefore(targetTime)) {
|
||||
advanceTime(nextTimerTime.difference(_currentTime));
|
||||
} else {
|
||||
advanceTime(targetTime.difference(_currentTime));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next time a timer should fire
|
||||
DateTime? _getNextTimerTime() {
|
||||
DateTime? nextTime;
|
||||
for (final timer in _activeTimers) {
|
||||
final timerTime = timer.nextTriggerTime;
|
||||
if (timerTime != null && (nextTime == null || timerTime.isBefore(nextTime))) {
|
||||
nextTime = timerTime;
|
||||
}
|
||||
}
|
||||
return nextTime;
|
||||
}
|
||||
|
||||
/// Cancel all active timers
|
||||
void cancelAllTimers() {
|
||||
for (final timer in _activeTimers) {
|
||||
timer.cancel();
|
||||
}
|
||||
_activeTimers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock timer implementation
|
||||
class _MockTimer implements Timer {
|
||||
final Duration _duration;
|
||||
final void Function() _callback;
|
||||
final DateTime _startTime;
|
||||
bool _isActive = true;
|
||||
|
||||
_MockTimer(this._duration, this._callback) : _startTime = DateTime(2024, 1, 1, 9, 0);
|
||||
|
||||
@override
|
||||
bool get isActive => _isActive;
|
||||
|
||||
@override
|
||||
int get tick => 0; // Not used in mock
|
||||
|
||||
DateTime? get nextTriggerTime => _isActive ? _startTime.add(_duration) : null;
|
||||
|
||||
bool shouldTrigger(DateTime currentTime) {
|
||||
return _isActive && currentTime.isAtOrAfter(_startTime.add(_duration));
|
||||
}
|
||||
|
||||
void trigger() {
|
||||
if (_isActive) {
|
||||
_callback();
|
||||
_isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel() {
|
||||
_isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock periodic timer implementation
|
||||
class _MockPeriodicTimer extends _MockTimer {
|
||||
final void Function(Timer) _periodicCallback;
|
||||
DateTime _nextTriggerTime;
|
||||
|
||||
_MockPeriodicTimer(Duration duration, this._periodicCallback)
|
||||
: _nextTriggerTime = DateTime(2024, 1, 1, 9, 0).add(duration),
|
||||
super(duration, () {});
|
||||
|
||||
@override
|
||||
DateTime? get nextTriggerTime => _isActive ? _nextTriggerTime : null;
|
||||
|
||||
@override
|
||||
bool shouldTrigger(DateTime currentTime) {
|
||||
return _isActive && currentTime.isAtOrAfter(_nextTriggerTime);
|
||||
}
|
||||
|
||||
@override
|
||||
void trigger() {
|
||||
if (_isActive) {
|
||||
_periodicCallback(this);
|
||||
_nextTriggerTime = _nextTriggerTime.add(_duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to help with time comparisons
|
||||
extension DateTimeComparison on DateTime {
|
||||
bool isAtOrAfter(DateTime other) => isAfter(other) || isAtSameMomentAs(other);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import 'package:shelf_static/shelf_static.dart';
|
||||
import 'package:shelf_web_socket/shelf_web_socket.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import '../database/database_manager.dart';
|
||||
import '../config/config_manager.dart';
|
||||
import '../logging/logger.dart';
|
||||
import 'websocket_manager.dart';
|
||||
|
||||
class DashboardServer {
|
||||
static DashboardServer? _instance;
|
||||
static DashboardServer get instance => _instance ??= DashboardServer._();
|
||||
|
||||
DashboardServer._() : _dbManager = DatabaseManager(sqlite3.open('productivity_tracker.db'));
|
||||
|
||||
HttpServer? _server;
|
||||
final DatabaseManager _dbManager;
|
||||
final ConfigManager _configManager = ConfigManager.instance;
|
||||
int _port = 8080;
|
||||
|
||||
DashboardServer.withDatabase(this._dbManager);
|
||||
|
||||
Future<void> start([int port = 8080]) async {
|
||||
_port = port;
|
||||
|
||||
final router = Router();
|
||||
|
||||
// API Routes
|
||||
router.get('/api/stats', _handleStats);
|
||||
router.get('/api/stats/history', _handleStatsHistory);
|
||||
router.get('/api/achievements', _handleAchievements);
|
||||
router.get('/api/activities', _handleActivities);
|
||||
router.get('/api/focus-sessions', _handleFocusSessions);
|
||||
router.get('/api/xp-breakdown', _handleXPBreakdown);
|
||||
router.get('/api/logs', _handleLogs);
|
||||
router.get('/api/config', _handleGetConfig);
|
||||
router.post('/api/config', _handleUpdateConfig);
|
||||
router.get('/api/classifications', _handleGetClassifications);
|
||||
router.post('/api/classifications', _handleSaveClassification);
|
||||
router.delete('/api/classifications/<application>', _handleDeleteClassification);
|
||||
router.get('/api/unclassified', _handleGetUnclassified);
|
||||
|
||||
// WebSocket for real-time updates
|
||||
router.get('/ws', _handleWebSocket);
|
||||
|
||||
// Static file handler for the web UI
|
||||
final staticHandler = createStaticHandler('lib/src/web/static', defaultDocument: 'index.html');
|
||||
|
||||
final cascade = Cascade().add(router).add(staticHandler);
|
||||
|
||||
final handler = Pipeline()
|
||||
.addMiddleware(logRequests())
|
||||
.addMiddleware(_corsMiddleware())
|
||||
.addHandler(cascade.handler);
|
||||
|
||||
try {
|
||||
_server = await shelf_io.serve(handler, InternetAddress.anyIPv6, _port);
|
||||
Logger.info('Dashboard server started on http://[::]:$_port (IPv4 and IPv6)');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to start dashboard server: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _server?.close();
|
||||
_server = null;
|
||||
Logger.info('Dashboard server stopped');
|
||||
}
|
||||
|
||||
Middleware _corsMiddleware() {
|
||||
return (Handler innerHandler) {
|
||||
return (Request request) async {
|
||||
if (request.method == 'OPTIONS') {
|
||||
return Response.ok('', headers: _corsHeaders);
|
||||
}
|
||||
|
||||
final response = await innerHandler(request);
|
||||
return response.change(headers: _corsHeaders);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, String> get _corsHeaders => {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
|
||||
Future<Response> _handleStats(Request request) async {
|
||||
try {
|
||||
final stats = _dbManager.getTodayStats();
|
||||
final streaks = _dbManager.getStreakStats();
|
||||
final recentActivity = _dbManager.getRecentActivity(5);
|
||||
|
||||
final response = {
|
||||
'today': stats,
|
||||
'streaks': streaks,
|
||||
'recent_activity':
|
||||
recentActivity
|
||||
.map(
|
||||
(row) => {
|
||||
'type': row['type'],
|
||||
'application': row['application'],
|
||||
'timestamp': row['timestamp'],
|
||||
'duration_seconds': row['duration_seconds'],
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
return Response.ok(jsonEncode(response), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling stats request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get stats');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleStatsHistory(Request request) async {
|
||||
try {
|
||||
final days = int.tryParse(request.url.queryParameters['days'] ?? '7') ?? 7;
|
||||
final history = _getStatsHistory(days);
|
||||
|
||||
return Response.ok(jsonEncode(history), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling stats history request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get stats history');
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _getStatsHistory(int days) {
|
||||
final history = <Map<String, dynamic>>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (int i = days - 1; i >= 0; i--) {
|
||||
final date = now.subtract(Duration(days: i));
|
||||
final dateStr = date.toIso8601String().substring(0, 10);
|
||||
|
||||
final stats = _dbManager.getDailyStatsForDate(dateStr);
|
||||
|
||||
if (stats.isNotEmpty) {
|
||||
final row = stats.first;
|
||||
history.add({
|
||||
'date': dateStr,
|
||||
'level': row['level'],
|
||||
'xp': row['total_xp'],
|
||||
'focus_time': row['focus_time_seconds'],
|
||||
'meeting_time': row['meeting_time_seconds'],
|
||||
});
|
||||
} else {
|
||||
history.add({'date': dateStr, 'level': 1, 'xp': 0, 'focus_time': 0, 'meeting_time': 0});
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
Future<Response> _handleAchievements(Request request) async {
|
||||
try {
|
||||
final achievements = _dbManager.getAllAchievements();
|
||||
|
||||
final achievementList =
|
||||
achievements
|
||||
.map(
|
||||
(row) => {
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'description': row['description'],
|
||||
'xp_reward': row['xp_reward'],
|
||||
'achieved_at': row['achieved_at'],
|
||||
'level_at_achievement': row['level_at_achievement'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Response.ok(jsonEncode(achievementList), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling achievements request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get achievements');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleActivities(Request request) async {
|
||||
try {
|
||||
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '100') ?? 100;
|
||||
final activities = _dbManager.getRecentActivities(limit);
|
||||
|
||||
final activityList =
|
||||
activities
|
||||
.map(
|
||||
(row) => {
|
||||
'id': row['id'],
|
||||
'type': row['type'],
|
||||
'application': row['application'],
|
||||
'metadata': row['metadata'] != null ? jsonDecode(row['metadata']) : null,
|
||||
'timestamp': row['timestamp'],
|
||||
'duration_seconds': row['duration_seconds'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Response.ok(jsonEncode(activityList), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling activities request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get activities');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleFocusSessions(Request request) async {
|
||||
try {
|
||||
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
|
||||
final sessions = _dbManager.getRecentFocusSessions(limit);
|
||||
|
||||
final sessionList =
|
||||
sessions
|
||||
.map(
|
||||
(row) => {
|
||||
'id': row['id'],
|
||||
'date': row['date'],
|
||||
'duration_minutes': row['duration_minutes'],
|
||||
'bonus_xp': row['bonus_xp'],
|
||||
'timestamp': row['timestamp'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Response.ok(jsonEncode(sessionList), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling focus sessions request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get focus sessions');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleXPBreakdown(Request request) async {
|
||||
try {
|
||||
final date = request.url.queryParameters['date'];
|
||||
final Map<String, int> breakdown;
|
||||
|
||||
if (date != null) {
|
||||
breakdown = _dbManager.getXPBreakdownForDate(date);
|
||||
} else {
|
||||
breakdown = _dbManager.getTodayXPBreakdown();
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode(breakdown), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling XP breakdown request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get XP breakdown');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleLogs(Request request) async {
|
||||
try {
|
||||
final count = int.tryParse(request.url.queryParameters['count'] ?? '100') ?? 100;
|
||||
final level = request.url.queryParameters['level'];
|
||||
|
||||
List<String> logs;
|
||||
if (level != null) {
|
||||
final logLevel = LogLevel.values.firstWhere(
|
||||
(l) => l.name.toLowerCase() == level.toLowerCase(),
|
||||
orElse: () => LogLevel.info,
|
||||
);
|
||||
logs = await Logger.instance.getLogsByLevel(logLevel, count);
|
||||
} else {
|
||||
logs = await Logger.instance.getRecentLogs(count);
|
||||
}
|
||||
|
||||
return Response.ok(jsonEncode({'logs': logs}), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling logs request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get logs');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleGetConfig(Request request) async {
|
||||
try {
|
||||
final config = _configManager.getAllConfig();
|
||||
return Response.ok(jsonEncode(config), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling get config request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get config');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleUpdateConfig(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final updates = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
for (final entry in updates.entries) {
|
||||
await _configManager.updateConfig(entry.key, entry.value);
|
||||
}
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({'success': true, 'message': 'Configuration updated'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Error handling update config request: $e');
|
||||
return Response.internalServerError(body: 'Failed to update config');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleGetClassifications(Request request) async {
|
||||
try {
|
||||
final classifications = _dbManager.getAllApplicationClassifications();
|
||||
|
||||
final classificationList = classifications
|
||||
.map(
|
||||
(row) => {
|
||||
'id': row['id'],
|
||||
'application_name': row['application_name'],
|
||||
'category_id': row['category_id'],
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Response.ok(jsonEncode(classificationList), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling get classifications request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get classifications');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleSaveClassification(Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final applicationName = data['application_name'] as String?;
|
||||
final categoryId = data['category_id'] as String?;
|
||||
|
||||
if (applicationName == null || categoryId == null) {
|
||||
return Response.badRequest(body: 'Missing application_name or category_id');
|
||||
}
|
||||
|
||||
_dbManager.saveApplicationClassification(applicationName, categoryId);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({'success': true, 'message': 'Classification saved'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Error handling save classification request: $e');
|
||||
return Response.internalServerError(body: 'Failed to save classification');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleDeleteClassification(Request request) async {
|
||||
try {
|
||||
final applicationName = request.params['application'];
|
||||
if (applicationName == null) {
|
||||
return Response.badRequest(body: 'Missing application name');
|
||||
}
|
||||
|
||||
// URL decode the application name
|
||||
final decodedName = Uri.decodeComponent(applicationName);
|
||||
_dbManager.deleteApplicationClassification(decodedName);
|
||||
|
||||
return Response.ok(
|
||||
jsonEncode({'success': true, 'message': 'Classification deleted'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error('Error handling delete classification request: $e');
|
||||
return Response.internalServerError(body: 'Failed to delete classification');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _handleGetUnclassified(Request request) async {
|
||||
try {
|
||||
final unclassified = _dbManager.getUnclassifiedApplications();
|
||||
|
||||
final unclassifiedList = unclassified
|
||||
.map(
|
||||
(row) => {
|
||||
'id': row['id'],
|
||||
'application_name': row['application_name'],
|
||||
'first_seen': row['first_seen'],
|
||||
'last_seen': row['last_seen'],
|
||||
'occurrence_count': row['occurrence_count'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return Response.ok(jsonEncode(unclassifiedList), headers: {'Content-Type': 'application/json'});
|
||||
} catch (e) {
|
||||
Logger.error('Error handling get unclassified request: $e');
|
||||
return Response.internalServerError(body: 'Failed to get unclassified applications');
|
||||
}
|
||||
}
|
||||
|
||||
Handler _handleWebSocket() {
|
||||
return webSocketHandler((webSocket) {
|
||||
Logger.info('New WebSocket connection established');
|
||||
WebSocketManager.instance.addConnection(webSocket);
|
||||
|
||||
// Handle incoming messages (ping/pong, etc.)
|
||||
webSocket.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
final messageType = data['type'] as String?;
|
||||
|
||||
// Handle ping messages
|
||||
if (messageType == 'ping') {
|
||||
final pongMessage = {
|
||||
'type': 'pong',
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
webSocket.sink.add(jsonEncode(pongMessage));
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error('Error processing WebSocket message: $e');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
Logger.error('WebSocket stream error: $error');
|
||||
},
|
||||
onDone: () {
|
||||
Logger.info('WebSocket connection closed');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String get dashboardUrl => 'http://localhost:$_port';
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
class ProductivityDashboard {
|
||||
constructor() {
|
||||
this.chart = null;
|
||||
this.refreshInterval = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadInitialData();
|
||||
this.setupEventListeners();
|
||||
this.startAutoRefresh();
|
||||
this.setupChart();
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.updateStats(),
|
||||
this.updateActivity(),
|
||||
this.updateAchievements(),
|
||||
this.updateXPBreakdown(),
|
||||
this.updateLogs(),
|
||||
this.loadConfig(),
|
||||
this.updateClassifications(),
|
||||
this.updateUnclassified()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load initial data:', error);
|
||||
this.showMessage('Failed to load dashboard data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatsAndActivity() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
// Update header stats
|
||||
document.getElementById('current-level').textContent = data.today.level;
|
||||
document.getElementById('current-xp').textContent = data.today.xp;
|
||||
document.getElementById('current-streak').textContent = data.streaks.current_streak;
|
||||
|
||||
// Update progress bars
|
||||
const focusHours = Math.floor(data.today.focus_time / 3600);
|
||||
const focusMinutes = Math.floor((data.today.focus_time % 3600) / 60);
|
||||
const meetingHours = Math.floor(data.today.meeting_time / 3600);
|
||||
const meetingMinutes = Math.floor((data.today.meeting_time % 3600) / 60);
|
||||
|
||||
document.getElementById('focus-time').textContent = `${focusHours}h ${focusMinutes}m`;
|
||||
document.getElementById('meeting-time').textContent = `${meetingHours}h ${meetingMinutes}m`;
|
||||
document.getElementById('focus-sessions').textContent = data.today.focus_sessions;
|
||||
|
||||
// Update progress bars (assuming 8 hours = 100%)
|
||||
const focusPercent = Math.min((data.today.focus_time / (8 * 3600)) * 100, 100);
|
||||
const meetingPercent = Math.min((data.today.meeting_time / (4 * 3600)) * 100, 100);
|
||||
|
||||
document.getElementById('focus-progress').style.width = `${focusPercent}%`;
|
||||
document.getElementById('meeting-progress').style.width = `${meetingPercent}%`;
|
||||
|
||||
// Update recent activity
|
||||
const activityContainer = document.getElementById('recent-activity');
|
||||
|
||||
if (data.recent_activity && data.recent_activity.length > 0) {
|
||||
activityContainer.innerHTML = data.recent_activity.map(activity => {
|
||||
const date = new Date(activity.timestamp);
|
||||
const timeStr = date.toLocaleTimeString();
|
||||
const durationMin = Math.floor(activity.duration_seconds / 60);
|
||||
|
||||
return `
|
||||
<div class="activity-item">
|
||||
<span class="activity-type">${this.capitalizeFirst(activity.type)}</span>
|
||||
<div class="activity-details">
|
||||
${activity.application} • ${durationMin}m • ${timeStr}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
activityContainer.innerHTML = '<div class="activity-item"><span class="activity-type">No recent activity</span></div>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update stats and activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility methods
|
||||
async updateStats() {
|
||||
return this.updateStatsAndActivity();
|
||||
}
|
||||
|
||||
async updateActivity() {
|
||||
return this.updateStatsAndActivity();
|
||||
}
|
||||
|
||||
async updateAchievements() {
|
||||
try {
|
||||
const response = await fetch('/api/achievements?limit=5');
|
||||
const achievements = await response.json();
|
||||
|
||||
const achievementsContainer = document.getElementById('achievements-list');
|
||||
|
||||
if (achievements && achievements.length > 0) {
|
||||
achievementsContainer.innerHTML = achievements.map(achievement => {
|
||||
const date = new Date(achievement.achieved_at);
|
||||
const dateStr = date.toLocaleDateString();
|
||||
|
||||
return `
|
||||
<div class="achievement-item">
|
||||
<span class="achievement-name">${achievement.name}</span>
|
||||
<div class="achievement-description">
|
||||
${achievement.description} • +${achievement.xp_reward} XP • ${dateStr}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
achievementsContainer.innerHTML = '<div class="achievement-item"><span class="achievement-name">No achievements yet</span></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update achievements:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateXPBreakdown() {
|
||||
try {
|
||||
const response = await fetch('/api/xp-breakdown');
|
||||
const breakdown = await response.json();
|
||||
|
||||
const breakdownContainer = document.getElementById('xp-breakdown');
|
||||
|
||||
if (breakdown && Object.keys(breakdown).length > 0) {
|
||||
const totalXP = Object.values(breakdown).reduce((sum, xp) => sum + xp, 0);
|
||||
|
||||
breakdownContainer.innerHTML = Object.entries(breakdown)
|
||||
.sort(([,a], [,b]) => b - a) // Sort by XP amount descending
|
||||
.map(([source, xp]) => {
|
||||
const percentage = totalXP > 0 ? ((xp / totalXP) * 100).toFixed(1) : 0;
|
||||
const icon = this.getXPSourceIcon(source);
|
||||
|
||||
return `
|
||||
<div class="xp-source-item">
|
||||
<div class="xp-source-header">
|
||||
<span class="xp-source-icon">${icon}</span>
|
||||
<span class="xp-source-name">${this.formatXPSourceName(source)}</span>
|
||||
<span class="xp-source-amount">+${xp} XP</span>
|
||||
</div>
|
||||
<div class="xp-source-bar">
|
||||
<div class="xp-source-progress" style="width: ${percentage}%"></div>
|
||||
</div>
|
||||
<div class="xp-source-percentage">${percentage}%</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
breakdownContainer.innerHTML = '<div class="xp-source-item"><span class="xp-source-name">No XP earned today</span></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update XP breakdown:', error);
|
||||
// If the endpoint doesn't exist yet, show a placeholder
|
||||
const breakdownContainer = document.getElementById('xp-breakdown');
|
||||
if (breakdownContainer) {
|
||||
breakdownContainer.innerHTML = '<div class="xp-source-item"><span class="xp-source-name">XP breakdown coming soon...</span></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getXPSourceIcon(source) {
|
||||
const icons = {
|
||||
'coding': '💻',
|
||||
'focused_browsing': '🔍',
|
||||
'collaboration': '🤝',
|
||||
'meetings': '📅',
|
||||
'misc': '📝',
|
||||
'uncategorized': '❓',
|
||||
'focus_session': '🎯',
|
||||
'achievement': '🏆',
|
||||
'manual_boost': '🚀',
|
||||
// Legacy category support
|
||||
'browsing': '🌐',
|
||||
'communication': '💬',
|
||||
'meeting': '🤝',
|
||||
'terminal': '⌨️',
|
||||
'security': '🔐',
|
||||
'other': '📝'
|
||||
};
|
||||
return icons[source] || '📊';
|
||||
}
|
||||
|
||||
formatXPSourceName(source) {
|
||||
const names = {
|
||||
'coding': 'Coding',
|
||||
'focused_browsing': 'Focused Browsing',
|
||||
'collaboration': 'Collaboration',
|
||||
'meetings': 'Meetings',
|
||||
'misc': 'Miscellaneous',
|
||||
'uncategorized': 'Uncategorized',
|
||||
'focus_session': 'Focus Sessions',
|
||||
'achievement': 'Achievements',
|
||||
'manual_boost': 'Manual Boosts',
|
||||
// Legacy category support
|
||||
'browsing': 'Web Browsing',
|
||||
'communication': 'Communication',
|
||||
'meeting': 'Meetings',
|
||||
'terminal': 'Terminal/CLI',
|
||||
'security': 'Security Tools',
|
||||
'other': 'Other Activities'
|
||||
};
|
||||
return names[source] || source.charAt(0).toUpperCase() + source.slice(1);
|
||||
}
|
||||
|
||||
async updateLogs() {
|
||||
try {
|
||||
const level = document.getElementById('log-level').value;
|
||||
const url = level ? `/api/logs?level=${level}&count=50` : '/api/logs?count=50';
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
const logsContainer = document.getElementById('logs-container');
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
logsContainer.innerHTML = data.logs.map(log => {
|
||||
const logClass = this.getLogClass(log);
|
||||
return `<div class="log-entry ${logClass}">${this.escapeHtml(log)}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
logsContainer.innerHTML = '<div class="log-entry">No logs available</div>';
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
} catch (error) {
|
||||
console.error('Failed to update logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const config = await response.json();
|
||||
|
||||
// Update config inputs
|
||||
document.getElementById('coding-xp').value = config.xp_rewards?.base_multipliers?.coding || 10;
|
||||
document.getElementById('research-xp').value = config.xp_rewards?.base_multipliers?.research || 8;
|
||||
document.getElementById('meeting-xp').value = config.xp_rewards?.base_multipliers?.meeting || 3;
|
||||
document.getElementById('focus-bonus').value = config.xp_rewards?.focus_session_bonuses?.base_xp_per_minute || 5;
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig() {
|
||||
try {
|
||||
const updates = {
|
||||
'xp_rewards.base_multipliers.coding': parseInt(document.getElementById('coding-xp').value),
|
||||
'xp_rewards.base_multipliers.research': parseInt(document.getElementById('research-xp').value),
|
||||
'xp_rewards.base_multipliers.meeting': parseInt(document.getElementById('meeting-xp').value),
|
||||
'xp_rewards.focus_session_bonuses.base_xp_per_minute': parseInt(document.getElementById('focus-bonus').value)
|
||||
};
|
||||
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage('Configuration saved successfully!', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to save configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
this.showMessage('Failed to save configuration', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async setupChart() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/history?days=7');
|
||||
const history = await response.json();
|
||||
|
||||
const ctx = document.getElementById('xp-chart').getContext('2d');
|
||||
|
||||
const labels = history.map(day => {
|
||||
const date = new Date(day.date);
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const xpData = history.map(day => day.xp);
|
||||
const levelData = history.map(day => day.level);
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'XP',
|
||||
data: xpData,
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Level',
|
||||
data: levelData,
|
||||
borderColor: '#764ba2',
|
||||
backgroundColor: 'rgba(118, 75, 162, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: false,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'XP'
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Level'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
title: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateChart() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/history?days=7');
|
||||
const history = await response.json();
|
||||
|
||||
if (this.chart) {
|
||||
const labels = history.map(day => {
|
||||
const date = new Date(day.date);
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
});
|
||||
|
||||
const xpData = history.map(day => day.xp);
|
||||
const levelData = history.map(day => day.level);
|
||||
|
||||
// Update chart data
|
||||
this.chart.data.labels = labels;
|
||||
this.chart.data.datasets[0].data = xpData;
|
||||
this.chart.data.datasets[1].data = levelData;
|
||||
|
||||
// Refresh the chart
|
||||
this.chart.update('none'); // 'none' for no animation during updates
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Save config button
|
||||
document.getElementById('save-config').addEventListener('click', () => {
|
||||
this.saveConfig();
|
||||
});
|
||||
|
||||
// Refresh logs button
|
||||
document.getElementById('refresh-logs').addEventListener('click', () => {
|
||||
this.updateLogs();
|
||||
});
|
||||
|
||||
// Log level filter
|
||||
document.getElementById('log-level').addEventListener('change', () => {
|
||||
this.updateLogs();
|
||||
});
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
// Refresh data every 30 seconds
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.updateStatsAndActivity();
|
||||
this.updateChart();
|
||||
this.updateAchievements();
|
||||
this.updateXPBreakdown();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLogClass(logEntry) {
|
||||
if (logEntry.includes('[ERROR]')) return 'error';
|
||||
if (logEntry.includes('[WARN]')) return 'warn';
|
||||
if (logEntry.includes('[INFO]')) return 'info';
|
||||
if (logEntry.includes('[DEBUG]')) return 'debug';
|
||||
return '';
|
||||
}
|
||||
|
||||
capitalizeFirst(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showMessage(message, type = 'info') {
|
||||
// Create message element
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.className = `message ${type}`;
|
||||
messageEl.textContent = message;
|
||||
|
||||
// Insert at top of container
|
||||
const container = document.querySelector('.container');
|
||||
container.insertBefore(messageEl, container.firstChild);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (messageEl.parentNode) {
|
||||
messageEl.parentNode.removeChild(messageEl);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async updateClassifications() {
|
||||
try {
|
||||
const response = await fetch('/api/classifications');
|
||||
const classifications = await response.json();
|
||||
|
||||
const classificationsContainer = document.getElementById('classifications-list');
|
||||
|
||||
if (classifications && classifications.length > 0) {
|
||||
classificationsContainer.innerHTML = classifications.map(classification => {
|
||||
const categoryIcon = this.getCategoryIcon(classification.category_id);
|
||||
const categoryName = this.formatCategoryName(classification.category_id);
|
||||
|
||||
return `
|
||||
<div class="classification-item">
|
||||
<div class="classification-header">
|
||||
<span class="classification-icon">${categoryIcon}</span>
|
||||
<span class="classification-app">${classification.application_name}</span>
|
||||
<span class="classification-category">${categoryName}</span>
|
||||
<button class="btn-delete" onclick="dashboard.deleteClassification('${classification.application_name}')">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
classificationsContainer.innerHTML = '<div class="classification-item"><span class="classification-name">No classifications yet</span></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update classifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateUnclassified() {
|
||||
try {
|
||||
const response = await fetch('/api/unclassified');
|
||||
const unclassified = await response.json();
|
||||
|
||||
const unclassifiedContainer = document.getElementById('unclassified-list');
|
||||
|
||||
if (unclassified && unclassified.length > 0) {
|
||||
unclassifiedContainer.innerHTML = unclassified.map(app => {
|
||||
const lastSeen = new Date(app.last_seen);
|
||||
const timeStr = lastSeen.toLocaleDateString();
|
||||
|
||||
return `
|
||||
<div class="unclassified-item">
|
||||
<div class="unclassified-header">
|
||||
<span class="unclassified-name">${app.application_name}</span>
|
||||
<span class="unclassified-count">${app.occurrence_count} times</span>
|
||||
<span class="unclassified-date">Last: ${timeStr}</span>
|
||||
</div>
|
||||
<div class="classification-controls">
|
||||
<select class="category-select" id="category-${app.id}">
|
||||
<option value="">Select category...</option>
|
||||
<option value="coding">💻 Coding</option>
|
||||
<option value="focused_browsing">🔍 Focused Browsing</option>
|
||||
<option value="collaboration">🤝 Collaboration</option>
|
||||
<option value="meetings">📅 Meetings</option>
|
||||
<option value="misc">📝 Miscellaneous</option>
|
||||
<option value="uncategorized">❓ Uncategorized</option>
|
||||
</select>
|
||||
<button class="btn-classify" onclick="dashboard.classifyApplication('${app.application_name}', 'category-${app.id}')">Classify</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
unclassifiedContainer.innerHTML = '<div class="unclassified-item"><span class="unclassified-name">No unclassified applications</span></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update unclassified applications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async classifyApplication(applicationName, selectId) {
|
||||
try {
|
||||
const selectElement = document.getElementById(selectId);
|
||||
const categoryId = selectElement.value;
|
||||
|
||||
if (!categoryId) {
|
||||
this.showMessage('Please select a category', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/classifications', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
application_name: applicationName,
|
||||
category_id: categoryId
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage(`${applicationName} classified as ${this.formatCategoryName(categoryId)}`, 'success');
|
||||
await this.updateClassifications();
|
||||
await this.updateUnclassified();
|
||||
} else {
|
||||
throw new Error('Failed to classify application');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to classify application:', error);
|
||||
this.showMessage('Failed to classify application', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteClassification(applicationName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(applicationName);
|
||||
const response = await fetch(`/api/classifications/${encodedName}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.showMessage(`Classification for ${applicationName} removed`, 'success');
|
||||
await this.updateClassifications();
|
||||
await this.updateUnclassified();
|
||||
} else {
|
||||
throw new Error('Failed to delete classification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete classification:', error);
|
||||
this.showMessage('Failed to delete classification', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryIcon(categoryId) {
|
||||
const icons = {
|
||||
'coding': '💻',
|
||||
'focused_browsing': '🔍',
|
||||
'collaboration': '🤝',
|
||||
'meetings': '📅',
|
||||
'misc': '📝',
|
||||
'uncategorized': '❓',
|
||||
// Legacy category support
|
||||
'browsing': '🌐',
|
||||
'communication': '💬',
|
||||
'meeting': '🤝',
|
||||
'terminal': '⌨️',
|
||||
'security': '🔐',
|
||||
'other': '📝'
|
||||
};
|
||||
return icons[categoryId] || '📊';
|
||||
}
|
||||
|
||||
formatCategoryName(categoryId) {
|
||||
const names = {
|
||||
'coding': 'Coding',
|
||||
'focused_browsing': 'Focused Browsing',
|
||||
'collaboration': 'Collaboration',
|
||||
'meetings': 'Meetings',
|
||||
'misc': 'Miscellaneous',
|
||||
'uncategorized': 'Uncategorized',
|
||||
// Legacy category support
|
||||
'browsing': 'Web Browsing',
|
||||
'communication': 'Communication',
|
||||
'meeting': 'Meetings',
|
||||
'terminal': 'Terminal/CLI',
|
||||
'security': 'Security Tools',
|
||||
'other': 'Other'
|
||||
};
|
||||
return names[categoryId] || categoryId.charAt(0).toUpperCase() + categoryId.slice(1);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopAutoRefresh();
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.dashboard = new ProductivityDashboard();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.dashboard) {
|
||||
window.dashboard.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XP Nix - Productivity Dashboard</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🎮 XP Nix Productivity Dashboard</h1>
|
||||
<div class="header-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Level</span>
|
||||
<span class="stat-value" id="current-level">1</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">XP</span>
|
||||
<span class="stat-value" id="current-xp">0</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Streak</span>
|
||||
<span class="stat-value" id="current-streak">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Today's Stats -->
|
||||
<div class="card">
|
||||
<h2>📊 Today's Progress</h2>
|
||||
<div class="progress-stats">
|
||||
<div class="progress-item">
|
||||
<span class="progress-label">Focus Time</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="focus-progress"></div>
|
||||
</div>
|
||||
<span class="progress-value" id="focus-time">0h 0m</span>
|
||||
</div>
|
||||
<div class="progress-item">
|
||||
<span class="progress-label">Meeting Time</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="meeting-progress"></div>
|
||||
</div>
|
||||
<span class="progress-value" id="meeting-time">0h 0m</span>
|
||||
</div>
|
||||
<div class="progress-item">
|
||||
<span class="progress-label">Focus Sessions</span>
|
||||
<span class="progress-value" id="focus-sessions">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XP History Chart -->
|
||||
<div class="card chart-card">
|
||||
<h2>📈 XP Progress (7 Days)</h2>
|
||||
<canvas id="xp-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card">
|
||||
<h2>⚡ Recent Activity</h2>
|
||||
<div class="activity-list" id="recent-activity">
|
||||
<div class="activity-item">
|
||||
<span class="activity-type">No recent activity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XP Breakdown -->
|
||||
<div class="card">
|
||||
<h2>💎 XP Sources Today</h2>
|
||||
<div class="xp-breakdown" id="xp-breakdown">
|
||||
<div class="xp-source-item">
|
||||
<span class="xp-source-name">Loading XP breakdown...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div class="card">
|
||||
<h2>🏆 Recent Achievements</h2>
|
||||
<div class="achievements-list" id="achievements-list">
|
||||
<div class="achievement-item">
|
||||
<span class="achievement-name">No achievements yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Panel -->
|
||||
<div class="card config-card">
|
||||
<h2>⚙️ Configuration</h2>
|
||||
<div class="config-section">
|
||||
<h3>XP Multipliers</h3>
|
||||
<div class="config-group">
|
||||
<label for="coding-xp">Coding XP per minute:</label>
|
||||
<input type="number" id="coding-xp" min="1" max="50" value="10">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label for="research-xp">Research XP per minute:</label>
|
||||
<input type="number" id="research-xp" min="1" max="50" value="8">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label for="meeting-xp">Meeting XP per minute:</label>
|
||||
<input type="number" id="meeting-xp" min="1" max="50" value="3">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label for="focus-bonus">Focus session bonus per minute:</label>
|
||||
<input type="number" id="focus-bonus" min="1" max="20" value="5">
|
||||
</div>
|
||||
<button id="save-config" class="btn-primary">Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Classifications -->
|
||||
<div class="card classification-card">
|
||||
<h2>🏷️ Application Classifications</h2>
|
||||
<div class="classification-section">
|
||||
<h3>Unclassified Applications</h3>
|
||||
<div class="unclassified-list" id="unclassified-list">
|
||||
<div class="unclassified-item">
|
||||
<span class="unclassified-name">Loading unclassified applications...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Current Classifications</h3>
|
||||
<div class="classifications-list" id="classifications-list">
|
||||
<div class="classification-item">
|
||||
<span class="classification-name">Loading classifications...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Logs -->
|
||||
<div class="card logs-card">
|
||||
<h2>📝 System Logs</h2>
|
||||
<div class="logs-controls">
|
||||
<select id="log-level">
|
||||
<option value="">All Levels</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<button id="refresh-logs" class="btn-secondary">Refresh</button>
|
||||
</div>
|
||||
<div class="logs-container" id="logs-container">
|
||||
<div class="log-entry">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,579 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
min-width: 120px;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 20px;
|
||||
color: #4a5568;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
min-width: 100px;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.chart-card canvas {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.activity-list, .achievements-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-item, .achievement-item {
|
||||
padding: 12px;
|
||||
border-left: 4px solid #667eea;
|
||||
background: #f7fafc;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.activity-item:hover, .achievement-item:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.activity-type, .achievement-name {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.activity-details, .achievement-description {
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* XP Breakdown Styles */
|
||||
.xp-breakdown {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xp-source-item {
|
||||
padding: 15px;
|
||||
background: #f7fafc;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.xp-source-item:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.xp-source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xp-source-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.xp-source-name {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xp-source-amount {
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.xp-source-bar {
|
||||
height: 6px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.xp-source-progress {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.xp-source-percentage {
|
||||
font-size: 0.8rem;
|
||||
color: #718096;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
color: #4a5568;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-group label {
|
||||
min-width: 180px;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.config-group input {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.config-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e0;
|
||||
}
|
||||
|
||||
.logs-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.logs-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-controls select {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
background: #1a202c;
|
||||
color: #e2e8f0;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 5px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #fed7d7;
|
||||
background: rgba(254, 178, 178, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry.warn {
|
||||
color: #faf089;
|
||||
background: rgba(250, 240, 137, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: #90cdf4;
|
||||
}
|
||||
|
||||
.log-entry.debug {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card, .logs-card {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-width: 100px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.config-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.config-group label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Success/Error Messages */
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
border: 1px solid #9ae6b4;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
border: 1px solid #feb2b2;
|
||||
}
|
||||
|
||||
/* Classification Styles */
|
||||
.classification-card {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.classification-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.classification-section h3 {
|
||||
color: #4a5568;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.unclassified-list, .classifications-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.unclassified-item, .classification-item {
|
||||
padding: 15px;
|
||||
background: #f7fafc;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #ed8936;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.classification-item {
|
||||
border-left-color: #48bb78;
|
||||
}
|
||||
|
||||
.unclassified-item:hover, .classification-item:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.unclassified-header, .classification-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.unclassified-name, .classification-app {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.unclassified-count, .unclassified-date {
|
||||
font-size: 0.9rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.classification-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.classification-category {
|
||||
font-weight: 500;
|
||||
color: #48bb78;
|
||||
background: rgba(72, 187, 120, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.classification-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-select {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 0.9rem;
|
||||
min-width: 180px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.category-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn-classify {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-classify:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #c53030;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../logging/logger.dart';
|
||||
|
||||
class WebSocketManager {
|
||||
static WebSocketManager? _instance;
|
||||
static WebSocketManager get instance => _instance ??= WebSocketManager._();
|
||||
|
||||
WebSocketManager._();
|
||||
|
||||
final Set<WebSocketChannel> _connections = {};
|
||||
|
||||
void addConnection(WebSocketChannel channel) {
|
||||
_connections.add(channel);
|
||||
Logger.info('WebSocket connection added. Total connections: ${_connections.length}');
|
||||
|
||||
// Listen for connection close
|
||||
channel.stream.listen(
|
||||
null,
|
||||
onDone: () => _removeConnection(channel),
|
||||
onError: (error) {
|
||||
Logger.error('WebSocket connection error: $error');
|
||||
_removeConnection(channel);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _removeConnection(WebSocketChannel channel) {
|
||||
_connections.remove(channel);
|
||||
Logger.info('WebSocket connection removed. Total connections: ${_connections.length}');
|
||||
}
|
||||
|
||||
void broadcast(Map<String, dynamic> message) {
|
||||
if (_connections.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final jsonMessage = jsonEncode(message);
|
||||
final connectionsToRemove = <WebSocketChannel>[];
|
||||
|
||||
for (final connection in _connections) {
|
||||
try {
|
||||
connection.sink.add(jsonMessage);
|
||||
} catch (e) {
|
||||
Logger.error('Failed to send WebSocket message: $e');
|
||||
connectionsToRemove.add(connection);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed connections
|
||||
for (final connection in connectionsToRemove) {
|
||||
_removeConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
void close() {
|
||||
for (final connection in _connections) {
|
||||
try {
|
||||
connection.sink.close();
|
||||
} catch (e) {
|
||||
Logger.error('Error closing WebSocket connection: $e');
|
||||
}
|
||||
}
|
||||
_connections.clear();
|
||||
Logger.info('All WebSocket connections closed');
|
||||
}
|
||||
|
||||
int get connectionCount => _connections.length;
|
||||
bool get hasConnections => _connections.isNotEmpty;
|
||||
}
|
||||
Reference in New Issue
Block a user