feat(#349): persist and restore desktop window geometry
The stock Flutter desktop runner never saved the window frame, so the window always reopened at a default position and size (#349). Adds WindowGeometryService (window_manager + screen_retriever): it saves the frame on move/resize, debounced, and restores it on launch. Restore is clamped against the connected displays. A window last placed on a monitor that is now unplugged must not reopen off-screen where it cannot be grabbed; if the saved frame does not overlap any display by at least 80px on both axes it is discarded and the window opens at the default. A too-small or undecodable saved value also falls through to the default (logged, not swallowed). Gated on PlatformInfo.isDesktop, so it is a no-op on mobile and web. Scoped past just Windows deliberately: window_manager is desktop-only and the same gap exists on Linux/macOS, so all three get the fix rather than Windows needing an extra guard. Known limitation, not addressed here: without a native runner change to start the window hidden, restore repositions AFTER the window is shown, so there can be a brief flash at the default position before it jumps to the saved frame. Eliminating that needs a windows/runner edit; flagged for the owner to judge during hardware test. flutter analyze clean, dart format clean, tests pass. Windows-run verification is the owner's (geometry is a real-desktop behaviour).feat/349-window-geometry
parent
8af0e0d5dc
commit
57d7c0549b
@ -0,0 +1,124 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:screen_retriever/screen_retriever.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../storage/prefs_manager.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
|
||||||
|
/// Persists and restores the desktop window's position and size (#349).
|
||||||
|
///
|
||||||
|
/// The stock Flutter desktop runner never saved geometry, so the window always
|
||||||
|
/// reopened at a default frame. This service saves the frame on move/resize and
|
||||||
|
/// restores it on launch.
|
||||||
|
///
|
||||||
|
/// The restore is clamped against the currently-connected displays: a window
|
||||||
|
/// last placed on a monitor that is now unplugged must not reopen off-screen
|
||||||
|
/// where the user cannot reach it. If the saved frame does not overlap any
|
||||||
|
/// display enough to grab, it is discarded and the window opens at the default.
|
||||||
|
///
|
||||||
|
/// Desktop only (window_manager has no mobile/web backend); a no-op elsewhere.
|
||||||
|
class WindowGeometryService with WindowListener {
|
||||||
|
WindowGeometryService._();
|
||||||
|
static final WindowGeometryService instance = WindowGeometryService._();
|
||||||
|
|
||||||
|
static const _prefsKey = 'window_geometry';
|
||||||
|
static const _minWidth = 400.0;
|
||||||
|
static const _minHeight = 300.0;
|
||||||
|
// A window counts as reachable if at least this much of it overlaps a
|
||||||
|
// display on both axes, so a sliver still on screen is recoverable.
|
||||||
|
static const _minVisible = 80.0;
|
||||||
|
|
||||||
|
Timer? _saveDebounce;
|
||||||
|
|
||||||
|
/// Call once in main() before runApp, after PrefsManager.initialize().
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (!PlatformInfo.isDesktop) return;
|
||||||
|
|
||||||
|
await windowManager.ensureInitialized();
|
||||||
|
await windowManager.waitUntilReadyToShow(null, () async {
|
||||||
|
await _restore();
|
||||||
|
await windowManager.show();
|
||||||
|
await windowManager.focus();
|
||||||
|
});
|
||||||
|
windowManager.addListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restore() async {
|
||||||
|
final raw = PrefsManager.instance.getString(_prefsKey);
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
|
Rect saved;
|
||||||
|
try {
|
||||||
|
final m = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
saved = Rect.fromLTWH(
|
||||||
|
(m['x'] as num).toDouble(),
|
||||||
|
(m['y'] as num).toDouble(),
|
||||||
|
(m['w'] as num).toDouble(),
|
||||||
|
(m['h'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// SAFELANE 6: never swallow. A corrupt value falls through to the default.
|
||||||
|
appLogger.error(
|
||||||
|
'Failed to decode saved window geometry; using default: $e',
|
||||||
|
tag: 'Window',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved.width < _minWidth || saved.height < _minHeight) return;
|
||||||
|
|
||||||
|
if (!await _isReachable(saved)) {
|
||||||
|
appLogger.info(
|
||||||
|
'Saved window geometry is off-screen (monitor likely unplugged); '
|
||||||
|
'opening at the default position instead.',
|
||||||
|
tag: 'Window',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await windowManager.setBounds(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when [saved] overlaps some connected display's visible area by at
|
||||||
|
/// least [_minVisible] on both axes.
|
||||||
|
Future<bool> _isReachable(Rect saved) async {
|
||||||
|
final displays = await screenRetriever.getAllDisplays();
|
||||||
|
for (final d in displays) {
|
||||||
|
final origin = d.visiblePosition ?? Offset.zero;
|
||||||
|
final size = d.visibleSize ?? d.size;
|
||||||
|
final display = origin & size; // Rect from an Offset and a Size.
|
||||||
|
final overlap = saved.intersect(display);
|
||||||
|
if (overlap.width >= _minVisible && overlap.height >= _minVisible) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSave() {
|
||||||
|
_saveDebounce?.cancel();
|
||||||
|
_saveDebounce = Timer(const Duration(milliseconds: 400), _save);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
try {
|
||||||
|
final b = await windowManager.getBounds();
|
||||||
|
await PrefsManager.instance.setString(
|
||||||
|
_prefsKey,
|
||||||
|
jsonEncode({'x': b.left, 'y': b.top, 'w': b.width, 'h': b.height}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error('Failed to save window geometry: $e', tag: 'Window');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowMoved() => _scheduleSave();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowResized() => _scheduleSave();
|
||||||
|
}
|
||||||
Loading…
Reference in new issue