diff --git a/lib/main.dart b/lib/main.dart index 00248124..deb761fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,6 +80,9 @@ Future main(List args) async { // can run the [SplashScreen] widget as the app. if (isDesktopPlatform) { await configureWindow(); + if (canLaunchAtStartup) setupLaunchAtStartup(); + if (canUseSystemTray) setupSystemTray(); + runApp(const SplashScreen()); } diff --git a/lib/providers/home_provider.dart b/lib/providers/home_provider.dart index 415fa043..894944b4 100644 --- a/lib/providers/home_provider.dart +++ b/lib/providers/home_provider.dart @@ -120,7 +120,7 @@ class HomeProvider extends ChangeNotifier { /// they come back. Map volumes = {}; - Future setTab(UnityTab tab, BuildContext context) async { + Future setTab(UnityTab tab, [BuildContext? context]) async { if (tab == this.tab) return; final currentTab = this.tab; @@ -159,8 +159,10 @@ class HomeProvider extends ChangeNotifier { volumes.clear(); } - refreshDeviceOrientation(context); - updateWakelock(context); + if (context != null) { + refreshDeviceOrientation(context); + updateWakelock(context); + } notifyListeners(); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index d8d0281e..aa9c30f9 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -26,15 +26,19 @@ import 'package:bluecherry_client/providers/update_provider.dart'; import 'package:bluecherry_client/screens/events_timeline/desktop/timeline.dart'; import 'package:bluecherry_client/screens/settings/shared/options_chooser_tile.dart'; import 'package:bluecherry_client/utils/logging.dart'; +import 'package:bluecherry_client/utils/methods.dart'; import 'package:bluecherry_client/utils/storage.dart'; import 'package:bluecherry_client/utils/video_player.dart'; import 'package:bluecherry_client/widgets/hover_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; +import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:unity_video_player/unity_video_player.dart'; import 'package:unity_video_player_main/unity_video_player_main.dart'; +import 'package:window_manager/window_manager.dart'; enum NetworkUsage { auto, @@ -97,16 +101,16 @@ class _SettingsOption { late final String Function(dynamic value) saveAs; late final T Function(String value) loadFrom; - final ValueChanged? onChanged; + final Future Function(T)? onChanged; final T Function(dynamic value)? valueOverrider; late T _value; T get value => valueOverrider?.call(_value) ?? _value; set value(T newValue) { - SettingsProvider.instance.updateProperty(() { + SettingsProvider.instance.updateProperty(() async { _value = newValue; - onChanged?.call(value); + await onChanged?.call(value); }); } @@ -453,6 +457,48 @@ class SettingsProvider extends UnityProvider { final kLaunchAppOnStartup = _SettingsOption( def: false, key: 'window.launch_app_on_startup', + getDefault: kIsWeb ? null : launchAtStartup.isEnabled, + onChanged: (value) async { + if (kIsWeb) { + return; + } + if (value) { + await launchAtStartup.enable(); + } else { + await launchAtStartup.disable(); + } + }, + ); + final kFullscreen = _SettingsOption( + def: false, + key: 'window.fullscreen', + getDefault: () async { + if (!isDesktopPlatform) return false; + return windowManager.isFullScreen(); + }, + onChanged: (value) async { + if (!isDesktopPlatform) return; + await windowManager.setFullScreen(value); + }, + ); + final kImmersiveMode = _SettingsOption( + def: false, + key: 'window.immersive_mode', + onChanged: (value) async { + if (isMobilePlatform) { + if (value) { + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersive, + overlays: [], + ); + } else { + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + } + } + }, ); final kMinimizeToTray = _SettingsOption( def: false, @@ -528,7 +574,7 @@ class SettingsProvider extends UnityProvider { final kSoftwareZooming = _SettingsOption( def: isHardwareZoomSupported ? true : false, key: 'other.software_zoom', - onChanged: (value) { + onChanged: (value) async { for (final player in UnityPlayers.players.values) { player ..resetCrop() @@ -592,6 +638,8 @@ class SettingsProvider extends UnityProvider { kTimeFormat, kConvertTimeToLocalTimezone, kLaunchAppOnStartup, + kFullscreen, + kImmersiveMode, kMinimizeToTray, kAnimationsEnabled, kHighContrast, @@ -659,8 +707,8 @@ class SettingsProvider extends UnityProvider { super.save(notifyListeners: notifyListeners); } - void updateProperty(VoidCallback update) { - update(); + Future updateProperty(Future Function() update) async { + await update(); save(); } diff --git a/lib/screens/settings/application.dart b/lib/screens/settings/application.dart index 163be6b1..7ce30dff 100644 --- a/lib/screens/settings/application.dart +++ b/lib/screens/settings/application.dart @@ -21,6 +21,8 @@ import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/screens/settings/settings_desktop.dart'; import 'package:bluecherry_client/screens/settings/shared/options_chooser_tile.dart'; import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/utils/methods.dart'; +import 'package:bluecherry_client/utils/window.dart'; import 'package:bluecherry_client/widgets/misc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -69,6 +71,7 @@ class ApplicationSettings extends StatelessWidget { settings.kThemeMode.value = v; }, ), + if (isMobilePlatform) _buildImmersiveModeTile(), const LanguageSection(), SubHeader( loc.dateAndTime, @@ -93,45 +96,64 @@ class ApplicationSettings extends StatelessWidget { subtitle: Text(loc.convertToLocalTimeDescription), isThreeLine: true, ), - if (settings.kShowDebugInfo.value) ...[ + if (isDesktopPlatform) ...[ const SubHeader('Window'), - CheckboxListTile.adaptive( - value: settings.kLaunchAppOnStartup.value, - onChanged: (v) { - if (v != null) { - settings.kLaunchAppOnStartup.value = v; - } - }, - contentPadding: DesktopSettings.horizontalPadding, - secondary: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundColor: theme.iconTheme.color, - child: const Icon(Icons.launch), - ), - title: const Text('Launch app on startup'), - subtitle: const Text( - 'Whether to launchthe app when the system starts', + if (canLaunchAtStartup) + CheckboxListTile.adaptive( + value: settings.kLaunchAppOnStartup.value, + onChanged: (v) { + if (v != null) { + settings.kLaunchAppOnStartup.value = v; + } + }, + contentPadding: DesktopSettings.horizontalPadding, + secondary: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.launch), + ), + title: const Text('Launch app on startup'), + subtitle: const Text( + 'Whether to launch the app when the system starts', + ), ), - ), CheckboxListTile.adaptive( - value: settings.kMinimizeToTray.value, + value: settings.kFullscreen.value, onChanged: (v) { - if (v != null) { - settings.kMinimizeToTray.value = v; - } + if (v != null) settings.kFullscreen.value = v; }, contentPadding: DesktopSettings.horizontalPadding, secondary: CircleAvatar( backgroundColor: Colors.transparent, foregroundColor: theme.iconTheme.color, - child: const Icon(Icons.sensor_door), - ), - title: const Text('Minimize to tray'), - subtitle: const Text( - 'Whether to minimize app to the system tray when the window is closed. ' - 'This will keep the app running in the background.', + child: const Icon(Icons.fullscreen), ), + title: const Text('Fullscreen Mode'), + subtitle: const Text('Whether the app is in fullscreen mode or not.'), ), + _buildImmersiveModeTile(), + if (canUseSystemTray) + CheckboxListTile.adaptive( + value: settings.kMinimizeToTray.value, + onChanged: (v) { + if (v != null) { + settings.kMinimizeToTray.value = v; + } + }, + contentPadding: DesktopSettings.horizontalPadding, + secondary: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.sensor_door), + ), + title: const Text('Minimize to tray'), + subtitle: const Text( + 'Whether to minimize app to the system tray when the window is ' + 'closed. This will keep the app running in the background.', + ), + ), + ], + if (settings.kShowDebugInfo.value) ...[ const SubHeader('Acessibility'), CheckboxListTile.adaptive( value: settings.kAnimationsEnabled.value, @@ -191,6 +213,41 @@ class ApplicationSettings extends StatelessWidget { ], ]); } + + /// Creates the Immersive Mode tile. + /// + /// On Desktop, this is used alonside the Fullscreen mode tile. When in + /// fullscreen, the immersive mode hides the top bar and only shows it when + /// the user hovers over the top of the window. + /// + /// On Mobile, this makes the app full-screen and hides the system UI. + Widget _buildImmersiveModeTile() { + return Builder(builder: (context) { + final theme = Theme.of(context); + final settings = context.watch(); + + return CheckboxListTile.adaptive( + value: settings.kImmersiveMode.value, + onChanged: settings.kFullscreen.value || isMobilePlatform + ? (v) { + if (v != null) settings.kImmersiveMode.value = v; + } + : null, + contentPadding: DesktopSettings.horizontalPadding, + secondary: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.web_asset), + ), + title: const Text('Immersive Mode'), + subtitle: const Text( + 'This will hide the title bar and window controls. ' + 'To show the top bar, hover over the top of the window. ' + 'This only works in fullscreen mode.', + ), + ); + }); + } } class LanguageSection extends StatelessWidget { diff --git a/lib/utils/window.dart b/lib/utils/window.dart index 818350f9..319449ba 100644 --- a/lib/utils/window.dart +++ b/lib/utils/window.dart @@ -27,6 +27,9 @@ import 'package:bluecherry_client/providers/update_provider.dart'; import 'package:bluecherry_client/utils/methods.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:launch_at_startup/launch_at_startup.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +// import 'package:tray_manager/tray_manager.dart'; import 'package:unity_multi_window/unity_multi_window.dart'; import 'package:window_manager/window_manager.dart'; @@ -150,3 +153,95 @@ void launchFileExplorer(String path) { ); } } + +/// It is only possible to launch at startup on Desktop Systems. +/// +/// MacOS is still lacking configuration at this time, so we are not supporting +/// it for now. +bool get canLaunchAtStartup => isDesktopPlatform && !Platform.isMacOS; + +Future setupLaunchAtStartup() async { + assert(isDesktopPlatform); + final packageInfo = await PackageInfo.fromPlatform(); + + launchAtStartup.setup( + appName: packageInfo.appName, + appPath: Platform.resolvedExecutable, + // Set packageName parameter to support MSIX. + // This is required to check if the app is running in MSIX container. + // We do not support MSIX for now. + // packageName: 'dev.leanflutter.examples.launchatstartupexample', + ); +} + +// System tray is temporarily disabled due to issues with the `tray_manager` +// plugin. It will be re-enabled once the issues are resolved. +// bool get canUseSystemTray => isDesktopPlatform && !Platform.isLinux; +bool get canUseSystemTray => false; + +Future setupSystemTray() async { + assert(canUseSystemTray); + + // await trayManager.destroy(); + // await trayManager.setIcon( + // Platform.isWindows ? 'assets/images/icon.ico' : 'assets/images/icon.png', + // ); + // final menu = Menu(items: [ + // MenuItem(key: 'screens', label: 'Layouts'), + // MenuItem(key: 'timeline_of_events', label: 'Timeline of Events'), + // MenuItem(key: 'events_browser', label: 'Events Browser'), + // MenuItem(key: 'downloads', label: 'Downloads'), + // MenuItem(key: 'settings', label: 'Settings'), + // MenuItem.separator(), + // MenuItem(key: 'quit', label: 'Quit bluecherry'), + // ]); + + // await trayManager.setContextMenu(menu); + // await trayManager.setTitle('Bluecherry'); + // await trayManager.setToolTip('Bluecherry Client'); + + // trayManager.addListener(UnityTrayListener()); +} + +// class UnityTrayListener with TrayListener { +// @override +// void onTrayIconMouseDown() { +// debugPrint('Tray icon mouse down'); +// windowManager.show(); +// } + +// @override +// void onTrayIconRightMouseDown() { +// debugPrint('Tray icon right mouse down'); +// // trayManager.popUpContextMenu(); +// } + +// @override +// void onTrayIconRightMouseUp() { +// debugPrint('Tray icon right mouse up'); +// } + +// @override +// void onTrayMenuItemClick(MenuItem menuItem) { +// switch (menuItem.key) { +// case 'screens': +// HomeProvider.instance.setTab(UnityTab.deviceGrid); +// break; +// case 'timeline_of_events': +// HomeProvider.instance.setTab(UnityTab.eventsTimeline); +// break; +// case 'events_browser': +// HomeProvider.instance.setTab(UnityTab.eventsHistory); +// break; +// case 'downloads': +// HomeProvider.instance.setTab(UnityTab.downloads); +// break; +// case 'settings': +// HomeProvider.instance.setTab(UnityTab.settings); +// break; +// case 'quit': +// windowManager.close(); +// break; +// } +// } +// } diff --git a/lib/widgets/desktop_buttons.dart b/lib/widgets/desktop_buttons.dart index d76f47e5..21068325 100644 --- a/lib/widgets/desktop_buttons.dart +++ b/lib/widgets/desktop_buttons.dart @@ -24,6 +24,7 @@ import 'package:bluecherry_client/main.dart'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/providers/update_provider.dart'; import 'package:bluecherry_client/screens/events_browser/events_screen.dart'; import 'package:bluecherry_client/screens/events_timeline/events_playback.dart'; @@ -80,7 +81,7 @@ class NObserver extends NavigatorObserver { } } -class WindowButtons extends StatelessWidget { +class WindowButtons extends StatefulWidget { const WindowButtons({ super.key, this.title, @@ -108,6 +109,23 @@ class WindowButtons extends StatelessWidget { /// The widget displayed in the remaining space. final Widget? flexible; + @override + State createState() => _WindowButtonsState(); +} + +class _WindowButtonsState extends State + with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!isDesktop || isMobile) return const SizedBox.shrink(); @@ -115,6 +133,7 @@ class WindowButtons extends StatelessWidget { final theme = Theme.of(context); final loc = AppLocalizations.of(context); + final settings = context.watch(); final home = context.watch(); final tab = home.tab; @@ -127,18 +146,18 @@ class WindowButtons extends StatelessWidget { final isLinuxPlatform = !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; final centerTitle = - (AppBarTheme.of(context).centerTitle ?? false) && !showNavigator; + (AppBarTheme.of(context).centerTitle ?? false) && !widget.showNavigator; - return StreamBuilder( + final bar = StreamBuilder( stream: navigationStream.stream, builder: (context, arguments) { final canPop = (navigatorKey.currentState?.canPop() ?? false) && navigatorObserver.poppableRoute; - final showNavigator = !canPop && this.showNavigator; + final showNavigator = !canPop && widget.showNavigator; final titleWidget = Text( () { - if (title != null) return title!; + if (widget.title != null) return widget.title!; if (arguments.data != null) { if (arguments.data is Event) { @@ -154,7 +173,7 @@ class WindowButtons extends StatelessWidget { // If it is in another screen, show the title or fallback to "Bluecherry" if (tab.index >= UnityTab.values.length) { - return title ?? 'Bluecherry'; + return widget.title ?? 'Bluecherry'; } if (!(isMacOSPlatform || kIsWeb)) { @@ -190,7 +209,7 @@ class WindowButtons extends StatelessWidget { padding: const EdgeInsetsDirectional.only(start: 8.0), child: SquaredIconButton( onPressed: () async { - await onBack?.call(); + await widget.onBack?.call(); await navigatorKey.currentState?.maybePop(); }, tooltip: @@ -244,11 +263,14 @@ class WindowButtons extends StatelessWidget { icon: const Icon(Icons.refresh, size: 20.0), tooltip: loc.refresh, ), - if (flexible != null) flexible!, - - // Do not render the Window Buttons on web nor macOS. macOS - // render the buttons natively. - if (!kIsWeb && !isMacOSPlatform && !UpdateManager.isEmbedded) + if (widget.flexible != null) widget.flexible!, + + // Do not render the Window Buttons on web nor macOS nor when + // in fullscreen. macOS render the buttons natively. + if (!kIsWeb && + !isMacOSPlatform && + !UpdateManager.isEmbedded && + !settings.kFullscreen.value) SizedBox( width: 138, child: Builder(builder: (context) { @@ -268,9 +290,7 @@ class WindowButtons extends StatelessWidget { } }, ), - DecoratedCloseButton( - onPressed: windowManager.close, - ), + DecoratedCloseButton(onPressed: close), ].map((button) { return Padding( padding: const EdgeInsetsDirectional.all(2.0), @@ -279,10 +299,40 @@ class WindowButtons extends StatelessWidget { }).toList(), ); } - return WindowCaption( - brightness: theme.brightness, - backgroundColor: Colors.transparent, - ); + return Row(children: [ + WindowCaptionButton.minimize( + brightness: theme.brightness, + onPressed: () async { + final isMinimized = + await windowManager.isMinimized(); + if (isMinimized) { + windowManager.restore(); + } else { + windowManager.minimize(); + } + }, + ), + FutureBuilder( + future: windowManager.isMaximized(), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.data == true) { + return WindowCaptionButton.unmaximize( + brightness: theme.brightness, + onPressed: windowManager.unmaximize, + ); + } + return WindowCaptionButton.maximize( + brightness: theme.brightness, + onPressed: windowManager.maximize, + ); + }, + ), + WindowCaptionButton.close( + brightness: theme.brightness, + onPressed: close, + ), + ]); }), ), ]), @@ -323,6 +373,76 @@ class WindowButtons extends StatelessWidget { ); }, ); + + if (settings.kFullscreen.value && settings.kImmersiveMode.value) { + return MouseRegion( + onEnter: (_) { + showOverlayEntry(context, bar); + }, + child: const SizedBox( + height: 4, + width: double.infinity, + ), + ); + } + + return bar; + } + + OverlayEntry? _overlayEntry; + void showOverlayEntry(BuildContext context, Widget bar) { + _overlayEntry = OverlayEntry(builder: (context) { + return AnimatedBuilder( + animation: _animationController, + child: bar, + builder: (context, animation) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + ignoring: _animationController.status == AnimationStatus.forward, + child: MouseRegion( + onExit: (_) { + dismissOverlayEntry(); + }, + child: Material( + color: Colors.transparent, + elevation: 8, + child: SlideTransition( + position: Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )), + child: animation, + ), + ), + ), + ), + ); + }, + ); + }); + Overlay.of(context).insert(_overlayEntry!); + _animationController.forward(); + } + + Future dismissOverlayEntry() async { + await _animationController.reverse(); + _overlayEntry?.remove(); + _overlayEntry = null; + } + + Future close() { + final settings = context.read(); + if (settings.kMinimizeToTray.value) { + return windowManager.hide(); + } else { + return windowManager.close(); + } } } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..b6e45f2f 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,5 +1,8 @@ +// This file was modified according to https://pub.dev/packages/launch_at_startup#macos-support + import Cocoa import FlutterMacOS +import LaunchAtLogin // launch_at_startup class MainFlutterWindow: NSWindow { override func awakeFromNib() { @@ -8,6 +11,26 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + // + // Add FlutterMethodChannel platform code + FlutterMethodChannel( + name: "launch_at_startup", binaryMessenger: flutterViewController.engine.binaryMessenger + ) + .setMethodCallHandler { (_ call: FlutterMethodCall, result: @escaping FlutterResult) in + switch call.method { + case "launchAtStartupIsEnabled": + result(LaunchAtLogin.isEnabled) + case "launchAtStartupSetEnabled": + if let arguments = call.arguments as? [String: Any] { + LaunchAtLogin.isEnabled = arguments["setEnabledValue"] as! Bool + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + // + RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() diff --git a/pubspec.lock b/pubspec.lock index c4f31236..0ea938b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -493,6 +493,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.0" + launch_at_startup: + dependency: "direct main" + description: + name: launch_at_startup + sha256: "1f8a75520913d1038630049e6c44a2575a23ffd28cc8b14fdf37401d1d21de84" + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 016178cf..5b4e3ec3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: # Desktop window_manager: ^0.4.2 titlebar_buttons: ^1.0.0 + launch_at_startup: ^0.3.1 + # tray_manager: ^0.2.4 unity_multi_window: path: packages/unity_multi_window/