From 8ea06b12f7f5fbb0aebd2cb61954496fc243ea85 Mon Sep 17 00:00:00 2001 From: Nate Anderson Date: Thu, 12 Jun 2025 11:36:30 -0600 Subject: [PATCH] Starting to go places. More testing in place, need to solidify the dashboard api boundary next --- .envrc | 1 + .gitignore | 16 + CHANGELOG.md | 3 + CLAUDE.md | 76 ++ README.md | 49 ++ ROADMAP.md | 94 +++ analysis_options.yaml | 30 + assets/levelup.mp3 | Bin 0 -> 22824 bytes bin/xp_nix.dart | 108 +++ config/xp_config.json | 1 + flake.lock | 61 ++ flake.nix | 26 + lib/src/config/config_manager.dart | 315 +++++++++ lib/src/config/hyprland_config_parser.dart | 343 +++++++++ lib/src/database/database_manager.dart | 460 ++++++++++++ .../detectors/hyprland_activity_detector.dart | 89 +++ lib/src/detectors/idle_monitor.dart | 72 ++ lib/src/detectors/zoom_detector.dart | 86 +++ lib/src/enhancers/hyprland_enhancer.dart | 447 ++++++++++++ lib/src/interfaces/i_activity_detector.dart | 17 + lib/src/interfaces/i_desktop_enhancer.dart | 17 + lib/src/interfaces/i_idle_monitor.dart | 18 + lib/src/interfaces/i_time_provider.dart | 29 + lib/src/logging/logger.dart | 254 +++++++ lib/src/models/activity_event.dart | 106 +++ lib/src/monitors/productivity_monitor.dart | 648 +++++++++++++++++ .../xp_notification_manager.dart | 303 ++++++++ lib/src/providers/system_time_provider.dart | 19 + lib/src/testing/mock_activity_detector.dart | 119 ++++ lib/src/testing/mock_desktop_enhancer.dart | 62 ++ lib/src/testing/mock_idle_monitor.dart | 73 ++ lib/src/testing/mock_time_provider.dart | 148 ++++ lib/src/web/dashboard_server.dart | 406 +++++++++++ lib/src/web/static/dashboard.js | 653 ++++++++++++++++++ lib/src/web/static/index.html | 159 +++++ lib/src/web/static/style.css | 579 ++++++++++++++++ pubspec.lock | 429 ++++++++++++ pubspec.yaml | 19 + test/deep_idle_test.dart | 203 ++++++ test/hyprland_config_parser_test.dart | 617 +++++++++++++++++ test/simulation/consolidation_test.dart | 120 ++++ test/simulation/simple_simulation_test.dart | 187 +++++ test/simulation/work_day_simulation_test.dart | 267 +++++++ test/xp_nix_test.dart | 7 + 44 files changed, 7736 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 analysis_options.yaml create mode 100644 assets/levelup.mp3 create mode 100644 bin/xp_nix.dart create mode 100644 config/xp_config.json create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lib/src/config/config_manager.dart create mode 100644 lib/src/config/hyprland_config_parser.dart create mode 100644 lib/src/database/database_manager.dart create mode 100644 lib/src/detectors/hyprland_activity_detector.dart create mode 100644 lib/src/detectors/idle_monitor.dart create mode 100644 lib/src/detectors/zoom_detector.dart create mode 100644 lib/src/enhancers/hyprland_enhancer.dart create mode 100644 lib/src/interfaces/i_activity_detector.dart create mode 100644 lib/src/interfaces/i_desktop_enhancer.dart create mode 100644 lib/src/interfaces/i_idle_monitor.dart create mode 100644 lib/src/interfaces/i_time_provider.dart create mode 100644 lib/src/logging/logger.dart create mode 100644 lib/src/models/activity_event.dart create mode 100644 lib/src/monitors/productivity_monitor.dart create mode 100644 lib/src/notifications/xp_notification_manager.dart create mode 100644 lib/src/providers/system_time_provider.dart create mode 100644 lib/src/testing/mock_activity_detector.dart create mode 100644 lib/src/testing/mock_desktop_enhancer.dart create mode 100644 lib/src/testing/mock_idle_monitor.dart create mode 100644 lib/src/testing/mock_time_provider.dart create mode 100644 lib/src/web/dashboard_server.dart create mode 100644 lib/src/web/static/dashboard.js create mode 100644 lib/src/web/static/index.html create mode 100644 lib/src/web/static/style.css create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/deep_idle_test.dart create mode 100644 test/hyprland_config_parser_test.dart create mode 100644 test/simulation/consolidation_test.dart create mode 100644 test/simulation/simple_simulation_test.dart create mode 100644 test/simulation/work_day_simulation_test.dart create mode 100644 test/xp_nix_test.dart diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9134fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.dart_tool/ + +# nix direnv +.direnv/** + +**/.claude/settings.local.json + +# logs +**/logs/** +*.log + +# sqlite file +*.db + +# binaries +*.exe diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..13c48cd --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ce5d90 --- /dev/null +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e94a3eb --- /dev/null +++ b/ROADMAP.md @@ -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 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/assets/levelup.mp3 b/assets/levelup.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..89ab56a98b14ca89f9125861ea68e40cfcbd2352 GIT binary patch literal 22824 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 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(); + } + } +} diff --git a/config/xp_config.json b/config/xp_config.json new file mode 100644 index 0000000..7fbc2c8 --- /dev/null +++ b/config/xp_config.json @@ -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"}} \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e0246ab --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4b93588 --- /dev/null +++ b/flake.nix @@ -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" + ''; + }; + }); +} diff --git a/lib/src/config/config_manager.dart b/lib/src/config/config_manager.dart new file mode 100644 index 0000000..d368c9d --- /dev/null +++ b/lib/src/config/config_manager.dart @@ -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? _config; + File? _configFile; + DateTime? _lastModified; + bool _isInitialized = false; + + Future 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 _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 _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 _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 get xpRewards { + _ensureInitialized(); + return _config!['xp_rewards'] ?? {}; + } + + Map get achievements { + _ensureInitialized(); + return _config!['achievements'] ?? {}; + } + + Map get levelSystem { + _ensureInitialized(); + return _config!['level_system'] ?? {}; + } + + Map get monitoring { + _ensureInitialized(); + return _config!['monitoring'] ?? {}; + } + + Map 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? ?? {}; + + 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 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 map, List path, dynamic value) { + if (path.length == 1) { + map[path[0]] = value; + return; + } + + final key = path[0]; + if (!map.containsKey(key) || map[key] is! Map) { + map[key] = {}; + } + + _setNestedValue(map[key], path.sublist(1), value); + } + + Future _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 getAllConfig() { + _ensureInitialized(); + return Map.from(_config!); + } +} diff --git a/lib/src/config/hyprland_config_parser.dart b/lib/src/config/hyprland_config_parser.dart new file mode 100644 index 0000000..a63e778 --- /dev/null +++ b/lib/src/config/hyprland_config_parser.dart @@ -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 dynamicSections; + final List 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 _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 = []; + final dynamicSections = {}; + final animations = []; + + bool inDynamicSection = false; + String currentSection = ''; + final currentSectionLines = []; + 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 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 = []; + final foundSections = []; + + // 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 _validateDecorationSection(String decorationContent) { + final issues = []; + + // 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 _validateGeneralSection(String generalContent) { + final issues = []; + 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 extractStylingProperties(String configContent) { + final config = parseConfig(configContent); + final styling = {}; + + // 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 = []; + 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 _extractDecorationProperties(String decorationContent) { + final properties = {}; + 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 _extractGeneralProperties(String generalContent) { + final properties = {}; + 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 issues; + final List 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(); + } +} diff --git a/lib/src/database/database_manager.dart b/lib/src/database/database_manager.dart new file mode 100644 index 0000000..72de86d --- /dev/null +++ b/lib/src/database/database_manager.dart @@ -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 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 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 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 getStatsHistory(int days) { + final history = []; + 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 getAllAchievements([int limit = 50]) { + return _db.select( + ''' + SELECT * FROM achievements + ORDER BY achieved_at DESC + LIMIT ? + ''', + [limit], + ); + } + + List getRecentActivities([int limit = 100]) { + return _db.select( + ''' + SELECT * FROM activity_events + ORDER BY timestamp DESC + LIMIT ? + ''', + [limit], + ); + } + + List getRecentFocusSessions([int limit = 50]) { + return _db.select( + ''' + SELECT * FROM focus_sessions + ORDER BY timestamp DESC + LIMIT ? + ''', + [limit], + ); + } + + List 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 getDailyStatsForDate(String date) { + return _db.select('SELECT * FROM daily_stats WHERE date = ?', [date]); + } + + Map getXPBreakdownForDate(String date) { + final breakdown = {}; + + // 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(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( + 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 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 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 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]); + } +} diff --git a/lib/src/detectors/hyprland_activity_detector.dart b/lib/src/detectors/hyprland_activity_detector.dart new file mode 100644 index 0000000..e4654d1 --- /dev/null +++ b/lib/src/detectors/hyprland_activity_detector.dart @@ -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 _activityController = StreamController.broadcast(); + Timer? _pollTimer; + String? _lastActiveWindow; + String? _lastActiveWindowTitle; + DateTime? _lastActivityTime; + bool _isRunning = false; + + @override + Stream get activityStream => _activityController.stream; + + @override + Future 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 _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 getCurrentActivity() { + return _activityController.stream.single; + } +} diff --git a/lib/src/detectors/idle_monitor.dart b/lib/src/detectors/idle_monitor.dart new file mode 100644 index 0000000..e8bc450 --- /dev/null +++ b/lib/src/detectors/idle_monitor.dart @@ -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 _idleStateController = StreamController.broadcast(); + IdleStatus _currentIdleStatus = IdleStatus.active; + + @override + Stream get idleStateStream => _idleStateController.stream; + + @override + IdleStatus get status => _currentIdleStatus; + + @override + Future 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(); + } +} diff --git a/lib/src/detectors/zoom_detector.dart b/lib/src/detectors/zoom_detector.dart new file mode 100644 index 0000000..1d3e2ea --- /dev/null +++ b/lib/src/detectors/zoom_detector.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import '../models/activity_event.dart'; + +class ZoomDetector { + Future 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 _hasZoomProcess() async { + try { + final result = await Process.run('pgrep', ['-f', 'zoom']); + return result.exitCode == 0; + } catch (e) { + return false; + } + } + + Future _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 _hasMediaActivity() async { + // Check both camera and microphone + final cameraActive = await _isCameraInUse(); + final micActive = await _isMicrophoneInUse(); + return cameraActive || micActive; + } + + Future _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 _isMicrophoneInUse() async { + try { + final result = await Process.run('pactl', ['list', 'source-outputs']); + return result.stdout.toLowerCase().contains('zoom'); + } catch (e) { + return false; + } + } +} diff --git a/lib/src/enhancers/hyprland_enhancer.dart b/lib/src/enhancers/hyprland_enhancer.dart new file mode 100644 index 0000000..eb375cb --- /dev/null +++ b/lib/src/enhancers/hyprland_enhancer.dart @@ -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 _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 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 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 _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 _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 _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 _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 _writeConfig(String config) async { + final configFile = File(_configPath); + await configFile.writeAsString(config); + print('๐Ÿ“ Updated configuration file'); + } + + Future _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 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 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'); + } + } +} diff --git a/lib/src/interfaces/i_activity_detector.dart b/lib/src/interfaces/i_activity_detector.dart new file mode 100644 index 0000000..e6346d9 --- /dev/null +++ b/lib/src/interfaces/i_activity_detector.dart @@ -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 get activityStream; + + /// Start detecting activity changes + Future start(); + + /// Stop detecting activity changes + void stop(); + + /// Get current active application info + Future getCurrentActivity(); +} diff --git a/lib/src/interfaces/i_desktop_enhancer.dart b/lib/src/interfaces/i_desktop_enhancer.dart new file mode 100644 index 0000000..8f48707 --- /dev/null +++ b/lib/src/interfaces/i_desktop_enhancer.dart @@ -0,0 +1,17 @@ +/// Interface for desktop enhancement functionality +abstract class IDesktopEnhancer { + /// Apply theme for the given level + Future applyThemeForLevel(int level); + + /// Celebrate level up with visual effects + Future celebrateLevelUp(int level); + + /// Get current theme information + String getCurrentThemeInfo(); + + /// Restore desktop backup + Future restoreBackup(); + + /// Refresh base config from system + Future refreshBaseConfig(); +} diff --git a/lib/src/interfaces/i_idle_monitor.dart b/lib/src/interfaces/i_idle_monitor.dart new file mode 100644 index 0000000..a3dd4c7 --- /dev/null +++ b/lib/src/interfaces/i_idle_monitor.dart @@ -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 get idleStateStream; + + /// Current idle state + IdleStatus get status; + + /// Start monitoring for idle state changes + Future start(); + + /// Stop monitoring + void stop(); +} diff --git a/lib/src/interfaces/i_time_provider.dart b/lib/src/interfaces/i_time_provider.dart new file mode 100644 index 0000000..c08e0b9 --- /dev/null +++ b/lib/src/interfaces/i_time_provider.dart @@ -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); + } +} diff --git a/lib/src/logging/logger.dart b/lib/src/logging/logger.dart new file mode 100644 index 0000000..9edb152 --- /dev/null +++ b/lib/src/logging/logger.dart @@ -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 _logStreamController = StreamController.broadcast(); + + Stream get logStream => _logStreamController.stream; + + Future 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 _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 _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 _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().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 _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> 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> 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 dispose() async { + await _logStreamController.close(); + } +} diff --git a/lib/src/models/activity_event.dart b/lib/src/models/activity_event.dart new file mode 100644 index 0000000..b8019a9 --- /dev/null +++ b/lib/src/models/activity_event.dart @@ -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 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 +} diff --git a/lib/src/monitors/productivity_monitor.dart b/lib/src/monitors/productivity_monitor.dart new file mode 100644 index 0000000..bcb1e60 --- /dev/null +++ b/lib/src/monitors/productivity_monitor.dart @@ -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 _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 _checkAchievements(int level, Map stats) async { + final achievements = >[]; + + // 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 _awardAchievement(Map 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 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 _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 testTheme(int level) async { + print('๐Ÿงช Testing theme for level $level...'); + await _desktopEnhancer.applyThemeForLevel(level); + } + + // Emergency restore + Future restoreDesktop() async { + print('๐Ÿ”ง Restoring desktop to backup...'); + await _desktopEnhancer.restoreBackup(); + } + + // Refresh base config from system + Future 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 _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 _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 getTodayStats() => _dbManager.getTodayStats(); + Map getStreakStats() => _dbManager.getStreakStats(); + + /// Manually trigger level up check (useful for testing) + Future checkForLevelUpNow() async { + await _checkForLevelUp(); + } +} diff --git a/lib/src/notifications/xp_notification_manager.dart b/lib/src/notifications/xp_notification_manager.dart new file mode 100644 index 0000000..778536c --- /dev/null +++ b/lib/src/notifications/xp_notification_manager.dart @@ -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 _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 showXPGain({ + required String source, + required int xpGained, + required ActivityEvent activity, + int? durationMinutes, + Map? 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 showLevelUp({required int newLevel, required int totalXP, required Map 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 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 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? 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 _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 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'], + }; + } +} diff --git a/lib/src/providers/system_time_provider.dart b/lib/src/providers/system_time_provider.dart new file mode 100644 index 0000000..85db85a --- /dev/null +++ b/lib/src/providers/system_time_provider.dart @@ -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(); + } +} diff --git a/lib/src/testing/mock_activity_detector.dart b/lib/src/testing/mock_activity_detector.dart new file mode 100644 index 0000000..ebde7d5 --- /dev/null +++ b/lib/src/testing/mock_activity_detector.dart @@ -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 _activityController = StreamController.broadcast(); + final List _activitySequence = []; + int _currentSequenceIndex = 0; + Timer? _sequenceTimer; + + String _currentApp = 'unknown'; + String _currentTitle = ''; + + @override + Stream get activityStream => _activityController.stream; + + @override + Future start() async { + // Start playing the activity sequence if one is set + if (_activitySequence.isNotEmpty) { + _playNextActivity(); + } + } + + @override + void stop() { + _sequenceTimer?.cancel(); + _activityController.close(); + } + + @override + Future getCurrentActivity() async { + return ActivityEvent( + type: ActivityEventType.focusedBrowsing, + application: '', + metadata: '', + timestamp: DateTime.now(), + ); + } + + /// Set a sequence of activities to simulate + void setActivitySequence(List 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); +} diff --git a/lib/src/testing/mock_desktop_enhancer.dart b/lib/src/testing/mock_desktop_enhancer.dart new file mode 100644 index 0000000..bc2e817 --- /dev/null +++ b/lib/src/testing/mock_desktop_enhancer.dart @@ -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 _operations = []; + + /// Get the operations that were performed (for testing verification) + List get operations => List.unmodifiable(_operations); + + /// Clear the operations log + void clearOperations() => _operations.clear(); + + @override + Future 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 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 restoreBackup() async { + _operations.add('restoreBackup()'); + _currentTheme = 'default'; + _lastAppliedLevel = 1; + print('๐Ÿ”ง [MOCK] Restored desktop from backup'); + } + + @override + Future refreshBaseConfig() async { + _operations.add('refreshBaseConfig()'); + print('๐Ÿ”„ [MOCK] Refreshed base config from system'); + } +} diff --git a/lib/src/testing/mock_idle_monitor.dart b/lib/src/testing/mock_idle_monitor.dart new file mode 100644 index 0000000..ff6764d --- /dev/null +++ b/lib/src/testing/mock_idle_monitor.dart @@ -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 _idleStateController = StreamController.broadcast(); + IdleStatus _status = IdleStatus.active; + + @override + Stream get idleStateStream => _idleStateController.stream; + + @override + Future 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 simulateIdleSequence(List 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); +} diff --git a/lib/src/testing/mock_time_provider.dart b/lib/src/testing/mock_time_provider.dart new file mode 100644 index 0000000..8802d22 --- /dev/null +++ b/lib/src/testing/mock_time_provider.dart @@ -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); +} diff --git a/lib/src/web/dashboard_server.dart b/lib/src/web/dashboard_server.dart new file mode 100644 index 0000000..8649268 --- /dev/null +++ b/lib/src/web/dashboard_server.dart @@ -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 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/', _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 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 get _corsHeaders => { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }; + + Future _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 _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> _getStatsHistory(int days) { + final history = >[]; + 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 _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 _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 _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 _handleXPBreakdown(Request request) async { + try { + final date = request.url.queryParameters['date']; + final Map 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 _handleLogs(Request request) async { + try { + final count = int.tryParse(request.url.queryParameters['count'] ?? '100') ?? 100; + final level = request.url.queryParameters['level']; + + List 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 _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 _handleUpdateConfig(Request request) async { + try { + final body = await request.readAsString(); + final updates = jsonDecode(body) as Map; + + 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 _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 _handleSaveClassification(Request request) async { + try { + final body = await request.readAsString(); + final data = jsonDecode(body) as Map; + + 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 _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 _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 _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'; +} diff --git a/lib/src/web/static/dashboard.js b/lib/src/web/static/dashboard.js new file mode 100644 index 0000000..decd4e0 --- /dev/null +++ b/lib/src/web/static/dashboard.js @@ -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 ` +
+ ${this.capitalizeFirst(activity.type)} +
+ ${activity.application} โ€ข ${durationMin}m โ€ข ${timeStr} +
+
+ `; + }).join(''); + } else { + activityContainer.innerHTML = '
No recent activity
'; + } + + } 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 ` +
+ ${achievement.name} +
+ ${achievement.description} โ€ข +${achievement.xp_reward} XP โ€ข ${dateStr} +
+
+ `; + }).join(''); + } else { + achievementsContainer.innerHTML = '
No achievements yet
'; + } + } 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 ` +
+
+ ${icon} + ${this.formatXPSourceName(source)} + +${xp} XP +
+
+
+
+
${percentage}%
+
+ `; + }).join(''); + } else { + breakdownContainer.innerHTML = '
No XP earned today
'; + } + } 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 = '
XP breakdown coming soon...
'; + } + } + } + + 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 `
${this.escapeHtml(log)}
`; + }).join(''); + } else { + logsContainer.innerHTML = '
No logs available
'; + } + + // 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 ` +
+
+ ${categoryIcon} + ${classification.application_name} + ${categoryName} + +
+
+ `; + }).join(''); + } else { + classificationsContainer.innerHTML = '
No classifications yet
'; + } + } 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 ` +
+
+ ${app.application_name} + ${app.occurrence_count} times + Last: ${timeStr} +
+
+ + +
+
+ `; + }).join(''); + } else { + unclassifiedContainer.innerHTML = '
No unclassified applications
'; + } + } 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(); + } +}); diff --git a/lib/src/web/static/index.html b/lib/src/web/static/index.html new file mode 100644 index 0000000..0e61865 --- /dev/null +++ b/lib/src/web/static/index.html @@ -0,0 +1,159 @@ + + + + + + XP Nix - Productivity Dashboard + + + + +
+
+

๐ŸŽฎ XP Nix Productivity Dashboard

+
+
+ Level + 1 +
+
+ XP + 0 +
+
+ Streak + 0 +
+
+
+ +
+ +
+

๐Ÿ“Š Today's Progress

+
+
+ Focus Time +
+
+
+ 0h 0m +
+
+ Meeting Time +
+
+
+ 0h 0m +
+
+ Focus Sessions + 0 +
+
+
+ + +
+

๐Ÿ“ˆ XP Progress (7 Days)

+ +
+ + +
+

โšก Recent Activity

+
+
+ No recent activity +
+
+
+ + +
+

๐Ÿ’Ž XP Sources Today

+
+
+ Loading XP breakdown... +
+
+
+ + +
+

๐Ÿ† Recent Achievements

+
+
+ No achievements yet +
+
+
+ + +
+

โš™๏ธ Configuration

+
+

XP Multipliers

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

๐Ÿท๏ธ Application Classifications

+
+

Unclassified Applications

+
+
+ Loading unclassified applications... +
+
+ +

Current Classifications

+
+
+ Loading classifications... +
+
+
+
+ + +
+

๐Ÿ“ System Logs

+
+ + +
+
+
Loading logs...
+
+
+
+
+ + + + diff --git a/lib/src/web/static/style.css b/lib/src/web/static/style.css new file mode 100644 index 0000000..5c64720 --- /dev/null +++ b/lib/src/web/static/style.css @@ -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; +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d5a38fa --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5a3f4c3 --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/deep_idle_test.dart b/test/deep_idle_test.dart new file mode 100644 index 0000000..17e3faa --- /dev/null +++ b/test/deep_idle_test.dart @@ -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!'); + }); + }); +} diff --git a/test/hyprland_config_parser_test.dart b/test/hyprland_config_parser_test.dart new file mode 100644 index 0000000..9e1ef43 --- /dev/null +++ b/test/hyprland_config_parser_test.dart @@ -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>()); + expect(styling['general'], isA>()); + expect(styling['animations'], isA>()); + + final decoration = styling['decoration'] as Map; + expect(decoration['rounding'], equals('15')); + expect(decoration['dim_inactive'], equals('true')); + expect(decoration['active_opacity'], equals('1.0')); + + final general = styling['general'] as Map; + 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; + 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; + + // 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>()); + final animations = styling['animations'] as List; + 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>()); + expect(styling['general'], isNull); + expect(styling['animations'], isA>()); + + final decoration = styling['decoration'] as Map; + 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; + + expect(general['col.active_border'], equals('rgba(7e5fddff) rgba(ff5100ff) rgba(00ff88ff) 45deg')); + expect(general['custom_prop'], equals('value=with=equals')); + }); + }); + }); +} diff --git a/test/simulation/consolidation_test.dart b/test/simulation/consolidation_test.dart new file mode 100644 index 0000000..4772dc1 --- /dev/null +++ b/test/simulation/consolidation_test.dart @@ -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>()); + }); + + 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)); + }); + }); +} diff --git a/test/simulation/simple_simulation_test.dart b/test/simulation/simple_simulation_test.dart new file mode 100644 index 0000000..425714d --- /dev/null +++ b/test/simulation/simple_simulation_test.dart @@ -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!'); + }); + }); +} diff --git a/test/simulation/work_day_simulation_test.dart b/test/simulation/work_day_simulation_test.dart new file mode 100644 index 0000000..41f834c --- /dev/null +++ b/test/simulation/work_day_simulation_test.dart @@ -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 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'); +} diff --git a/test/xp_nix_test.dart b/test/xp_nix_test.dart new file mode 100644 index 0000000..44b36c2 --- /dev/null +++ b/test/xp_nix_test.dart @@ -0,0 +1,7 @@ +import 'package:test/test.dart'; + +void main() { + test('placeholder test', () { + expect(1 + 1, equals(2)); + }); +}