import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:journaler/db.dart'; import 'package:journaler/notes.dart'; import 'package:system_tray/system_tray.dart'; import 'package:window_manager/window_manager.dart'; import 'package:audioplayers/audioplayers.dart'; // TODO: Add an icon to the executable, simply use the existing tray icon // TODO: Implement some sort of scroll through notes // Default values - will be replaced by DB values if they exist const Duration _defaultPopupInterval = Duration(minutes: 20); const String _defaultNotificationSound = 'MeetTheSniper.mp3'; void main() async { await DB.init(); WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); WindowOptions windowOptions = const WindowOptions( size: Size(1600, 900), center: true, backgroundColor: Colors.transparent, skipTaskbar: false, titleBarStyle: TitleBarStyle.normal, ); windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.hide(); }); runApp(const JournalerApp()); } class JournalerApp extends StatelessWidget { const JournalerApp({super.key}); static final TextTheme _baseTextTheme = const TextTheme( bodyMedium: TextStyle(fontSize: 24), ); static final TextStyle _baseTitleTextStyle = const TextStyle( fontSize: 20, fontWeight: FontWeight.w500, ); static final InputDecorationTheme _baseInputDecorationTheme = const InputDecorationTheme( border: OutlineInputBorder(), labelStyle: TextStyle(fontSize: 20), ); static final ThemeData lightTheme = ThemeData.light().copyWith( visualDensity: VisualDensity.adaptivePlatformDensity, colorScheme: ColorScheme.fromSeed( seedColor: Colors.blue, brightness: Brightness.light, ), scaffoldBackgroundColor: Colors.grey[100], appBarTheme: AppBarTheme( backgroundColor: Colors.blue, foregroundColor: Colors.white, titleTextStyle: _baseTitleTextStyle.copyWith(color: Colors.white), ), inputDecorationTheme: _baseInputDecorationTheme.copyWith( focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.blue.shade600, width: 2.0), ), labelStyle: _baseInputDecorationTheme.labelStyle?.copyWith( color: Colors.grey[700], ), ), textTheme: _baseTextTheme, ); static final ThemeData darkTheme = ThemeData.dark().copyWith( visualDensity: VisualDensity.adaptivePlatformDensity, colorScheme: ColorScheme.fromSeed( seedColor: Colors.blue, brightness: Brightness.dark, ), scaffoldBackgroundColor: Colors.grey[900], appBarTheme: AppBarTheme( backgroundColor: Colors.grey[850], foregroundColor: Colors.white, titleTextStyle: _baseTitleTextStyle.copyWith(color: Colors.white), ), inputDecorationTheme: _baseInputDecorationTheme.copyWith( border: const OutlineInputBorder( borderSide: BorderSide(color: Colors.grey), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.blue.shade300, width: 2.0), ), labelStyle: _baseInputDecorationTheme.labelStyle?.copyWith( color: Colors.grey[400], ), ), textTheme: _baseTextTheme.copyWith( bodyMedium: _baseTextTheme.bodyMedium?.copyWith(color: Colors.white), ), ); @override Widget build(BuildContext context) { return MaterialApp( title: 'Journaler', theme: lightTheme, darkTheme: darkTheme, themeMode: ThemeMode.system, home: const MainPage(), debugShowCheckedModeBanner: false, ); } } class MainPage extends StatefulWidget { const MainPage({super.key}); @override State createState() => MainPageState(); } class MainPageState extends State with WindowListener { final SystemTray _systemTray = SystemTray(); final Menu _menu = Menu(); final AudioPlayer _audioPlayer = AudioPlayer(); final TextEditingController _previousEntryController = TextEditingController(); final TextEditingController _currentEntryController = TextEditingController(); final FocusNode _currentEntryFocusNode = FocusNode(); final TextEditingController _scratchController = TextEditingController(); final TextEditingController _intervalController = TextEditingController(); final TextEditingController _soundController = TextEditingController(); Note? previousNote; Duration _currentPopupInterval = _defaultPopupInterval; String _currentNotificationSound = _defaultNotificationSound; Timer? _popupTimer; Timer? _debounceTimer; @override void initState() { super.initState(); windowManager.addListener(this); _initSystemTray(); _loadData(); windowManager.setPreventClose(true); _setWindowConfig(); } @override void dispose() { windowManager.removeListener(this); _popupTimer?.cancel(); _debounceTimer?.cancel(); _previousEntryController.dispose(); _currentEntryController.dispose(); _currentEntryFocusNode.dispose(); _scratchController.dispose(); _intervalController.dispose(); _soundController.dispose(); _audioPlayer.dispose(); super.dispose(); } @override void onWindowClose() { _saveData(); windowManager.hide(); } @override void onWindowFocus() { setState(() {}); } Future _initSystemTray() async { String iconPath = 'assets/app_icon.ico'; await _systemTray.initSystemTray(iconPath: iconPath, toolTip: "Journaler"); await _menu.buildFrom([ MenuItemLabel( label: 'Commit Sudoku', onClicked: (menuItem) => windowManager.destroy(), ), ]); await _systemTray.setContextMenu(_menu); _systemTray.registerSystemTrayEventHandler((eventName) { debugPrint("System Tray Event: $eventName"); if (eventName == kSystemTrayEventClick) { _showWindow(); } else if (eventName == kSystemTrayEventRightClick) { _systemTray.popUpContextMenu(); } }); } void _startPopupTimer() { _popupTimer?.cancel(); _popupTimer = Timer.periodic(_currentPopupInterval, (timer) { _showWindow(); }); debugPrint("Popup timer started with interval: ${_currentPopupInterval.inMinutes} minutes"); } Future _showWindow() async { _loadData(); bool wasVisible = await windowManager.isVisible(); if (!wasVisible) { await windowManager.setSize(const Size(1600, 900)); await windowManager.center(); await windowManager.show(); await windowManager.focus(); _currentEntryFocusNode.requestFocus(); await _playSound(); } else { await windowManager.focus(); _currentEntryFocusNode.requestFocus(); } } Future _playSound() async { await _audioPlayer.stop(); try { await _audioPlayer.play(AssetSource('sounds/$_currentNotificationSound')); debugPrint("Played sound: $_currentNotificationSound"); } catch (e) { debugPrint("Error playing sound $_currentNotificationSound: e"); } } void _loadData() async { String? intervalMinutesStr = await DB.getSetting('popupIntervalMinutes'); String? soundFileStr = await DB.getSetting('notificationSound'); int intervalMinutes = int.tryParse(intervalMinutesStr ?? '') ?? _defaultPopupInterval.inMinutes; _currentPopupInterval = Duration(minutes: intervalMinutes); _currentNotificationSound = soundFileStr ?? _defaultNotificationSound; _intervalController.text = intervalMinutes.toString(); _soundController.text = _currentNotificationSound; _startPopupTimer(); final note = await getLatestNote(); previousNote = note; _previousEntryController.text = note?.content ?? ""; final scratch = await getLatestScratch(); _scratchController.text = scratch?.content ?? ""; _currentEntryController.text = ""; debugPrint("Data loaded."); } void _saveData() async { String previousEntry = _previousEntryController.text; String currentEntry = _currentEntryController.text; String scratchContent = _scratchController.text; String intervalStr = _intervalController.text; String soundStr = _soundController.text; await createNote(currentEntry); await createScratch(scratchContent); if (previousNote != null) { previousNote!.content = previousEntry; await updateNote(previousNote!); } int newIntervalMinutes = int.tryParse(intervalStr) ?? _currentPopupInterval.inMinutes; Duration newInterval = Duration(minutes: newIntervalMinutes); if (newInterval != _currentPopupInterval) { _currentPopupInterval = newInterval; DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString()); _startPopupTimer(); } else { DB.setSetting('popupIntervalMinutes', newIntervalMinutes.toString()); } if (soundStr != _currentNotificationSound) { _currentNotificationSound = soundStr; DB.setSetting('notificationSound', soundStr); } else { DB.setSetting('notificationSound', soundStr); } debugPrint( "Saving data... Current Entry: [${currentEntry.length} chars], Scratch: [${scratchContent.length} chars]", ); } Future _setWindowConfig() async { await windowManager.setAspectRatio(16 / 9); } @override Widget build(BuildContext context) { // Wrap Scaffold with RawKeyboardListener as workaround for Escape key return RawKeyboardListener( focusNode: FocusNode(), // Needs its own node onKey: (RawKeyEvent event) { if (event is RawKeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { debugPrint( "Escape pressed inside MainPage (RawKeyboardListener - Workaround)", ); // Call method directly since we are in the state FocusManager.instance.primaryFocus?.unfocus(); // Keep unfocus attempt onWindowClose(); } }, child: Scaffold( appBar: AppBar( title: const Text('Journaler'), actions: [ // Group Label and Input for Interval Padding( padding: const EdgeInsets.symmetric(vertical: 8.0).copyWith(left: 8.0), // Add padding child: Row( mainAxisSize: MainAxisSize.min, // Use minimum space children: [ const Text("Interval (m):"), const SizedBox(width: 4), // Space between label and input SizedBox( width: 60, // Constrain width child: TextField( controller: _intervalController, // textAlignVertical: TextAlignVertical.center, // Let default alignment handle it decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly ], ), ), ], ), ), // Group Label and Input for Sound Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Text("Sound:"), const SizedBox(width: 4), SizedBox( width: 150, // Constrain width child: TextField( controller: _soundController, // textAlignVertical: TextAlignVertical.center, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), hintText: 'sound.mp3', ), ), ), ], ), ), // Test Sound Button Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: IconButton( icon: const Icon(Icons.volume_up), tooltip: 'Test Sound', onPressed: _playSound, ), ), const SizedBox(width: 10), ], ), body: Padding( padding: const EdgeInsets.all(8.0), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: 9, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: TextField( controller: _previousEntryController, maxLines: null, expands: true, style: Theme.of(context).textTheme.bodyMedium, decoration: const InputDecoration( labelText: 'Previous Entry', ), ), ), const SizedBox(height: 8), Expanded( child: TextField( controller: _currentEntryController, focusNode: _currentEntryFocusNode, maxLines: null, expands: true, autofocus: true, style: Theme.of(context).textTheme.bodyMedium, decoration: const InputDecoration( labelText: 'Current Entry (What\'s on your mind?)', ), ), ), ], ), ), const SizedBox(width: 8), Expanded( flex: 4, // Adjust flex factor as needed child: TextField( controller: _scratchController, maxLines: null, expands: true, style: Theme.of(context).textTheme.bodyMedium, // Apply theme text style decoration: const InputDecoration( labelText: 'Scratch', ), ), ), ], ), ), ), ); } } // --- End Actions and Shortcuts ---