From cdf328bd568efd6f4e1e8a950ea40a7cc61cbf3f Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 20:01:18 -0700 Subject: [PATCH] Issue 67: remember desktop window size and position Adds window_manager and a small WindowStateService, desktop-only (not Android/iOS). Restores saved bounds on launch; persists on resize/move via a debounced SharedPreferences write, reusing the same prefs instance the rest of startup already fetches. Uses the continuous onWindowResize/onWindowMove events rather than the "finished" variants, since those are macOS/Windows only in window_manager and this needs to work on Linux. --- lib/main.dart | 81 ++++++++++++------- lib/services/window_state_service.dart | 66 +++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 8 ++ linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 62 ++++++++++++-- pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 ++ windows/flutter/generated_plugins.cmake | 2 + 9 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 lib/services/window_state_service.dart diff --git a/lib/main.dart b/lib/main.dart index e0cc0d4..12894f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:window_manager/window_manager.dart'; import 'database/database.dart'; import 'models/app_settings.dart'; @@ -46,6 +47,7 @@ import 'utils/notification_payload.dart'; import 'package:permission_handler/permission_handler.dart'; import 'widgets/deep_link_listener.dart'; import 'services/debug_log_service.dart'; +import 'services/window_state_service.dart'; // Global navigator key for deep linking final GlobalKey navigatorKey = GlobalKey(); @@ -90,6 +92,11 @@ Future _runAppStartup() async { print('🚀 TEAM Flutter starting...'); print('✅ Flutter binding initialized'); + final isDesktop = !Platform.isAndroid && !Platform.isIOS; + if (isDesktop) { + await windowManager.ensureInitialized(); + } + try { // Initialize the database print('📦 Initializing database...'); @@ -108,6 +115,12 @@ Future _runAppStartup() async { final settingsService = SettingsService(prefs); print('✅ Settings loaded'); + if (isDesktop) { + final windowStateService = WindowStateService(prefs); + await windowStateService.restoreWindowState(); + windowManager.addListener(windowStateService); + } + // Initialize notification plugin print('🔔 Initializing notifications...'); final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); @@ -329,7 +342,8 @@ Future _runAppStartup() async { /// Handle notification tap to navigate to specific chat void _handleNotificationTap( NotificationResponse details, AppDatabase database) async { - print('📬 Notification tapped: ${details.payload} action=${details.actionId}'); + print( + '📬 Notification tapped: ${details.payload} action=${details.actionId}'); // Mesh-connection "Stop" action button, or a swipe-dismiss of the persistent // mesh notification → fully stop the service (kills a stuck reconnect). @@ -376,7 +390,8 @@ void _handleNotificationTap( if (contact.isRepeater) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.directMessagesDisabledForRepeaters), + content: Text(AppLocalizations.of(context)! + .directMessagesDisabledForRepeaters), ), ); return; @@ -505,7 +520,9 @@ class TeamFlutterApp extends StatelessWidget { final appTheme = settings.settings.appTheme; final isNighttime = appTheme == AppThemeMode.nighttime; SystemChrome.setEnabledSystemUIMode( - isNighttime ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, + isNighttime + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge, ); return MaterialApp( navigatorKey: navigatorKey, @@ -530,21 +547,24 @@ class TeamFlutterApp extends StatelessWidget { ), useMaterial3: true, ), - darkTheme: isNighttime ? _nighttimeTheme() : ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, - ), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - selectedItemColor: Colors.blue, - unselectedItemColor: Colors.grey, - ), - useMaterial3: true, - ), + darkTheme: isNighttime + ? _nighttimeTheme() + : ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + bottomNavigationBarTheme: + const BottomNavigationBarThemeData( + selectedItemColor: Colors.blue, + unselectedItemColor: Colors.grey, + ), + useMaterial3: true, + ), themeMode: switch (appTheme) { AppThemeMode.light => ThemeMode.light, AppThemeMode.dark => ThemeMode.dark, @@ -569,7 +589,6 @@ class TeamFlutterApp extends StatelessWidget { } } - ThemeData _nighttimeTheme() { final base = ColorScheme.fromSeed( seedColor: NightColors.primary, @@ -604,18 +623,18 @@ ThemeData _nighttimeTheme() { iconTheme: const IconThemeData(color: NightColors.onSurface), hintColor: NightColors.onSurfaceVariant, switchTheme: SwitchThemeData( - trackColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? NightColors.primary - : NightColors.dimmest), - thumbColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? NightColors.onSurface - : NightColors.dim), - trackOutlineColor: WidgetStateProperty.resolveWith((states) => states - .contains(WidgetState.selected) - ? Colors.transparent - : NightColors.dim), + trackColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? NightColors.primary + : NightColors.dimmest), + thumbColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? NightColors.onSurface + : NightColors.dim), + trackOutlineColor: WidgetStateProperty.resolveWith((states) => + states.contains(WidgetState.selected) + ? Colors.transparent + : NightColors.dim), ), sliderTheme: const SliderThemeData( activeTrackColor: NightColors.primary, diff --git a/lib/services/window_state_service.dart b/lib/services/window_state_service.dart new file mode 100644 index 0000000..6461c45 --- /dev/null +++ b/lib/services/window_state_service.dart @@ -0,0 +1,66 @@ +// Copyright (c) 2026 tmacinc +// Licensed under CC BY-NC-SA 4.0 + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +/// Remembers the desktop window's size and position across launches. +/// +/// Desktop-only, ephemeral UI state that nothing else needs to react to -- +/// deliberately kept out of the reactive AppSettings/SettingsService model. +/// +/// Note: window_manager's "finished" resize/move events +/// (onWindowResized/onWindowMoved) are macOS/Windows only, not available on +/// Linux. This uses the continuous onWindowResize/onWindowMove events with +/// its own debounce instead, so it works on Linux too. +class WindowStateService with WindowListener { + static const _keyX = 'window_x'; + static const _keyY = 'window_y'; + static const _keyWidth = 'window_width'; + static const _keyHeight = 'window_height'; + static const _debounce = Duration(milliseconds: 500); + + final SharedPreferences _prefs; + Timer? _debounceTimer; + + WindowStateService(this._prefs); + + /// Applies the saved window bounds, if any were previously recorded. + /// A first launch (nothing saved yet) leaves the platform default alone. + Future restoreWindowState() async { + final x = _prefs.getDouble(_keyX); + final y = _prefs.getDouble(_keyY); + final width = _prefs.getDouble(_keyWidth); + final height = _prefs.getDouble(_keyHeight); + if (x == null || y == null || width == null || height == null) return; + + await windowManager.setBounds(Rect.fromLTWH(x, y, width, height)); + } + + @override + void onWindowResize() => _scheduleSave(); + + @override + void onWindowMove() => _scheduleSave(); + + void _scheduleSave() { + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounce, _saveBounds); + } + + Future _saveBounds() async { + final bounds = await windowManager.getBounds(); + await _prefs.setDouble(_keyX, bounds.left); + await _prefs.setDouble(_keyY, bounds.top); + await _prefs.setDouble(_keyWidth, bounds.width); + await _prefs.setDouble(_keyHeight, bounds.height); + } + + void dispose() { + _debounceTimer?.cancel(); + windowManager.removeListener(this); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fc949e0..8c8315c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,17 +7,25 @@ #include "generated_plugin_registrant.h" #include +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c34d078..0c176a7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,8 +4,10 @@ list(APPEND FLUTTER_PLUGIN_LIST gtk + screen_retriever_linux sqlite3_flutter_libs url_launcher_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..de3eef5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,11 +14,13 @@ import geolocator_apple import mobile_scanner import package_info_plus import path_provider_foundation +import screen_retriever_macos import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs import wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -30,9 +32,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..7676121 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1045,6 +1045,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" share_plus: dependency: "direct main" description: @@ -1270,10 +1310,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1426,6 +1466,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.10.1" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" wkt_parser: dependency: transitive description: @@ -1459,5 +1507,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..8949a5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: # Utilities crypto: ^3.0.3 + window_manager: ^0.5.2 http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d806310..4e7fc37 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,9 +11,11 @@ #include #include #include +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -26,10 +28,14 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0807f9a..87994f3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,9 +8,11 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_blue_plus_winrt geolocator_windows permission_handler_windows + screen_retriever_windows share_plus sqlite3_flutter_libs url_launcher_windows + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST