diff --git a/lib/main.dart b/lib/main.dart index 5516e7409..156a8c5b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,11 +31,13 @@ import 'package:flutter/material.dart'; import 'package:catppuccin_flutter/catppuccin_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; import 'package:rattle/app.dart'; import 'package:rattle/constants/temp_dir.dart'; import 'package:rattle/providers/pty.dart'; +import 'package:rattle/providers/settings.dart'; import 'package:rattle/utils/is_desktop.dart'; import 'package:rattle/utils/is_production.dart'; import 'package:rattle/utils/show_error.dart'; @@ -170,23 +172,21 @@ Future main() async { await windowManager.ensureInitialized(); - WindowOptions windowOptions = const WindowOptions( + // Load saved window size from SharedPreferences. + final prefs = await SharedPreferences.getInstance(); + final savedWidth = prefs.getDouble('windowWidth') ?? defaultWindowWidth; + final savedHeight = prefs.getDouble('windowHeight') ?? defaultWindowHeight; + + WindowOptions windowOptions = WindowOptions( // Setting [alwaysOnTop] here will ensure the desktop app starts on top of // other apps on the desktop so that it is visible. // // We later turn it off as we don't want to force it always on top. alwaysOnTop: true, - // We can override the size in the first instance by, for example in - // Linux, editing linux/my_application.cc. - // - // Setting it here has effect when Restarting the app while debugging. - - // However, since Windows has 1280x720 by default in the windows-specific - // windows/runner/main.cpp, line 29, it is best not to override it here - // since under Windows 950x600 is too small. - - // size: Size(950, 600), + // Use saved window size if available, otherwise use platform defaults + // The saved size is loaded from SharedPreferences + size: Size(savedWidth, savedHeight), // The [title] is used for the window manager's window title. title: 'Rattle - Data Science with R', diff --git a/lib/providers/settings.dart b/lib/providers/settings.dart index 0ea39dea5..bcb35ecaf 100644 --- a/lib/providers/settings.dart +++ b/lib/providers/settings.dart @@ -100,3 +100,16 @@ final stripCommentsProvider = StateProvider((ref) => false); final askOnExitProvider = StateProvider((ref) => true); final ignoreMissingTargetProvider = StateProvider((ref) => true); + +// Window size settings providers +// Default window size values (can be overridden by platform-specific defaults if needed) + +const double defaultWindowWidth = 1280.0; +const double defaultWindowHeight = 720.0; + +final windowWidthProvider = StateProvider((ref) => defaultWindowWidth); + +final windowHeightProvider = + StateProvider((ref) => defaultWindowHeight); + +final rememberWindowSizeProvider = StateProvider((ref) => true); diff --git a/lib/settings/dialog.dart b/lib/settings/dialog.dart index d407e07bc..144acd5dd 100644 --- a/lib/settings/dialog.dart +++ b/lib/settings/dialog.dart @@ -42,6 +42,7 @@ import 'package:rattle/settings/sections/dataset_toggles.dart'; import 'package:rattle/settings/sections/graphic_theme.dart'; import 'package:rattle/settings/sections/script.dart'; import 'package:rattle/settings/sections/session.dart'; +import 'package:rattle/settings/sections/window_size.dart'; import 'package:rattle/settings/utils/handle_cancel_button.dart'; class SettingsDialog extends ConsumerStatefulWidget { @@ -117,6 +118,17 @@ class SettingsDialogState extends ConsumerState { ref.read(ignoreMissingTargetProvider.notifier).state = prefs.getBool('ignoreMissingTarget') ?? true; + + // Load window size settings from shared preferences + + ref.read(windowWidthProvider.notifier).state = + prefs.getDouble('windowWidth') ?? defaultWindowWidth; + + ref.read(windowHeightProvider.notifier).state = + prefs.getDouble('windowHeight') ?? defaultWindowHeight; + + ref.read(rememberWindowSizeProvider.notifier).state = + prefs.getBool('rememberWindowSize') ?? true; } /// Load all numeric settings from shared preferences. @@ -190,6 +202,7 @@ class SettingsDialogState extends ConsumerState { DatasetToggles(), GraphicTheme(), Session(), + WindowSize(), Script(), ], ), diff --git a/lib/settings/sections/window_size.dart b/lib/settings/sections/window_size.dart new file mode 100644 index 000000000..2b20bb22d --- /dev/null +++ b/lib/settings/sections/window_size.dart @@ -0,0 +1,304 @@ +/// Window size section. +// +// Time-stamp: "Sunday 2025-12-14 17:21:00 +1100 Graham Williams" +// +/// Copyright (C) 2024, Togaware Pty Ltd +/// +/// Licensed under the GNU General Public License, Version 3 (the "License"); +/// +/// License: https://opensource.org/license/gpl-3-0 +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . +/// +/// Authors: Aditya Arora + +library; + +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'package:rattle/constants/spacing.dart'; +import 'package:rattle/providers/settings.dart'; +import 'package:rattle/utils/is_desktop.dart'; + +class WindowSize extends ConsumerStatefulWidget { + const WindowSize({super.key}); + + @override + ConsumerState createState() => _WindowSizeState(); +} + +class _WindowSizeState extends ConsumerState with WindowListener { + late final TextEditingController _widthController; + late final TextEditingController _heightController; + double? _currentWidth; + double? _currentHeight; + + @override + void initState() { + super.initState(); + _widthController = TextEditingController(); + _heightController = TextEditingController(); + windowManager.addListener(this); + _loadCurrentWindowSize(); + _loadSettings(); + } + + @override + void dispose() { + windowManager.removeListener(this); + _widthController.dispose(); + _heightController.dispose(); + super.dispose(); + } + + /// Load the current window size from the window manager + Future _loadCurrentWindowSize() async { + if (isDesktop) { + try { + final size = await windowManager.getSize(); + setState(() { + _currentWidth = size.width; + _currentHeight = size.height; + }); + } catch (e) { + debugPrint('Error loading current window size: $e'); + } + } + } + + /// Load settings from providers and update text controllers + void _loadSettings() { + final savedWidth = ref.read(windowWidthProvider); + final savedHeight = ref.read(windowHeightProvider); + + _widthController.text = savedWidth.toStringAsFixed(0); + _heightController.text = savedHeight.toStringAsFixed(0); + } + + /// Save window size settings to shared preferences + Future _saveWindowSizeSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final width = double.tryParse(_widthController.text); + final height = double.tryParse(_heightController.text); + + if (width != null && width > 0) { + ref.read(windowWidthProvider.notifier).state = width; + await prefs.setDouble('windowWidth', width); + } + + if (height != null && height > 0) { + ref.read(windowHeightProvider.notifier).state = height; + await prefs.setDouble('windowHeight', height); + } + + // Save remember window size setting + final rememberSize = ref.read(rememberWindowSizeProvider); + await prefs.setBool('rememberWindowSize', rememberSize); + } + + /// Apply the window size from the text fields to the actual window + Future _applyWindowSize() async { + final width = double.tryParse(_widthController.text); + final height = double.tryParse(_heightController.text); + + if (width != null && width > 0 && height != null && height > 0) { + try { + await windowManager.setSize(Size(width, height)); + await _loadCurrentWindowSize(); + await _saveWindowSizeSettings(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error applying window size: $e'), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + } + + /// Reset window size settings to defaults + Future _resetWindowSize() async { + ref.read(windowWidthProvider.notifier).state = defaultWindowWidth; + ref.read(windowHeightProvider.notifier).state = defaultWindowHeight; + ref.read(rememberWindowSizeProvider.notifier).state = true; + + _widthController.text = defaultWindowWidth.toStringAsFixed(0); + _heightController.text = defaultWindowHeight.toStringAsFixed(0); + + await windowManager + .setSize(const Size(defaultWindowWidth, defaultWindowHeight)); + + _saveWindowSizeSettings(); + } + + @override + void onWindowResize() async { + if (!mounted) return; + try { + final size = await windowManager.getSize(); + setState(() { + _currentWidth = size.width; + _currentHeight = size.height; + }); + } catch (e) { + debugPrint('Error updating window size on resize: $e'); + } + } + + @override + Widget build(BuildContext context) { + final rememberSize = ref.watch(rememberWindowSizeProvider); + final savedWidth = ref.watch(windowWidthProvider); + final savedHeight = ref.watch(windowHeightProvider); + + // Keep controllers in sync with providers + if (_widthController.text != savedWidth.toStringAsFixed(0)) { + _widthController.text = savedWidth.toStringAsFixed(0); + } + if (_heightController.text != savedHeight.toStringAsFixed(0)) { + _heightController.text = savedHeight.toStringAsFixed(0); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Window Size', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + configRowGap, + MarkdownTooltip( + message: ''' + + **Reset Window Size:** Tap here to reset the window size + settings to the default values (${defaultWindowWidth.toInt()}x${defaultWindowHeight.toInt()}). + + ''', + child: ElevatedButton( + onPressed: _resetWindowSize, + child: const Text('Reset'), + ), + ), + ], + ), + configRowGap, + // Display current window size + if (_currentWidth != null && _currentHeight != null) + Text( + 'Current window size: ${_currentWidth!.toInt()} × ${_currentHeight!.toInt()}', + style: const TextStyle( + fontSize: 16, + ), + ), + configRowGap, + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + MarkdownTooltip( + message: ''' + + **Window Width:** Set the desired window width in pixels. + This value will be used when the app starts up. + + ''', + child: SizedBox( + width: 150, + child: TextField( + controller: _widthController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Width (pixels)', + border: OutlineInputBorder(), + ), + ), + ), + ), + configRowGap, + MarkdownTooltip( + message: ''' + + **Window Height:** Set the desired window height in pixels. + This value will be used when the app starts up. + + ''', + child: SizedBox( + width: 150, + child: TextField( + controller: _heightController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Height (pixels)', + border: OutlineInputBorder(), + ), + ), + ), + ), + configRowGap, + ElevatedButton( + onPressed: _applyWindowSize, + child: const Text('Apply'), + ), + ], + ), + configRowGap, + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + MarkdownTooltip( + message: ''' + + **Remember Window Size on Exit:** When enabled, the current + window size will be automatically saved when you exit the app, + overriding any manually set values. When disabled, the window + size will only use the manually set values and will not be + updated on exit. + + ''', + child: Row( + children: [ + const Text( + 'Remember window size on exit', + style: TextStyle(fontSize: 16), + ), + configRowGap, + Switch( + value: rememberSize, + onChanged: (value) { + ref.read(rememberWindowSizeProvider.notifier).state = + value; + _saveWindowSizeSettings(); + }, + ), + ], + ), + ), + ], + ), + settingsGroupGap, + const Divider(), + ], + ); + } +} diff --git a/lib/widgets/close_dialog.dart b/lib/widgets/close_dialog.dart index febcac86a..a4908929b 100644 --- a/lib/widgets/close_dialog.dart +++ b/lib/widgets/close_dialog.dart @@ -120,7 +120,24 @@ class _CloseDialogState extends ConsumerState { ); } - void _closeApp() { + void _closeApp() async { + // save window size before closing if the setting is enabled + final rememberSize = ref.read(rememberWindowSizeProvider); + if (rememberSize) { + try { + final size = await WindowManager.instance.getSize(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('windowWidth', size.width); + await prefs.setDouble('windowHeight', size.height); + + // update providers to reflect saved values + ref.read(windowWidthProvider.notifier).state = size.width; + ref.read(windowHeightProvider.notifier).state = size.height; + } catch (e) { + debugPrint('Error saving window size: $e'); + } + } + Navigator.of(context).pop(); cleanUpTempDirs(); WindowManager.instance.setPreventClose(false);