Starting to go places. More testing in place, need to solidify the dashboard api boundary next
This commit is contained in:
		
						commit
						8ea06b12f7
					
				
							
								
								
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					.dart_tool/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# nix direnv
 | 
				
			||||||
 | 
					.direnv/**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**/.claude/settings.local.json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# logs
 | 
				
			||||||
 | 
					**/logs/**
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# sqlite file
 | 
				
			||||||
 | 
					*.db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# binaries
 | 
				
			||||||
 | 
					*.exe
 | 
				
			||||||
							
								
								
									
										3
									
								
								CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					## 1.0.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Initial version.
 | 
				
			||||||
							
								
								
									
										76
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					# CLAUDE.md
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Commands
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Development
 | 
				
			||||||
 | 
					- `dart run bin/xp_nix.dart` - Run the main application
 | 
				
			||||||
 | 
					- `dart test` - Run all tests  
 | 
				
			||||||
 | 
					- `dart test test/specific_test.dart` - Run a specific test file
 | 
				
			||||||
 | 
					- `dart analyze` - Run static analysis
 | 
				
			||||||
 | 
					- `dart pub get` - Install dependencies
 | 
				
			||||||
 | 
					- `nix develop` - Enter development environment with Dart and SQLite
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Nix Environment
 | 
				
			||||||
 | 
					This project uses Nix flakes for development environment management. The flake provides Dart SDK and SQLite with proper library paths configured.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Architecture Overview
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Dependency Injection & Testing
 | 
				
			||||||
 | 
					The codebase uses dependency injection through interfaces to enable both production and testing modes:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Interfaces** (`lib/src/interfaces/`): Define contracts for core components
 | 
				
			||||||
 | 
					  - `IActivityDetector` - Window/application activity detection
 | 
				
			||||||
 | 
					  - `IIdleMonitor` - User idle state monitoring  
 | 
				
			||||||
 | 
					  - `IDesktopEnhancer` - Desktop theme/visual enhancements
 | 
				
			||||||
 | 
					  - `ITimeProvider` - Time operations (for testing time manipulation)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Production Implementations**: Real system integrations (Hyprland, system time)
 | 
				
			||||||
 | 
					- **Test Mocks** (`lib/src/testing/`): Controllable implementations for testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Core Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**ProductivityMonitor** - Central orchestrator that:
 | 
				
			||||||
 | 
					- Coordinates activity detection, idle monitoring, and desktop enhancement
 | 
				
			||||||
 | 
					- Calculates XP rewards based on activity types and time multipliers
 | 
				
			||||||
 | 
					- Manages level progression and achievement system
 | 
				
			||||||
 | 
					- Handles both event-driven (via IActivityDetector) and polling modes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Activity Classification System**:
 | 
				
			||||||
 | 
					- Uses `ActivityEventType` enum for categorizing activities (coding, meetings, etc.)
 | 
				
			||||||
 | 
					- Supports user-defined application classifications stored in database
 | 
				
			||||||
 | 
					- Fallback categorization based on application names and window titles
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**XP & Gamification**:
 | 
				
			||||||
 | 
					- Time-based multipliers for deep work hours vs late night penalty
 | 
				
			||||||
 | 
					- Focus session bonuses with milestone rewards (60min, 120min, 180min)
 | 
				
			||||||
 | 
					- Level-based visual themes applied via desktop enhancer
 | 
				
			||||||
 | 
					- Achievement system with level, focus, and session-based rewards
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Database Schema
 | 
				
			||||||
 | 
					SQLite database with tables for:
 | 
				
			||||||
 | 
					- Daily stats (XP, focus time, meeting time, level)
 | 
				
			||||||
 | 
					- Activity events with duration and metadata
 | 
				
			||||||
 | 
					- Application classifications (user-defined)
 | 
				
			||||||
 | 
					- Achievements and focus sessions
 | 
				
			||||||
 | 
					- Theme change history and streak tracking
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Configuration
 | 
				
			||||||
 | 
					JSON-based configuration (`config/xp_config.json`) defines:
 | 
				
			||||||
 | 
					- XP multipliers by activity type and time of day
 | 
				
			||||||
 | 
					- Achievement definitions and rewards
 | 
				
			||||||
 | 
					- Focus session bonus structure
 | 
				
			||||||
 | 
					- Monitoring intervals and thresholds
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Web Dashboard
 | 
				
			||||||
 | 
					Built-in web server (`lib/src/web/`) provides real-time dashboard at http://localhost:8080 showing stats, recent activities, and progress visualization.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Testing Strategy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The architecture supports comprehensive testing through:
 | 
				
			||||||
 | 
					- Interface-based dependency injection
 | 
				
			||||||
 | 
					- Time manipulation via `ITimeProvider`
 | 
				
			||||||
 | 
					- Mock implementations that simulate user behavior
 | 
				
			||||||
 | 
					- Simulation tests that model complete work days
 | 
				
			||||||
 | 
					- Integration tests for idle detection and activity consolidation
 | 
				
			||||||
							
								
								
									
										49
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					# XP Nix
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					A productivity tracking and gamification system designed for Linux desktop environments, specifically optimized for Hyprland window manager users.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Overview
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					XP Nix transforms your daily computer usage into a gamified experience by monitoring your activity patterns and providing real-time feedback through an XP (experience points) system. The application runs as a background service that intelligently tracks your productivity, detects idle periods, and rewards active engagement with your desktop environment.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Intended Application
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This tool is designed for:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Productivity enthusiasts** who want to gamify their work sessions and maintain awareness of their activity patterns
 | 
				
			||||||
 | 
					- **Remote workers** seeking to track and optimize their daily computer usage habits
 | 
				
			||||||
 | 
					- **Linux power users** running Hyprland who want deep integration with their window manager
 | 
				
			||||||
 | 
					- **Self-improvement focused individuals** who benefit from real-time feedback and progress tracking
 | 
				
			||||||
 | 
					- **Teams or individuals** who want to monitor productivity trends over time through detailed analytics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Core Functionality
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The system provides:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Real-time activity detection** that monitors window focus, application usage, and user interaction patterns
 | 
				
			||||||
 | 
					- **Intelligent idle detection** that distinguishes between productive pauses and actual inactivity
 | 
				
			||||||
 | 
					- **XP-style notifications** that provide immediate feedback and motivation
 | 
				
			||||||
 | 
					- **Web-based dashboard** for viewing detailed analytics, trends, and historical data
 | 
				
			||||||
 | 
					- **Configurable monitoring** with customizable thresholds and notification preferences
 | 
				
			||||||
 | 
					- **Database persistence** for long-term tracking and analysis
 | 
				
			||||||
 | 
					- **Zoom integration** for detecting video conferencing sessions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Use Cases
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Track daily productivity patterns and identify peak performance hours
 | 
				
			||||||
 | 
					- Gamify work sessions to maintain motivation during long coding or writing sessions
 | 
				
			||||||
 | 
					- Monitor break patterns to ensure healthy work-life balance
 | 
				
			||||||
 | 
					- Generate productivity reports for personal reflection or team accountability
 | 
				
			||||||
 | 
					- Integrate with existing workflow tools through the web API
 | 
				
			||||||
 | 
					- Maintain awareness of screen time and application usage distribution
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Technical Architecture
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Built with Dart for cross-platform compatibility and performance, featuring:
 | 
				
			||||||
 | 
					- Modular architecture with clean interfaces for extensibility
 | 
				
			||||||
 | 
					- Comprehensive testing suite including simulation capabilities
 | 
				
			||||||
 | 
					- Configuration management for personalized setups
 | 
				
			||||||
 | 
					- Web server for dashboard access and API endpoints
 | 
				
			||||||
 | 
					- Database integration for persistent data storage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This application is particularly well-suited for developers, writers, designers, and other knowledge workers who spend significant time at their computers and want to optimize their productivity through data-driven insights and positive reinforcement.
 | 
				
			||||||
							
								
								
									
										94
									
								
								ROADMAP.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								ROADMAP.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,94 @@
 | 
				
			|||||||
 | 
					# XP Productivity Monitor Enhancement Plan
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Core Visual Enhancements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Cursor-Following XP Numbers
 | 
				
			||||||
 | 
					- **Implementation**: Create ephemeral transparent windows using Hyprland cursor position API
 | 
				
			||||||
 | 
					- **Behavior**: Spawn at cursor location, animate upward like RPG damage numbers, fade after 2-3 seconds
 | 
				
			||||||
 | 
					- **Content**: Show individual activity XP (+5, +12) and streak bonuses (+45 for 30min focus)
 | 
				
			||||||
 | 
					- **Technical**: Use GTK4/Qt overlay widgets with CSS animations, non-interactive windows
 | 
				
			||||||
 | 
					- **Animation Style**: Numbers fly upward and slightly outward, with color coding (green for regular XP, gold for bonuses)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Visual Progress Bars
 | 
				
			||||||
 | 
					Move from bottom bar to more prominent locations:
 | 
				
			||||||
 | 
					- **Real-time XP bar**: Center-screen or near active window
 | 
				
			||||||
 | 
					- **Flow streak indicator**: Shows consecutive focused minutes
 | 
				
			||||||
 | 
					- **Daily mastery progress**: Toward daily goals
 | 
				
			||||||
 | 
					- **Consider**: Game HUD positioning principles - important info near center/cursor area
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Celebration System Overhaul
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Level-Up Celebrations
 | 
				
			||||||
 | 
					- **Major milestones** (every 5 levels): Full-screen celebration using hyprlock
 | 
				
			||||||
 | 
					  - Custom image/message, dismissed with mouse/keyboard input
 | 
				
			||||||
 | 
					  - Limit to max 10 interruptions per day
 | 
				
			||||||
 | 
					- **Regular levels**: Enhanced cursor effects + optional sound
 | 
				
			||||||
 | 
					- **Persistent stats**: Continue using swaync for dismissible notifications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Dual Currency Shop System
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Currency Types
 | 
				
			||||||
 | 
					1. **XP Points** (Cookie Clicker currency)
 | 
				
			||||||
 | 
					   - Earned through regular activity
 | 
				
			||||||
 | 
					   - Used for productivity upgrades and boosters
 | 
				
			||||||
 | 
					   - Creates meta-progression loop
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. **Reward Tokens** (Real-world currency)
 | 
				
			||||||
 | 
					   - Earned at slower rate from major achievements
 | 
				
			||||||
 | 
					   - Spent on actual purchases through automated system
 | 
				
			||||||
 | 
					   - Tier examples:
 | 
				
			||||||
 | 
					     - Small treats (drinks): 50-100 tokens
 | 
				
			||||||
 | 
					     - Tools/gadgets: 500-2000 tokens
 | 
				
			||||||
 | 
					     - Major purchases: 5000+ tokens
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### XP Shop Items (Rogue-lite/Ephemeral)
 | 
				
			||||||
 | 
					All XP shop purchases are temporary and reset daily/weekly:
 | 
				
			||||||
 | 
					- **Shell History Analyzer** (500 XP): +10% productivity bonus for today
 | 
				
			||||||
 | 
					- **Calendar Integration** (1000 XP): Meeting efficiency tracking for this week
 | 
				
			||||||
 | 
					- **Code Complexity Analyzer** (2000 XP): Debugging session bonuses for today
 | 
				
			||||||
 | 
					- **AI Work Pattern Analyzer** (5000 XP): Daily rhythm optimization for this week
 | 
				
			||||||
 | 
					- **Focus Multipliers**: 2x XP for next 1-3 hours
 | 
				
			||||||
 | 
					- **Streak Protectors**: Prevent losing streaks during breaks (single use)
 | 
				
			||||||
 | 
					- **Power Hours**: Enhanced XP gain and visual effects for set duration
 | 
				
			||||||
 | 
					- **Deep Work Shield**: Blocks distracting notifications temporarily
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Real-World Shop
 | 
				
			||||||
 | 
					- **One-click purchasing**: Click to buy instantly orders the item
 | 
				
			||||||
 | 
					- **Wishlist management**: Pre-approved items with token costs
 | 
				
			||||||
 | 
					- **Automatic fulfillment**: Orders placed immediately upon purchase confirmation
 | 
				
			||||||
 | 
					- **Purchase history**: Track what you've earned and when
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Environmental Feedback
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Focus-Based Environmental Changes
 | 
				
			||||||
 | 
					- **Progressive dimming**: Gradually dim non-active windows during focus sessions
 | 
				
			||||||
 | 
					- **Ambient soundscapes**: Evolve with focus depth (toggleable for shared workspaces)
 | 
				
			||||||
 | 
					- **Desktop enhancement**: Theme changes based on current focus state, not just level
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Technical Implementation Notes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Database Schema Updates
 | 
				
			||||||
 | 
					- Add `tokens` table for reward currency tracking
 | 
				
			||||||
 | 
					- Add `shop_items` table for available purchases
 | 
				
			||||||
 | 
					- Add `unlocked_features` table for XP shop progression
 | 
				
			||||||
 | 
					- Add `purchase_history` for real-world rewards
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Configuration Additions
 | 
				
			||||||
 | 
					- Cursor animation settings (duration, style, frequency)
 | 
				
			||||||
 | 
					- Celebration thresholds and styles
 | 
				
			||||||
 | 
					- Shop item definitions and unlock requirements
 | 
				
			||||||
 | 
					- Sound settings with workspace-aware toggling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Integration Points
 | 
				
			||||||
 | 
					- **Hyprland**: Cursor position tracking, window management
 | 
				
			||||||
 | 
					- **nwg-panel/waybar**: Progress bar widgets (or custom solution)
 | 
				
			||||||
 | 
					- **swaync**: Enhanced notifications
 | 
				
			||||||
 | 
					- **hyprlock**: Celebration screens
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Priority Implementation Order
 | 
				
			||||||
 | 
					1. Cursor-following XP animations
 | 
				
			||||||
 | 
					2. Dual currency system and database updates
 | 
				
			||||||
 | 
					3. XP shop with basic productivity upgrades
 | 
				
			||||||
 | 
					4. Enhanced celebration system
 | 
				
			||||||
 | 
					5. Environmental feedback features
 | 
				
			||||||
 | 
					6. Real-world purchase integration
 | 
				
			||||||
							
								
								
									
										30
									
								
								analysis_options.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								analysis_options.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					# This file configures the static analysis results for your project (errors,
 | 
				
			||||||
 | 
					# warnings, and lints).
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# This enables the 'recommended' set of lints from `package:lints`.
 | 
				
			||||||
 | 
					# This set helps identify many issues that may lead to problems when running
 | 
				
			||||||
 | 
					# or consuming Dart code, and enforces writing Dart using a single, idiomatic
 | 
				
			||||||
 | 
					# style and format.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# If you want a smaller set of lints you can change this to specify
 | 
				
			||||||
 | 
					# 'package:lints/core.yaml'. These are just the most critical lints
 | 
				
			||||||
 | 
					# (the recommended set includes the core lints).
 | 
				
			||||||
 | 
					# The core lints are also what is used by pub.dev for scoring packages.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					include: package:lints/recommended.yaml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Uncomment the following section to specify additional rules.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# linter:
 | 
				
			||||||
 | 
					#   rules:
 | 
				
			||||||
 | 
					#     - camel_case_types
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# analyzer:
 | 
				
			||||||
 | 
					#   exclude:
 | 
				
			||||||
 | 
					#     - path/to/excluded/files/**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# For more information about the core and recommended set of lints, see
 | 
				
			||||||
 | 
					# https://dart.dev/go/core-lints
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# For additional information about configuring this file, see
 | 
				
			||||||
 | 
					# https://dart.dev/guides/language/analysis-options
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								assets/levelup.mp3
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/levelup.mp3
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										108
									
								
								bin/xp_nix.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								bin/xp_nix.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,108 @@
 | 
				
			|||||||
 | 
					import 'dart:async';
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					import 'package:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/monitors/productivity_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/config/config_manager.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/logging/logger.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/web/dashboard_server.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/database/database_manager.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/detectors/idle_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/providers/system_time_provider.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/enhancers/hyprland_enhancer.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Enhanced main function with interactive commands and one-shot mode
 | 
				
			||||||
 | 
					void main(List<String> args) async {
 | 
				
			||||||
 | 
					  // Initialize logging system
 | 
				
			||||||
 | 
					  await Logger.instance.initialize(level: LogLevel.info, logDirectory: 'logs', maxFileSizeMB: 10, maxFiles: 5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Initialize configuration manager
 | 
				
			||||||
 | 
					  await ConfigManager.instance.initialize();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final db = sqlite3.open('productivity_tracker.db');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Create production dependencies
 | 
				
			||||||
 | 
					  final idleMonitor = IdleMonitor();
 | 
				
			||||||
 | 
					  final timeProvider = SystemTimeProvider();
 | 
				
			||||||
 | 
					  final desktopEnhancer = HyprlandEnhancer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Create monitor with dependency injection
 | 
				
			||||||
 | 
					  final monitor = ProductivityMonitor(
 | 
				
			||||||
 | 
					    db: db,
 | 
				
			||||||
 | 
					    idleMonitor: idleMonitor,
 | 
				
			||||||
 | 
					    timeProvider: timeProvider,
 | 
				
			||||||
 | 
					    desktopEnhancer: desktopEnhancer,
 | 
				
			||||||
 | 
					    // No activity detector provided - will use legacy polling mode
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final dashboardServer = DashboardServer.withDatabase(DatabaseManager(db));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ProcessSignal.sigint.watch().listen((_) async {
 | 
				
			||||||
 | 
					    Logger.info('Shutting down XP Nix...');
 | 
				
			||||||
 | 
					    print('\nShutting down...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    monitor.stop();
 | 
				
			||||||
 | 
					    await dashboardServer.stop();
 | 
				
			||||||
 | 
					    await Logger.instance.dispose();
 | 
				
			||||||
 | 
					    db.dispose();
 | 
				
			||||||
 | 
					    exit(0);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Start the dashboard server
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await dashboardServer.start(8080);
 | 
				
			||||||
 | 
					    Logger.info('Dashboard available at: ${dashboardServer.dashboardUrl}');
 | 
				
			||||||
 | 
					    print('🌐 Dashboard available at: ${dashboardServer.dashboardUrl}');
 | 
				
			||||||
 | 
					  } catch (e) {
 | 
				
			||||||
 | 
					    Logger.error('Failed to start dashboard server: $e');
 | 
				
			||||||
 | 
					    print('⚠️  Dashboard server failed to start: $e');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  monitor.start();
 | 
				
			||||||
 | 
					  monitor.printDetailedStats();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Add command listener for manual controls
 | 
				
			||||||
 | 
					  stdin.transform(utf8.decoder).transform(LineSplitter()).listen((line) {
 | 
				
			||||||
 | 
					    final parts = line.trim().split(' ');
 | 
				
			||||||
 | 
					    final command = parts[0].toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (command) {
 | 
				
			||||||
 | 
					      case 'stats':
 | 
				
			||||||
 | 
					        monitor.printDetailedStats();
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'test':
 | 
				
			||||||
 | 
					        if (parts.length > 1) {
 | 
				
			||||||
 | 
					          final level = int.tryParse(parts[1]) ?? 1;
 | 
				
			||||||
 | 
					          monitor.testTheme(level);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'restore':
 | 
				
			||||||
 | 
					        monitor.restoreDesktop();
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'refresh':
 | 
				
			||||||
 | 
					        monitor.refreshConfig();
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case 'help':
 | 
				
			||||||
 | 
					        print('''
 | 
				
			||||||
 | 
					Available commands:
 | 
				
			||||||
 | 
					- stats: Show current productivity stats
 | 
				
			||||||
 | 
					- test [level]: Test theme for specific level
 | 
				
			||||||
 | 
					- restore: Restore desktop backup
 | 
				
			||||||
 | 
					- refresh: Refresh base config from current system config
 | 
				
			||||||
 | 
					- help: Show this help
 | 
				
			||||||
 | 
					        ''');
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  print('💡 Type "help" for available commands');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Keep running and show stats periodically
 | 
				
			||||||
 | 
					  while (true) {
 | 
				
			||||||
 | 
					    await Future.delayed(Duration(seconds: 1));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (DateTime.now().second == 0 && DateTime.now().minute % 10 == 0) {
 | 
				
			||||||
 | 
					      monitor.printDetailedStats();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1
									
								
								config/xp_config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								config/xp_config.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					{"xp_rewards":{"base_multipliers":{"coding":10,"research":8,"communication":5,"meeting":3,"other":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 4+ hours of focus time in a day","xp_reward":200,"threshold_hours":4},"focus_titan":{"name":"Focus Titan","description":"Achieved 8+ hours of pure focus - Incredible!","xp_reward":500,"threshold_hours":8}},"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","xp_reward":100,"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"}}
 | 
				
			||||||
							
								
								
									
										61
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								flake.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -0,0 +1,61 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "nodes": {
 | 
				
			||||||
 | 
					    "flake-utils": {
 | 
				
			||||||
 | 
					      "inputs": {
 | 
				
			||||||
 | 
					        "systems": "systems"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "locked": {
 | 
				
			||||||
 | 
					        "lastModified": 1731533236,
 | 
				
			||||||
 | 
					        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
 | 
				
			||||||
 | 
					        "owner": "numtide",
 | 
				
			||||||
 | 
					        "repo": "flake-utils",
 | 
				
			||||||
 | 
					        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "original": {
 | 
				
			||||||
 | 
					        "owner": "numtide",
 | 
				
			||||||
 | 
					        "repo": "flake-utils",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "nixpkgs": {
 | 
				
			||||||
 | 
					      "locked": {
 | 
				
			||||||
 | 
					        "lastModified": 1749285348,
 | 
				
			||||||
 | 
					        "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=",
 | 
				
			||||||
 | 
					        "owner": "NixOS",
 | 
				
			||||||
 | 
					        "repo": "nixpkgs",
 | 
				
			||||||
 | 
					        "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "original": {
 | 
				
			||||||
 | 
					        "owner": "NixOS",
 | 
				
			||||||
 | 
					        "ref": "nixos-unstable",
 | 
				
			||||||
 | 
					        "repo": "nixpkgs",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "root": {
 | 
				
			||||||
 | 
					      "inputs": {
 | 
				
			||||||
 | 
					        "flake-utils": "flake-utils",
 | 
				
			||||||
 | 
					        "nixpkgs": "nixpkgs"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "systems": {
 | 
				
			||||||
 | 
					      "locked": {
 | 
				
			||||||
 | 
					        "lastModified": 1681028828,
 | 
				
			||||||
 | 
					        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
 | 
				
			||||||
 | 
					        "owner": "nix-systems",
 | 
				
			||||||
 | 
					        "repo": "default",
 | 
				
			||||||
 | 
					        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "original": {
 | 
				
			||||||
 | 
					        "owner": "nix-systems",
 | 
				
			||||||
 | 
					        "repo": "default",
 | 
				
			||||||
 | 
					        "type": "github"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "root": "root",
 | 
				
			||||||
 | 
					  "version": 7
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										26
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								flake.nix
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  description = "Simple dart flake";
 | 
				
			||||||
 | 
					  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
 | 
				
			||||||
 | 
					  inputs.flake-utils.url = "github:numtide/flake-utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  outputs = {
 | 
				
			||||||
 | 
					    flake-utils,
 | 
				
			||||||
 | 
					    nixpkgs,
 | 
				
			||||||
 | 
					    ...
 | 
				
			||||||
 | 
					  }:
 | 
				
			||||||
 | 
					    flake-utils.lib.eachDefaultSystem (system: let
 | 
				
			||||||
 | 
					      pkgs = import nixpkgs {
 | 
				
			||||||
 | 
					        inherit system;
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    in {
 | 
				
			||||||
 | 
					      devShell = pkgs.mkShell {
 | 
				
			||||||
 | 
					          buildInputs = with pkgs; [
 | 
				
			||||||
 | 
					            dart
 | 
				
			||||||
 | 
					            sqlite
 | 
				
			||||||
 | 
					          ];
 | 
				
			||||||
 | 
					          shellHook = ''
 | 
				
			||||||
 | 
					            export LD_LIBRARY_PATH="${pkgs.sqlite.out}/lib:$LD_LIBRARY_PATH"
 | 
				
			||||||
 | 
					          '';
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										315
									
								
								lib/src/config/config_manager.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										315
									
								
								lib/src/config/config_manager.dart
									
									
									
									
									
										Normal file
									
								
							@ -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!);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										343
									
								
								lib/src/config/hyprland_config_parser.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								lib/src/config/hyprland_config_parser.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										460
									
								
								lib/src/database/database_manager.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										460
									
								
								lib/src/database/database_manager.dart
									
									
									
									
									
										Normal file
									
								
							@ -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]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										89
									
								
								lib/src/detectors/hyprland_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/src/detectors/hyprland_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										72
									
								
								lib/src/detectors/idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								lib/src/detectors/idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										86
									
								
								lib/src/detectors/zoom_detector.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								lib/src/detectors/zoom_detector.dart
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										447
									
								
								lib/src/enhancers/hyprland_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										447
									
								
								lib/src/enhancers/hyprland_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							@ -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');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										17
									
								
								lib/src/interfaces/i_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/src/interfaces/i_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										17
									
								
								lib/src/interfaces/i_desktop_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/src/interfaces/i_desktop_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										18
									
								
								lib/src/interfaces/i_idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/src/interfaces/i_idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										29
									
								
								lib/src/interfaces/i_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								lib/src/interfaces/i_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										254
									
								
								lib/src/logging/logger.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								lib/src/logging/logger.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										106
									
								
								lib/src/models/activity_event.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								lib/src/models/activity_event.dart
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										648
									
								
								lib/src/monitors/productivity_monitor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										648
									
								
								lib/src/monitors/productivity_monitor.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,648 @@
 | 
				
			|||||||
 | 
					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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// 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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 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(),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										303
									
								
								lib/src/notifications/xp_notification_manager.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								lib/src/notifications/xp_notification_manager.dart
									
									
									
									
									
										Normal file
									
								
							@ -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'],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										19
									
								
								lib/src/providers/system_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/src/providers/system_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										119
									
								
								lib/src/testing/mock_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								lib/src/testing/mock_activity_detector.dart
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										62
									
								
								lib/src/testing/mock_desktop_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/src/testing/mock_desktop_enhancer.dart
									
									
									
									
									
										Normal file
									
								
							@ -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');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										73
									
								
								lib/src/testing/mock_idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								lib/src/testing/mock_idle_monitor.dart
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										148
									
								
								lib/src/testing/mock_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								lib/src/testing/mock_time_provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										406
									
								
								lib/src/web/dashboard_server.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								lib/src/web/dashboard_server.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,406 @@
 | 
				
			|||||||
 | 
					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:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import '../database/database_manager.dart';
 | 
				
			||||||
 | 
					import '../config/config_manager.dart';
 | 
				
			||||||
 | 
					import '../logging/logger.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, 'localhost', _port);
 | 
				
			||||||
 | 
					      Logger.info('Dashboard server started on http://localhost:$_port');
 | 
				
			||||||
 | 
					    } 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');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<Response> _handleWebSocket(Request request) async {
 | 
				
			||||||
 | 
					    // Basic WebSocket upgrade (simplified)
 | 
				
			||||||
 | 
					    // In a real implementation, you'd use a proper WebSocket library
 | 
				
			||||||
 | 
					    return Response.notFound('WebSocket not implemented yet');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String get dashboardUrl => 'http://localhost:$_port';
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										653
									
								
								lib/src/web/static/dashboard.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										653
									
								
								lib/src/web/static/dashboard.js
									
									
									
									
									
										Normal file
									
								
							@ -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();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										159
									
								
								lib/src/web/static/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								lib/src/web/static/index.html
									
									
									
									
									
										Normal file
									
								
							@ -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>
 | 
				
			||||||
							
								
								
									
										579
									
								
								lib/src/web/static/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										579
									
								
								lib/src/web/static/style.css
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										429
									
								
								pubspec.lock
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										429
									
								
								pubspec.lock
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,429 @@
 | 
				
			|||||||
 | 
					# Generated by pub
 | 
				
			||||||
 | 
					# See https://dart.dev/tools/pub/glossary#lockfile
 | 
				
			||||||
 | 
					packages:
 | 
				
			||||||
 | 
					  _fe_analyzer_shared:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: _fe_analyzer_shared
 | 
				
			||||||
 | 
					      sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "82.0.0"
 | 
				
			||||||
 | 
					  analyzer:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: analyzer
 | 
				
			||||||
 | 
					      sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "7.4.5"
 | 
				
			||||||
 | 
					  args:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: args
 | 
				
			||||||
 | 
					      sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.7.0"
 | 
				
			||||||
 | 
					  async:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: async
 | 
				
			||||||
 | 
					      sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.13.0"
 | 
				
			||||||
 | 
					  boolean_selector:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: boolean_selector
 | 
				
			||||||
 | 
					      sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.1.2"
 | 
				
			||||||
 | 
					  cli_config:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: cli_config
 | 
				
			||||||
 | 
					      sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.2.0"
 | 
				
			||||||
 | 
					  collection:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: collection
 | 
				
			||||||
 | 
					      sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.19.1"
 | 
				
			||||||
 | 
					  convert:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: convert
 | 
				
			||||||
 | 
					      sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.1.2"
 | 
				
			||||||
 | 
					  coverage:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: coverage
 | 
				
			||||||
 | 
					      sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.14.1"
 | 
				
			||||||
 | 
					  crypto:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: crypto
 | 
				
			||||||
 | 
					      sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.0.6"
 | 
				
			||||||
 | 
					  ffi:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: ffi
 | 
				
			||||||
 | 
					      sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.1.4"
 | 
				
			||||||
 | 
					  file:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: file
 | 
				
			||||||
 | 
					      sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "7.0.1"
 | 
				
			||||||
 | 
					  frontend_server_client:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: frontend_server_client
 | 
				
			||||||
 | 
					      sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "4.0.0"
 | 
				
			||||||
 | 
					  glob:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: glob
 | 
				
			||||||
 | 
					      sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.1.3"
 | 
				
			||||||
 | 
					  http_methods:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: http_methods
 | 
				
			||||||
 | 
					      sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.1.1"
 | 
				
			||||||
 | 
					  http_multi_server:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: http_multi_server
 | 
				
			||||||
 | 
					      sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.2.2"
 | 
				
			||||||
 | 
					  http_parser:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: http_parser
 | 
				
			||||||
 | 
					      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "4.1.2"
 | 
				
			||||||
 | 
					  io:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: io
 | 
				
			||||||
 | 
					      sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.0.5"
 | 
				
			||||||
 | 
					  js:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: js
 | 
				
			||||||
 | 
					      sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.7.2"
 | 
				
			||||||
 | 
					  lints:
 | 
				
			||||||
 | 
					    dependency: "direct dev"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: lints
 | 
				
			||||||
 | 
					      sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "5.1.1"
 | 
				
			||||||
 | 
					  logging:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: logging
 | 
				
			||||||
 | 
					      sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.3.0"
 | 
				
			||||||
 | 
					  matcher:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: matcher
 | 
				
			||||||
 | 
					      sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.12.17"
 | 
				
			||||||
 | 
					  meta:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: meta
 | 
				
			||||||
 | 
					      sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.17.0"
 | 
				
			||||||
 | 
					  mime:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: mime
 | 
				
			||||||
 | 
					      sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.0.0"
 | 
				
			||||||
 | 
					  node_preamble:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: node_preamble
 | 
				
			||||||
 | 
					      sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.0.2"
 | 
				
			||||||
 | 
					  package_config:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: package_config
 | 
				
			||||||
 | 
					      sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.2.0"
 | 
				
			||||||
 | 
					  path:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: path
 | 
				
			||||||
 | 
					      sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.9.1"
 | 
				
			||||||
 | 
					  pool:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: pool
 | 
				
			||||||
 | 
					      sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.5.1"
 | 
				
			||||||
 | 
					  pub_semver:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: pub_semver
 | 
				
			||||||
 | 
					      sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.2.0"
 | 
				
			||||||
 | 
					  shelf:
 | 
				
			||||||
 | 
					    dependency: "direct main"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: shelf
 | 
				
			||||||
 | 
					      sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.4.2"
 | 
				
			||||||
 | 
					  shelf_packages_handler:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: shelf_packages_handler
 | 
				
			||||||
 | 
					      sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.0.2"
 | 
				
			||||||
 | 
					  shelf_router:
 | 
				
			||||||
 | 
					    dependency: "direct main"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: shelf_router
 | 
				
			||||||
 | 
					      sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.1.4"
 | 
				
			||||||
 | 
					  shelf_static:
 | 
				
			||||||
 | 
					    dependency: "direct main"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: shelf_static
 | 
				
			||||||
 | 
					      sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.1.3"
 | 
				
			||||||
 | 
					  shelf_web_socket:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: shelf_web_socket
 | 
				
			||||||
 | 
					      sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.0.0"
 | 
				
			||||||
 | 
					  source_map_stack_trace:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: source_map_stack_trace
 | 
				
			||||||
 | 
					      sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.1.2"
 | 
				
			||||||
 | 
					  source_maps:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: source_maps
 | 
				
			||||||
 | 
					      sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.10.13"
 | 
				
			||||||
 | 
					  source_span:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: source_span
 | 
				
			||||||
 | 
					      sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.10.1"
 | 
				
			||||||
 | 
					  sqlite3:
 | 
				
			||||||
 | 
					    dependency: "direct main"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: sqlite3
 | 
				
			||||||
 | 
					      sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.7.6"
 | 
				
			||||||
 | 
					  stack_trace:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: stack_trace
 | 
				
			||||||
 | 
					      sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.12.1"
 | 
				
			||||||
 | 
					  stream_channel:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: stream_channel
 | 
				
			||||||
 | 
					      sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "2.1.4"
 | 
				
			||||||
 | 
					  string_scanner:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: string_scanner
 | 
				
			||||||
 | 
					      sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.4.1"
 | 
				
			||||||
 | 
					  term_glyph:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: term_glyph
 | 
				
			||||||
 | 
					      sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.2.2"
 | 
				
			||||||
 | 
					  test:
 | 
				
			||||||
 | 
					    dependency: "direct dev"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: test
 | 
				
			||||||
 | 
					      sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.26.2"
 | 
				
			||||||
 | 
					  test_api:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: test_api
 | 
				
			||||||
 | 
					      sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.7.6"
 | 
				
			||||||
 | 
					  test_core:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: test_core
 | 
				
			||||||
 | 
					      sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "0.6.11"
 | 
				
			||||||
 | 
					  typed_data:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: typed_data
 | 
				
			||||||
 | 
					      sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.4.0"
 | 
				
			||||||
 | 
					  vm_service:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: vm_service
 | 
				
			||||||
 | 
					      sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "15.0.2"
 | 
				
			||||||
 | 
					  watcher:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: watcher
 | 
				
			||||||
 | 
					      sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.1.1"
 | 
				
			||||||
 | 
					  web:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: web
 | 
				
			||||||
 | 
					      sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.1.1"
 | 
				
			||||||
 | 
					  web_socket:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: web_socket
 | 
				
			||||||
 | 
					      sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.0.1"
 | 
				
			||||||
 | 
					  web_socket_channel:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: web_socket_channel
 | 
				
			||||||
 | 
					      sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.0.3"
 | 
				
			||||||
 | 
					  webkit_inspection_protocol:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: webkit_inspection_protocol
 | 
				
			||||||
 | 
					      sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "1.2.1"
 | 
				
			||||||
 | 
					  yaml:
 | 
				
			||||||
 | 
					    dependency: transitive
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: yaml
 | 
				
			||||||
 | 
					      sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "3.1.3"
 | 
				
			||||||
 | 
					sdks:
 | 
				
			||||||
 | 
					  dart: ">=3.7.3 <4.0.0"
 | 
				
			||||||
							
								
								
									
										19
									
								
								pubspec.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								pubspec.yaml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					name: xp_nix
 | 
				
			||||||
 | 
					description: A sample command-line application.
 | 
				
			||||||
 | 
					version: 1.0.0
 | 
				
			||||||
 | 
					# repository: https://github.com/my_org/my_repo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					environment:
 | 
				
			||||||
 | 
					  sdk: ^3.7.3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Add regular dependencies here.
 | 
				
			||||||
 | 
					dependencies:
 | 
				
			||||||
 | 
					  sqlite3: ^2.7.6
 | 
				
			||||||
 | 
					  shelf: ^1.4.1
 | 
				
			||||||
 | 
					  shelf_router: ^1.1.4
 | 
				
			||||||
 | 
					  shelf_static: ^1.1.2
 | 
				
			||||||
 | 
					  # path: ^1.8.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					dev_dependencies:
 | 
				
			||||||
 | 
					  lints: ^5.0.0
 | 
				
			||||||
 | 
					  test: ^1.24.0
 | 
				
			||||||
							
								
								
									
										203
									
								
								test/deep_idle_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								test/deep_idle_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,203 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					import 'package:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/monitors/productivity_monitor.dart';
 | 
				
			||||||
 | 
					import '../lib/src/testing/mock_idle_monitor.dart';
 | 
				
			||||||
 | 
					import '../lib/src/testing/mock_activity_detector.dart';
 | 
				
			||||||
 | 
					import '../lib/src/testing/mock_time_provider.dart';
 | 
				
			||||||
 | 
					import '../lib/src/testing/mock_desktop_enhancer.dart';
 | 
				
			||||||
 | 
					import '../lib/src/config/config_manager.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  group('Deep Idle Event Tests', () {
 | 
				
			||||||
 | 
					    late Database db;
 | 
				
			||||||
 | 
					    late ProductivityMonitor monitor;
 | 
				
			||||||
 | 
					    late MockIdleMonitor mockIdleMonitor;
 | 
				
			||||||
 | 
					    late MockActivityDetector mockActivityDetector;
 | 
				
			||||||
 | 
					    late MockTimeProvider mockTimeProvider;
 | 
				
			||||||
 | 
					    late MockDesktopEnhancer mockDesktopEnhancer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setUp(() async {
 | 
				
			||||||
 | 
					      // Create in-memory database for testing
 | 
				
			||||||
 | 
					      db = sqlite3.openInMemory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Reset and initialize ConfigManager with default config
 | 
				
			||||||
 | 
					      ConfigManager.resetInstance();
 | 
				
			||||||
 | 
					      await ConfigManager.instance.initialize('/tmp/test_config_${DateTime.now().millisecondsSinceEpoch}.json');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create mock dependencies
 | 
				
			||||||
 | 
					      mockIdleMonitor = MockIdleMonitor();
 | 
				
			||||||
 | 
					      mockActivityDetector = MockActivityDetector();
 | 
				
			||||||
 | 
					      mockTimeProvider = MockTimeProvider();
 | 
				
			||||||
 | 
					      mockDesktopEnhancer = MockDesktopEnhancer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Set up starting time (Monday 9 AM)
 | 
				
			||||||
 | 
					      mockTimeProvider.setTime(DateTime(2024, 1, 1, 9, 0));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create testable monitor with mocked dependencies
 | 
				
			||||||
 | 
					      monitor = ProductivityMonitor(
 | 
				
			||||||
 | 
					        db: db,
 | 
				
			||||||
 | 
					        idleMonitor: mockIdleMonitor,
 | 
				
			||||||
 | 
					        activityDetector: mockActivityDetector,
 | 
				
			||||||
 | 
					        timeProvider: mockTimeProvider,
 | 
				
			||||||
 | 
					        desktopEnhancer: mockDesktopEnhancer,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tearDown(() async {
 | 
				
			||||||
 | 
					      monitor.stop();
 | 
				
			||||||
 | 
					      // Add a small delay to allow async operations to complete
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 100));
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        db.dispose();
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        // Database might already be closed, ignore the error
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('should end current activity and award XP when user goes deep idle', () async {
 | 
				
			||||||
 | 
					      // Start the monitor
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🧪 Testing deep idle behavior...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Start a coding activity
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'main.dart - TestProject');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Work for 30 minutes
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Check that no activity has been saved yet (still in progress)
 | 
				
			||||||
 | 
					      var stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var initialXP = stats['xp'] as int;
 | 
				
			||||||
 | 
					      print('📊 Initial XP before deep idle: $initialXP');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // User goes deep idle - this should trigger ending the current activity
 | 
				
			||||||
 | 
					      print('😴 User goes deep idle...');
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateDeepIdle();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 50)); // Allow event processing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check that the activity was saved and XP was awarded
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var finalXP = stats['xp'] as int;
 | 
				
			||||||
 | 
					      var focusTime = stats['focus_time'] as int;
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      print('📊 Final XP after deep idle: $finalXP');
 | 
				
			||||||
 | 
					      print('📊 Focus time: ${(focusTime / 60).toStringAsFixed(1)} minutes');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify that XP was awarded for the 30-minute coding session
 | 
				
			||||||
 | 
					      expect(finalXP, greaterThan(initialXP), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned XP from the coding session when going deep idle');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Expected: 30 minutes * 10 XP/min = 300 XP (base, before multipliers)
 | 
				
			||||||
 | 
					      expect(finalXP, greaterThan(200), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned substantial XP from 30 minutes of coding');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Should have focus time from coding
 | 
				
			||||||
 | 
					      expect(focusTime, greaterThan(25 * 60), 
 | 
				
			||||||
 | 
					        reason: 'Should have at least 25 minutes of focus time recorded');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Deep idle activity ending test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('should not duplicate XP when going from light idle to deep idle', () async {
 | 
				
			||||||
 | 
					      // Start the monitor
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🧪 Testing light idle to deep idle transition...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Start a coding activity
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'feature.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Work for 20 minutes
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 20));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // User goes light idle first
 | 
				
			||||||
 | 
					      print('😐 User goes light idle...');
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateLightIdle();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 50));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      var stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var xpAfterLightIdle = stats['xp'] as int;
 | 
				
			||||||
 | 
					      print('📊 XP after light idle: $xpAfterLightIdle');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Then user goes deep idle
 | 
				
			||||||
 | 
					      print('😴 User goes deep idle...');
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateDeepIdle();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 50));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var xpAfterDeepIdle = stats['xp'] as int;
 | 
				
			||||||
 | 
					      print('📊 XP after deep idle: $xpAfterDeepIdle');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // XP should have been awarded when going deep idle, but not duplicated
 | 
				
			||||||
 | 
					      expect(xpAfterDeepIdle, greaterThan(0), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned XP from the coding session');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // The XP should be the same whether we went through light idle or not
 | 
				
			||||||
 | 
					      // (since the activity should only be saved once when going deep idle)
 | 
				
			||||||
 | 
					      expect(xpAfterDeepIdle, greaterThan(150), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned XP for 20 minutes of coding');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Light idle to deep idle transition test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('should handle multiple activity sessions with deep idle interruptions', () async {
 | 
				
			||||||
 | 
					      // Start the monitor
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🧪 Testing multiple sessions with deep idle interruptions...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // First coding session
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'session1.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 25));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Go deep idle (should end first session)
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateDeepIdle();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 50));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      var stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var xpAfterFirstSession = stats['xp'] as int;
 | 
				
			||||||
 | 
					      print('📊 XP after first session: $xpAfterFirstSession');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // User becomes active again
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Second coding session
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'session2.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 35));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Go deep idle again (should end second session)
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateDeepIdle();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 50));
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      var finalXP = stats['xp'] as int;
 | 
				
			||||||
 | 
					      var totalFocusTime = stats['focus_time'] as int;
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      print('📊 Final XP after both sessions: $finalXP');
 | 
				
			||||||
 | 
					      print('📊 Total focus time: ${(totalFocusTime / 60).toStringAsFixed(1)} minutes');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Should have XP from both sessions
 | 
				
			||||||
 | 
					      expect(finalXP, greaterThan(xpAfterFirstSession), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned additional XP from second session');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Should have substantial XP from 60 minutes total (25 + 35)
 | 
				
			||||||
 | 
					      expect(finalXP, greaterThan(400), 
 | 
				
			||||||
 | 
					        reason: 'Should have earned substantial XP from both coding sessions');
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Should have focus time from both sessions (at least 55 minutes)
 | 
				
			||||||
 | 
					      expect(totalFocusTime, greaterThan(55 * 60), 
 | 
				
			||||||
 | 
					        reason: 'Should have focus time from both sessions');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Multiple sessions with deep idle test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										617
									
								
								test/hyprland_config_parser_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										617
									
								
								test/hyprland_config_parser_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,617 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/config/hyprland_config_parser.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  group('HyprlandConfigParser', () {
 | 
				
			||||||
 | 
					    group('parseConfig', () {
 | 
				
			||||||
 | 
					      test('should parse basic config with decoration and general sections', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					# Basic Hyprland config
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					\$mod = SUPER
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 2
 | 
				
			||||||
 | 
					    size = 8
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  shadow {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    range = 15
 | 
				
			||||||
 | 
					    render_power = 3
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff)
 | 
				
			||||||
 | 
					  col.inactive_border = rgba(595959aa)
 | 
				
			||||||
 | 
					  gaps_in = 5
 | 
				
			||||||
 | 
					  gaps_out = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					animation=fade, 1, 4, default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					bind = \$mod, Q, killactive
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('exec-once = waybar'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('\$mod = SUPER'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('bind = \$mod, Q, killactive'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isNot(contains('decoration {')));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isNot(contains('general {')));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isNot(contains('animation=')));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.dynamicSections, hasLength(2));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('rounding = 10'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('blur {'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('border_size = 2'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.animations, hasLength(2));
 | 
				
			||||||
 | 
					        expect(result.animations[0], equals('animation=windows, 1, 7, default'));
 | 
				
			||||||
 | 
					        expect(result.animations[1], equals('animation=fade, 1, 4, default'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle nested sections within decoration', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 15
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 3
 | 
				
			||||||
 | 
					    size = 12
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					  drop_shadow = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('blur {'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('enabled = true'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('passes = 3'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('vibrancy_darkness = 0.2'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('shadow {'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('color = rgba(7e5fddaa)'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('dim_inactive = true'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle complex general section with gradient borders', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					  allow_tearing = false
 | 
				
			||||||
 | 
					  layout = dwindle
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('border_size = 4'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('rgba(7e5fddff) rgba(ff5100ff) rgba(00ff88ff) 45deg'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('resize_on_border = true'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('extend_border_grab_area = 15'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should preserve non-dynamic sections in base config', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					input {
 | 
				
			||||||
 | 
					  kb_layout = us
 | 
				
			||||||
 | 
					  follow_mouse = 1
 | 
				
			||||||
 | 
					  touchpad {
 | 
				
			||||||
 | 
					    natural_scroll = true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					misc {
 | 
				
			||||||
 | 
					  disable_hyprland_logo = true
 | 
				
			||||||
 | 
					  force_default_wallpaper = 0
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 5
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					windowrule = float, ^(pavucontrol)\$
 | 
				
			||||||
 | 
					windowrule = workspace 2, ^(firefox)\$
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('input {'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('kb_layout = us'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('touchpad {'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('misc {'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('windowrule = float'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isNot(contains('decoration {')));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('rounding = 5'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle config with comments and empty lines', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					# This is a comment
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Decoration settings
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  # Rounded corners
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  # Blur settings
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 2
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# General settings
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  # Active border color
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Animation settings
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('# This is a comment'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('exec-once = waybar'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('# Rounded corners'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['decoration'], contains('# Blur settings'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('# Active border color'));
 | 
				
			||||||
 | 
					        expect(result.animations[0], equals('animation=windows, 1, 7, default'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle config with no dynamic sections', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					\$mod = SUPER
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					input {
 | 
				
			||||||
 | 
					  kb_layout = us
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					bind = \$mod, Q, killactive
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('exec-once = waybar'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('input {'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('bind = \$mod, Q, killactive'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections, isEmpty);
 | 
				
			||||||
 | 
					        expect(result.animations, hasLength(1));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle inline animations in base config', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					animation=fade, 1, 4, default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					animation=workspaces, 1, 6, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.animations, hasLength(3));
 | 
				
			||||||
 | 
					        expect(result.animations, contains('animation=windows, 1, 7, default'));
 | 
				
			||||||
 | 
					        expect(result.animations, contains('animation=fade, 1, 4, default'));
 | 
				
			||||||
 | 
					        expect(result.animations, contains('animation=workspaces, 1, 6, default'));
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isNot(contains('animation=')));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    group('validateConfig', () {
 | 
				
			||||||
 | 
					      test('should validate complete config as valid', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 2
 | 
				
			||||||
 | 
					    size = 8
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  shadow {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    range = 15
 | 
				
			||||||
 | 
					    render_power = 3
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff)
 | 
				
			||||||
 | 
					  col.inactive_border = rgba(595959aa)
 | 
				
			||||||
 | 
					  gaps_in = 5
 | 
				
			||||||
 | 
					  gaps_out = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.validateConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.isValid, isTrue);
 | 
				
			||||||
 | 
					        expect(result.issues, isEmpty);
 | 
				
			||||||
 | 
					        expect(result.foundSections, containsAll(['decoration', 'general']));
 | 
				
			||||||
 | 
					        expect(result.hasAnimations, isTrue);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should identify missing decoration properties', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  # Missing blur and shadow sections
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff)
 | 
				
			||||||
 | 
					  col.inactive_border = rgba(595959aa)
 | 
				
			||||||
 | 
					  gaps_in = 5
 | 
				
			||||||
 | 
					  gaps_out = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.validateConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.isValid, isFalse);
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing decoration property: blur'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing decoration property: shadow'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('No animation definitions found'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should identify missing blur sub-properties', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    # Missing passes and size
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  shadow {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    range = 15
 | 
				
			||||||
 | 
					    render_power = 3
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.validateConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.isValid, isFalse);
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing blur property: passes'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing blur property: size'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should identify missing general properties', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  # Missing color and gap properties
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.validateConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.isValid, isFalse);
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing general property: col.active_border'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing general property: col.inactive_border'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing general property: gaps_in'));
 | 
				
			||||||
 | 
					        expect(result.issues, contains('Missing general property: gaps_out'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should accept inline animations in base config', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 2
 | 
				
			||||||
 | 
					    size = 8
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  shadow {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    range = 15
 | 
				
			||||||
 | 
					    render_power = 3
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff)
 | 
				
			||||||
 | 
					  col.inactive_border = rgba(595959aa)
 | 
				
			||||||
 | 
					  gaps_in = 5
 | 
				
			||||||
 | 
					  gaps_out = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.validateConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.isValid, isTrue);
 | 
				
			||||||
 | 
					        expect(result.hasAnimations, isTrue);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    group('extractStylingProperties', () {
 | 
				
			||||||
 | 
					      test('should extract all styling properties from complete config', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 15
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 3
 | 
				
			||||||
 | 
					    size = 12
 | 
				
			||||||
 | 
					    brightness = 1.1
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  shadow {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    range = 20
 | 
				
			||||||
 | 
					    render_power = 4
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  dim_inactive = true
 | 
				
			||||||
 | 
					  active_opacity = 1.0
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 4
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff) rgba(ff5100ff) 45deg
 | 
				
			||||||
 | 
					  col.inactive_border = rgba(595959aa)
 | 
				
			||||||
 | 
					  gaps_in = 4
 | 
				
			||||||
 | 
					  gaps_out = 8
 | 
				
			||||||
 | 
					  resize_on_border = true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					animation=windows, 1, 8, easeout, slide
 | 
				
			||||||
 | 
					animation=fade, 1, 7, easeout
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final styling = HyprlandConfigParser.extractStylingProperties(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(styling['decoration'], isA<Map<String, dynamic>>());
 | 
				
			||||||
 | 
					        expect(styling['general'], isA<Map<String, dynamic>>());
 | 
				
			||||||
 | 
					        expect(styling['animations'], isA<List<String>>());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final decoration = styling['decoration'] as Map<String, dynamic>;
 | 
				
			||||||
 | 
					        expect(decoration['rounding'], equals('15'));
 | 
				
			||||||
 | 
					        expect(decoration['dim_inactive'], equals('true'));
 | 
				
			||||||
 | 
					        expect(decoration['active_opacity'], equals('1.0'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final general = styling['general'] as Map<String, dynamic>;
 | 
				
			||||||
 | 
					        expect(general['border_size'], equals('4'));
 | 
				
			||||||
 | 
					        expect(general['col.active_border'], equals('rgba(7e5fddff) rgba(ff5100ff) 45deg'));
 | 
				
			||||||
 | 
					        expect(general['resize_on_border'], equals('true'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final animations = styling['animations'] as List<String>;
 | 
				
			||||||
 | 
					        expect(animations, hasLength(2));
 | 
				
			||||||
 | 
					        expect(animations, contains('animation=windows, 1, 8, easeout, slide'));
 | 
				
			||||||
 | 
					        expect(animations, contains('animation=fade, 1, 7, easeout'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle nested properties correctly', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					    passes = 2
 | 
				
			||||||
 | 
					    size = 8
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final styling = HyprlandConfigParser.extractStylingProperties(config);
 | 
				
			||||||
 | 
					        final decoration = styling['decoration'] as Map<String, dynamic>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Note: The current implementation extracts nested properties as flat key-value pairs
 | 
				
			||||||
 | 
					        // This is a limitation but matches the current parsing behavior
 | 
				
			||||||
 | 
					        expect(decoration['enabled'], equals('true'));
 | 
				
			||||||
 | 
					        expect(decoration['passes'], equals('2'));
 | 
				
			||||||
 | 
					        expect(decoration['brightness'], equals('1.05'));
 | 
				
			||||||
 | 
					        expect(decoration['vibrancy'], equals('0.2'));
 | 
				
			||||||
 | 
					        expect(decoration['color'], equals('rgba(7e5fdd88)'));
 | 
				
			||||||
 | 
					        expect(decoration['offset'], equals('0 0'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should extract inline animations from base config', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					bind = \$mod, Q, killactive
 | 
				
			||||||
 | 
					animation=fade, 1, 4, default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final styling = HyprlandConfigParser.extractStylingProperties(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(styling['animations'], isA<List<String>>());
 | 
				
			||||||
 | 
					        final animations = styling['animations'] as List<String>;
 | 
				
			||||||
 | 
					        expect(animations, hasLength(2));
 | 
				
			||||||
 | 
					        expect(animations, contains('animation=windows, 1, 7, default'));
 | 
				
			||||||
 | 
					        expect(animations, contains('animation=fade, 1, 4, default'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle config with only some styling sections', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 8
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# No general section
 | 
				
			||||||
 | 
					animation=workspaces, 1, 6, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final styling = HyprlandConfigParser.extractStylingProperties(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(styling['decoration'], isA<Map<String, dynamic>>());
 | 
				
			||||||
 | 
					        expect(styling['general'], isNull);
 | 
				
			||||||
 | 
					        expect(styling['animations'], isA<List<String>>());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final decoration = styling['decoration'] as Map<String, dynamic>;
 | 
				
			||||||
 | 
					        expect(decoration['rounding'], equals('8'));
 | 
				
			||||||
 | 
					        expect(decoration['enabled'], equals('false'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    group('buildFullConfig', () {
 | 
				
			||||||
 | 
					      test('should reconstruct config with dynamic sections', () {
 | 
				
			||||||
 | 
					        const baseConfig = '''
 | 
				
			||||||
 | 
					exec-once = waybar
 | 
				
			||||||
 | 
					\$mod = SUPER
 | 
				
			||||||
 | 
					bind = \$mod, Q, killactive
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const decorationConfig = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  blur {
 | 
				
			||||||
 | 
					    enabled = true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const generalConfig = '''
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					  gaps_in = 5
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const animationConfig = '''
 | 
				
			||||||
 | 
					animation=windows, 1, 7, default
 | 
				
			||||||
 | 
					animation=fade, 1, 4, default
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final config = HyprlandConfig(baseConfig: baseConfig, dynamicSections: {}, animations: []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final fullConfig = config.buildFullConfig(
 | 
				
			||||||
 | 
					          decorationConfig: decorationConfig,
 | 
				
			||||||
 | 
					          generalConfig: generalConfig,
 | 
				
			||||||
 | 
					          animationConfig: animationConfig,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('exec-once = waybar'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('\$mod = SUPER'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('decoration {'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('rounding = 10'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('general {'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('border_size = 2'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('animation=windows, 1, 7, default'));
 | 
				
			||||||
 | 
					        expect(fullConfig, contains('animation=fade, 1, 4, default'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    group('edge cases', () {
 | 
				
			||||||
 | 
					      test('should handle empty config', () {
 | 
				
			||||||
 | 
					        const config = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, isEmpty);
 | 
				
			||||||
 | 
					        expect(result.dynamicSections, isEmpty);
 | 
				
			||||||
 | 
					        expect(result.animations, isEmpty);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle config with only comments', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					# This is a comment
 | 
				
			||||||
 | 
					# Another comment
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(result.baseConfig, contains('# This is a comment'));
 | 
				
			||||||
 | 
					        expect(result.dynamicSections, isEmpty);
 | 
				
			||||||
 | 
					        expect(result.animations, isEmpty);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle malformed sections gracefully', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					decoration {
 | 
				
			||||||
 | 
					  rounding = 10
 | 
				
			||||||
 | 
					  # Missing closing brace
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  border_size = 2
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final result = HyprlandConfigParser.parseConfig(config);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Should still parse what it can
 | 
				
			||||||
 | 
					        expect(result.dynamicSections['general'], contains('border_size = 2'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      test('should handle properties with equals signs in values', () {
 | 
				
			||||||
 | 
					        const config = '''
 | 
				
			||||||
 | 
					general {
 | 
				
			||||||
 | 
					  col.active_border = rgba(7e5fddff) rgba(ff5100ff) rgba(00ff88ff) 45deg
 | 
				
			||||||
 | 
					  custom_prop = value=with=equals
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					''';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final styling = HyprlandConfigParser.extractStylingProperties(config);
 | 
				
			||||||
 | 
					        final general = styling['general'] as Map<String, dynamic>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(general['col.active_border'], equals('rgba(7e5fddff) rgba(ff5100ff) rgba(00ff88ff) 45deg'));
 | 
				
			||||||
 | 
					        expect(general['custom_prop'], equals('value=with=equals'));
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										120
									
								
								test/simulation/consolidation_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								test/simulation/consolidation_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,120 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					import 'package:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/config/config_manager.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/monitors/productivity_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_activity_detector.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_desktop_enhancer.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_idle_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_time_provider.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  group('Consolidation Tests', () {
 | 
				
			||||||
 | 
					    late Database db;
 | 
				
			||||||
 | 
					    late ProductivityMonitor monitor;
 | 
				
			||||||
 | 
					    late MockIdleMonitor mockIdleMonitor;
 | 
				
			||||||
 | 
					    late MockActivityDetector mockActivityDetector;
 | 
				
			||||||
 | 
					    late MockTimeProvider mockTimeProvider;
 | 
				
			||||||
 | 
					    late MockDesktopEnhancer mockDesktopEnhancer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setUp(() async {
 | 
				
			||||||
 | 
					      // Create in-memory database for testing
 | 
				
			||||||
 | 
					      db = sqlite3.openInMemory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Reset and initialize ConfigManager with default config
 | 
				
			||||||
 | 
					      ConfigManager.resetInstance();
 | 
				
			||||||
 | 
					      await ConfigManager.instance.initialize('/tmp/test_config_${DateTime.now().millisecondsSinceEpoch}.json');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create mock dependencies
 | 
				
			||||||
 | 
					      mockIdleMonitor = MockIdleMonitor();
 | 
				
			||||||
 | 
					      mockActivityDetector = MockActivityDetector();
 | 
				
			||||||
 | 
					      mockTimeProvider = MockTimeProvider();
 | 
				
			||||||
 | 
					      mockDesktopEnhancer = MockDesktopEnhancer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Set up starting time
 | 
				
			||||||
 | 
					      mockTimeProvider.setTime(DateTime(2024, 1, 1, 9, 0));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create monitor with mocked dependencies
 | 
				
			||||||
 | 
					      monitor = ProductivityMonitor(
 | 
				
			||||||
 | 
					        db: db,
 | 
				
			||||||
 | 
					        idleMonitor: mockIdleMonitor,
 | 
				
			||||||
 | 
					        timeProvider: mockTimeProvider,
 | 
				
			||||||
 | 
					        desktopEnhancer: mockDesktopEnhancer,
 | 
				
			||||||
 | 
					        activityDetector: mockActivityDetector,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tearDown(() async {
 | 
				
			||||||
 | 
					      monitor.stop();
 | 
				
			||||||
 | 
					      // Add a small delay to allow async operations to complete
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 100));
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        db.dispose();
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        // Database might already be closed, ignore the error
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('unified monitor can be created and started', () async {
 | 
				
			||||||
 | 
					      expect(() => monitor.start(), returnsNormally);
 | 
				
			||||||
 | 
					      expect(monitor.getTodayStats(), isA<Map<String, dynamic>>());
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('unified monitor processes activities correctly', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate coding activity
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'test.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10)); // Allow event processing
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivityForced();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(0));
 | 
				
			||||||
 | 
					      expect(stats['focus_time'], greaterThan(0));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('unified monitor handles level ups', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate enough activity to level up
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'big_project.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10)); // Allow event processing
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(hours: 1));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivityForced();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Manually trigger level up check
 | 
				
			||||||
 | 
					      await monitor.checkForLevelUpNow();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['level'], greaterThanOrEqualTo(2));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify desktop enhancer was called
 | 
				
			||||||
 | 
					      print('Desktop enhancer operations: ${mockDesktopEnhancer.operations}');
 | 
				
			||||||
 | 
					      expect(mockDesktopEnhancer.operations, isNotEmpty);
 | 
				
			||||||
 | 
					      expect(mockDesktopEnhancer.operations.any((op) => op.contains('celebrateLevelUp')), isTrue);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('unified monitor handles idle detection', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Start active
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive();
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'work.dart');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Go idle
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateIdle();
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 90));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Come back active
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['focus_sessions'], greaterThan(0));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										187
									
								
								test/simulation/simple_simulation_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								test/simulation/simple_simulation_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,187 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					import 'package:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/monitors/productivity_monitor.dart';
 | 
				
			||||||
 | 
					import '../../lib/src/testing/mock_idle_monitor.dart';
 | 
				
			||||||
 | 
					import '../../lib/src/testing/mock_activity_detector.dart';
 | 
				
			||||||
 | 
					import '../../lib/src/testing/mock_time_provider.dart';
 | 
				
			||||||
 | 
					import '../../lib/src/testing/mock_desktop_enhancer.dart';
 | 
				
			||||||
 | 
					import '../../lib/src/config/config_manager.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  group('Simple Productivity Simulation Tests', () {
 | 
				
			||||||
 | 
					    late Database db;
 | 
				
			||||||
 | 
					    late ProductivityMonitor monitor;
 | 
				
			||||||
 | 
					    late MockIdleMonitor mockIdleMonitor;
 | 
				
			||||||
 | 
					    late MockActivityDetector mockActivityDetector;
 | 
				
			||||||
 | 
					    late MockTimeProvider mockTimeProvider;
 | 
				
			||||||
 | 
					    late MockDesktopEnhancer mockDesktopEnhancer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setUp(() async {
 | 
				
			||||||
 | 
					      // Create in-memory database for testing
 | 
				
			||||||
 | 
					      db = sqlite3.openInMemory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Reset and initialize ConfigManager with default config
 | 
				
			||||||
 | 
					      ConfigManager.resetInstance();
 | 
				
			||||||
 | 
					      await ConfigManager.instance.initialize('/tmp/test_config_${DateTime.now().millisecondsSinceEpoch}.json');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create mock dependencies
 | 
				
			||||||
 | 
					      mockIdleMonitor = MockIdleMonitor();
 | 
				
			||||||
 | 
					      mockActivityDetector = MockActivityDetector();
 | 
				
			||||||
 | 
					      mockTimeProvider = MockTimeProvider();
 | 
				
			||||||
 | 
					      mockDesktopEnhancer = MockDesktopEnhancer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Set up starting time (Monday 9 AM)
 | 
				
			||||||
 | 
					      mockTimeProvider.setTime(DateTime(2024, 1, 1, 9, 0));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create testable monitor with mocked dependencies
 | 
				
			||||||
 | 
					      monitor = ProductivityMonitor(
 | 
				
			||||||
 | 
					        db: db,
 | 
				
			||||||
 | 
					        idleMonitor: mockIdleMonitor,
 | 
				
			||||||
 | 
					        activityDetector: mockActivityDetector,
 | 
				
			||||||
 | 
					        timeProvider: mockTimeProvider,
 | 
				
			||||||
 | 
					        desktopEnhancer: mockDesktopEnhancer,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tearDown(() async {
 | 
				
			||||||
 | 
					      monitor.stop();
 | 
				
			||||||
 | 
					      // Add a small delay to allow async operations to complete
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 100));
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        db.dispose();
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        // Database might already be closed, ignore the error
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('simulates basic coding session with XP calculation', () async {
 | 
				
			||||||
 | 
					      // Start the monitor
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n💻 Starting coding session...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate 1 hour of coding
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'main.dart - TestProject');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 1)); // Allow event to be processed
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 60));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check stats
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      print(
 | 
				
			||||||
 | 
					        '📊 Stats: ${stats['xp']} XP, Level ${stats['level']}, ${(stats['focus_time'] / 3600).toStringAsFixed(1)}h focus',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify XP was earned
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(0), reason: 'Should have earned XP from coding');
 | 
				
			||||||
 | 
					      expect(stats['focus_time'], greaterThan(0), reason: 'Should have focus time from coding');
 | 
				
			||||||
 | 
					      expect(stats['level'], greaterThanOrEqualTo(1), reason: 'Should have at least level 1');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Expected: 60 minutes * 10 XP/min = 600 XP (plus any time multipliers)
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(500), reason: 'Should have substantial XP from 1 hour coding');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Basic coding session test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('simulates mixed activity day', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🔄 Simulating mixed activity day...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Morning coding (2 hours)
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'feature.dart');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 1));
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 120));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Meeting (1 hour)
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('zoom', 'Team Meeting');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 1));
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 60));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Research (30 minutes)
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('firefox', 'Documentation - dart.dev');
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 1));
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      print('📊 Mixed day stats: ${stats['xp']} XP, Level ${stats['level']}');
 | 
				
			||||||
 | 
					      print(
 | 
				
			||||||
 | 
					        '   Focus: ${(stats['focus_time'] / 3600).toStringAsFixed(1)}h, Meetings: ${(stats['meeting_time'] / 3600).toStringAsFixed(1)}h',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify different activity types
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(1000), reason: 'Should have substantial XP from mixed activities');
 | 
				
			||||||
 | 
					      expect(stats['focus_time'], greaterThan(0), reason: 'Should have focus time from coding and research');
 | 
				
			||||||
 | 
					      expect(stats['meeting_time'], greaterThan(0), reason: 'Should have meeting time from zoom');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Mixed activity day test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('demonstrates XP calculation accuracy', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🧮 Testing XP calculation accuracy...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Test different activity types with known durations
 | 
				
			||||||
 | 
					      final activities = [
 | 
				
			||||||
 | 
					        ('vscode', 'coding', 30, 10), // 30 min coding at 10 XP/min = 300 XP
 | 
				
			||||||
 | 
					        ('firefox', 'research', 20, 6), // 20 min research at 6 XP/min = 120 XP
 | 
				
			||||||
 | 
					        ('slack', 'collaboration', 15, 7), // 15 min collaboration at 7 XP/min = 105 XP
 | 
				
			||||||
 | 
					        ('zoom', 'meeting', 60, 3), // 60 min meeting at 3 XP/min = 180 XP
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      int expectedTotalXP = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (final (app, description, minutes, xpPerMin) in activities) {
 | 
				
			||||||
 | 
					        mockActivityDetector.simulateActivity(app, description);
 | 
				
			||||||
 | 
					        await Future.delayed(Duration(milliseconds: 1));
 | 
				
			||||||
 | 
					        mockTimeProvider.advanceTime(Duration(minutes: minutes));
 | 
				
			||||||
 | 
					        monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expectedTotalXP += minutes * xpPerMin;
 | 
				
			||||||
 | 
					        print('   $description: $minutes min × $xpPerMin XP/min = ${minutes * xpPerMin} XP');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      final actualXP = stats['xp'] as int;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('📊 Expected: ~$expectedTotalXP XP, Actual: $actualXP XP');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Allow for time multipliers (should be close to expected)
 | 
				
			||||||
 | 
					      expect(actualXP, greaterThan(expectedTotalXP * 0.8), reason: 'XP should be reasonably close to expected');
 | 
				
			||||||
 | 
					      expect(actualXP, lessThan(expectedTotalXP * 2.0), reason: 'XP should not be wildly inflated');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ XP calculation accuracy test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('verifies level progression', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n📈 Testing level progression...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate enough activity to level up (need 100+ XP per level)
 | 
				
			||||||
 | 
					      for (int i = 0; i < 5; i++) {
 | 
				
			||||||
 | 
					        mockActivityDetector.simulateActivity('vscode', 'level_up_test_$i.dart');
 | 
				
			||||||
 | 
					        await Future.delayed(Duration(milliseconds: 1));
 | 
				
			||||||
 | 
					        mockTimeProvider.advanceTime(Duration(minutes: 30)); // 30 min * 10 XP/min = 300 XP
 | 
				
			||||||
 | 
					        monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					        print('   Session ${i + 1}: ${stats['xp']} XP, Level ${stats['level']}');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final finalStats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(finalStats['level'], greaterThan(1), reason: 'Should have leveled up');
 | 
				
			||||||
 | 
					      expect(finalStats['xp'], greaterThan(1000), reason: 'Should have substantial XP');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('✅ Level progression test passed!');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										267
									
								
								test/simulation/work_day_simulation_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								test/simulation/work_day_simulation_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,267 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					import 'package:sqlite3/sqlite3.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/monitors/productivity_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_idle_monitor.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_activity_detector.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_time_provider.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/testing/mock_desktop_enhancer.dart';
 | 
				
			||||||
 | 
					import 'package:xp_nix/src/config/config_manager.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  group('Work Day Simulation Tests', () {
 | 
				
			||||||
 | 
					    late Database db;
 | 
				
			||||||
 | 
					    late ProductivityMonitor monitor;
 | 
				
			||||||
 | 
					    late MockIdleMonitor mockIdleMonitor;
 | 
				
			||||||
 | 
					    late MockActivityDetector mockActivityDetector;
 | 
				
			||||||
 | 
					    late MockTimeProvider mockTimeProvider;
 | 
				
			||||||
 | 
					    late MockDesktopEnhancer mockDesktopEnhancer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setUp(() async {
 | 
				
			||||||
 | 
					      // Create in-memory database for testing
 | 
				
			||||||
 | 
					      db = sqlite3.openInMemory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Reset and initialize ConfigManager with default config
 | 
				
			||||||
 | 
					      ConfigManager.resetInstance();
 | 
				
			||||||
 | 
					      await ConfigManager.instance.initialize('/tmp/test_config_${DateTime.now().millisecondsSinceEpoch}.json');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create mock dependencies
 | 
				
			||||||
 | 
					      mockIdleMonitor = MockIdleMonitor();
 | 
				
			||||||
 | 
					      mockActivityDetector = MockActivityDetector();
 | 
				
			||||||
 | 
					      mockTimeProvider = MockTimeProvider();
 | 
				
			||||||
 | 
					      mockDesktopEnhancer = MockDesktopEnhancer();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Set up starting time (Monday 9 AM)
 | 
				
			||||||
 | 
					      mockTimeProvider.setTime(DateTime(2024, 1, 1, 9, 0));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Create monitor with mocked dependencies
 | 
				
			||||||
 | 
					      monitor = ProductivityMonitor(
 | 
				
			||||||
 | 
					        db: db,
 | 
				
			||||||
 | 
					        idleMonitor: mockIdleMonitor,
 | 
				
			||||||
 | 
					        timeProvider: mockTimeProvider,
 | 
				
			||||||
 | 
					        desktopEnhancer: mockDesktopEnhancer,
 | 
				
			||||||
 | 
					        activityDetector: mockActivityDetector,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tearDown(() async {
 | 
				
			||||||
 | 
					      monitor.stop();
 | 
				
			||||||
 | 
					      // Add a small delay to allow async operations to complete
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 100));
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        db.dispose();
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        // Database might already be closed, ignore the error
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('simulates a full productive work day', () async {
 | 
				
			||||||
 | 
					      // Start the monitor
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Wait a moment for initialization
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === MORNING CODING SESSION (9:00 - 10:30) ===
 | 
				
			||||||
 | 
					      print('\n🌅 Starting morning coding session...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'main.dart - ProductivityApp');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 90));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity(); // Save the activity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate switching to a different file
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'database_manager.dart - ProductivityApp');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity(); // Save the activity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check stats after morning coding
 | 
				
			||||||
 | 
					      var stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(0), reason: 'Should have earned XP from coding');
 | 
				
			||||||
 | 
					      expect(stats['focus_time'], greaterThan(0), reason: 'Should have focus time from coding');
 | 
				
			||||||
 | 
					      print('📊 After morning coding: ${stats['xp']} XP, ${(stats['focus_time'] / 3600).toStringAsFixed(1)}h focus');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === BRIEF BREAK - CHECK SLACK (10:30 - 10:45) ===
 | 
				
			||||||
 | 
					      print('\n💬 Quick Slack check...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('slack', 'General Channel');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 15));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === RESEARCH SESSION (10:45 - 11:30) ===
 | 
				
			||||||
 | 
					      print('\n🔍 Research session...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('firefox', 'Dart Documentation - dart.dev');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 45));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === TEAM MEETING (11:30 - 12:30) ===
 | 
				
			||||||
 | 
					      print('\n📅 Team meeting...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('zoom', 'Weekly Team Standup');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 60));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Check stats after meeting
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['meeting_time'], greaterThan(0), reason: 'Should have meeting time');
 | 
				
			||||||
 | 
					      print('📊 After meeting: ${stats['xp']} XP, ${(stats['meeting_time'] / 3600).toStringAsFixed(1)}h meetings');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === LUNCH BREAK - GO IDLE (12:30 - 13:30) ===
 | 
				
			||||||
 | 
					      print('\n🍽️ Lunch break - going idle...');
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateIdle();
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 60));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Come back from lunch
 | 
				
			||||||
 | 
					      print('\n🔄 Back from lunch...');
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Should get focus session bonus for the morning work
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['focus_sessions'], greaterThan(0), reason: 'Should have focus sessions from idle recovery');
 | 
				
			||||||
 | 
					      print('📊 After lunch: ${stats['focus_sessions']} focus sessions completed');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === AFTERNOON CODING SPRINT (13:30 - 15:30) ===
 | 
				
			||||||
 | 
					      print('\n⚡ Afternoon coding sprint...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'test_suite.dart - ProductivityApp');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 120));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === DOCUMENTATION WORK (15:30 - 16:30) ===
 | 
				
			||||||
 | 
					      print('\n📝 Documentation work...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'README.md - ProductivityApp');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 60));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === END OF DAY WRAP-UP (16:30 - 17:00) ===
 | 
				
			||||||
 | 
					      print('\n📋 End of day wrap-up...');
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('slack', 'Daily Summary');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // === FINAL STATS VERIFICATION ===
 | 
				
			||||||
 | 
					      print('\n📈 Final day statistics:');
 | 
				
			||||||
 | 
					      stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      monitor.printDetailedStats();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify XP calculations
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(1000), reason: 'Should have substantial XP from full work day');
 | 
				
			||||||
 | 
					      expect(stats['level'], greaterThanOrEqualTo(2), reason: 'Should have leveled up');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify focus time (coding + research)
 | 
				
			||||||
 | 
					      final focusHours = stats['focus_time'] / 3600;
 | 
				
			||||||
 | 
					      expect(focusHours, greaterThan(4), reason: 'Should have 4+ hours of focus time');
 | 
				
			||||||
 | 
					      expect(focusHours, lessThan(6), reason: 'Focus time should be reasonable');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify meeting time
 | 
				
			||||||
 | 
					      final meetingHours = stats['meeting_time'] / 3600;
 | 
				
			||||||
 | 
					      expect(meetingHours, greaterThan(0.9), reason: 'Should have ~1 hour of meeting time');
 | 
				
			||||||
 | 
					      expect(meetingHours, lessThan(1.1), reason: 'Meeting time should be accurate');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Verify focus sessions
 | 
				
			||||||
 | 
					      expect(stats['focus_sessions'], greaterThanOrEqualTo(1), reason: 'Should have at least 1 focus session');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Test specific XP calculations
 | 
				
			||||||
 | 
					      _verifyXPCalculations(stats);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n✅ Work day simulation completed successfully!');
 | 
				
			||||||
 | 
					      print(
 | 
				
			||||||
 | 
					        '📊 Final Stats: Level ${stats['level']}, ${stats['xp']} XP, ${focusHours.toStringAsFixed(1)}h focus, ${stats['focus_sessions']} sessions',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('simulates rapid context switching day', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🔄 Simulating rapid context switching...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate a day with lots of short activities
 | 
				
			||||||
 | 
					      final activities = [
 | 
				
			||||||
 | 
					        ('vscode', 'main.dart', 15), // 15 min coding
 | 
				
			||||||
 | 
					        ('slack', 'General', 5), // 5 min slack
 | 
				
			||||||
 | 
					        ('firefox', 'Stack Overflow', 10), // 10 min research
 | 
				
			||||||
 | 
					        ('vscode', 'test.dart', 20), // 20 min coding
 | 
				
			||||||
 | 
					        ('zoom', 'Quick sync', 15), // 15 min meeting
 | 
				
			||||||
 | 
					        ('vscode', 'bug_fix.dart', 25), // 25 min coding
 | 
				
			||||||
 | 
					        ('slack', 'Code review', 10), // 10 min collaboration
 | 
				
			||||||
 | 
					      ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      for (final (app, title, minutes) in activities) {
 | 
				
			||||||
 | 
					        mockActivityDetector.simulateActivity(app, title);
 | 
				
			||||||
 | 
					        mockTimeProvider.advanceTime(Duration(minutes: minutes));
 | 
				
			||||||
 | 
					        monitor.flushCurrentActivity(); // Flush each activity
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Should still accumulate reasonable XP despite context switching
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(200), reason: 'Should earn XP from varied activities');
 | 
				
			||||||
 | 
					      expect(stats['focus_time'], greaterThan(0), reason: 'Should have some focus time');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('📊 Context switching day: ${stats['xp']} XP, ${(stats['focus_time'] / 3600).toStringAsFixed(1)}h focus');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('simulates achievement unlocking', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n🏆 Testing achievement unlocking...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Simulate extended coding to trigger achievements
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'epic_feature.dart');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Advance time to accumulate enough XP for level 5 (need ~500 XP)
 | 
				
			||||||
 | 
					      // 30 min * 10 XP/min = 300 XP per flush, need at least 2 flushes
 | 
				
			||||||
 | 
					      for (int i = 0; i < 3; i++) {
 | 
				
			||||||
 | 
					        mockTimeProvider.advanceTime(Duration(minutes: 60)); // 1 hour each
 | 
				
			||||||
 | 
					        monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					        await Future.delayed(Duration(milliseconds: 1)); // Allow level check
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['level'], greaterThanOrEqualTo(2), reason: 'Should reach at least level 2');
 | 
				
			||||||
 | 
					      expect(stats['xp'], greaterThan(500), reason: 'Should have substantial XP');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('📊 Achievement test: Level ${stats['level']}, ${stats['xp']} XP');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    test('verifies idle time handling', () async {
 | 
				
			||||||
 | 
					      monitor.start();
 | 
				
			||||||
 | 
					      await Future.delayed(Duration(milliseconds: 10));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('\n😴 Testing idle time handling...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Start with some activity and make sure user is active
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive(); // Ensure we start active
 | 
				
			||||||
 | 
					      mockActivityDetector.simulateActivity('vscode', 'work.dart');
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 30));
 | 
				
			||||||
 | 
					      monitor.flushCurrentActivity();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Go idle for extended period
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateIdle();
 | 
				
			||||||
 | 
					      mockTimeProvider.advanceTime(Duration(minutes: 90)); // 1.5 hours idle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Come back active - this should trigger focus session calculation
 | 
				
			||||||
 | 
					      mockIdleMonitor.simulateActive();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Should get focus session bonus
 | 
				
			||||||
 | 
					      final stats = monitor.getTodayStats();
 | 
				
			||||||
 | 
					      expect(stats['focus_sessions'], greaterThan(0), reason: 'Should award focus session after idle period');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      print('📊 Idle handling: ${stats['focus_sessions']} focus sessions awarded');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Verify that XP calculations are working correctly
 | 
				
			||||||
 | 
					void _verifyXPCalculations(Map<String, dynamic> stats) {
 | 
				
			||||||
 | 
					  print('\n🧮 Verifying XP calculations...');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Expected XP breakdown (approximate):
 | 
				
			||||||
 | 
					  // - Coding: ~4 hours * 60 min * 10 XP/min = ~2400 XP
 | 
				
			||||||
 | 
					  // - Research: ~45 min * 6 XP/min = ~270 XP
 | 
				
			||||||
 | 
					  // - Meetings: ~60 min * 3 XP/min = ~180 XP
 | 
				
			||||||
 | 
					  // - Collaboration: ~45 min * 7 XP/min = ~315 XP
 | 
				
			||||||
 | 
					  // - Focus session bonuses: varies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  final totalXP = stats['xp'] as int;
 | 
				
			||||||
 | 
					  expect(totalXP, greaterThan(2000), reason: 'Should have substantial XP from full day');
 | 
				
			||||||
 | 
					  expect(totalXP, lessThan(5000), reason: 'XP should be reasonable, not inflated');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  print('✅ XP calculations appear correct: $totalXP total XP');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										7
									
								
								test/xp_nix_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								test/xp_nix_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					import 'package:test/test.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					void main() {
 | 
				
			||||||
 | 
					  test('placeholder test', () {
 | 
				
			||||||
 | 
					    expect(1 + 1, equals(2));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user