diff --git a/.changes/2029-backgrounding-and-tray-icon.md b/.changes/2029-backgrounding-and-tray-icon.md new file mode 100644 index 000000000000..cdfff0c4b4c4 --- /dev/null +++ b/.changes/2029-backgrounding-and-tray-icon.md @@ -0,0 +1 @@ +- Keep the app alive in background and close to the tray icon only (on supported platforms) diff --git a/app/assets/icon/comment.svg b/app/assets/icon/comment.svg deleted file mode 100644 index 5249ed142862..000000000000 --- a/app/assets/icon/comment.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/app/assets/icon/tray_icon.ico b/app/assets/icon/tray_icon.ico new file mode 100644 index 000000000000..02d0007970f6 Binary files /dev/null and b/app/assets/icon/tray_icon.ico differ diff --git a/app/assets/icon/tray_icon.png b/app/assets/icon/tray_icon.png new file mode 100644 index 000000000000..afda175b3aae Binary files /dev/null and b/app/assets/icon/tray_icon.png differ diff --git a/app/assets/icon/tray_icon.svg b/app/assets/icon/tray_icon.svg new file mode 100644 index 000000000000..1a3a8798fb0d --- /dev/null +++ b/app/assets/icon/tray_icon.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + diff --git a/app/assets/icon/forgot_password.svg b/app/assets/images/forgot_password.svg similarity index 100% rename from app/assets/icon/forgot_password.svg rename to app/assets/images/forgot_password.svg diff --git a/app/assets/icon/intro.png b/app/assets/images/intro.png similarity index 100% rename from app/assets/icon/intro.png rename to app/assets/images/intro.png diff --git a/app/assets/videos/.gitkeep b/app/assets/videos/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app/assets/videos/video.mp4 b/app/assets/videos/video.mp4 deleted file mode 100644 index a73d32e6ae4e..000000000000 Binary files a/app/assets/videos/video.mp4 and /dev/null differ diff --git a/app/lib/config/desktop.dart b/app/lib/config/desktop.dart new file mode 100644 index 000000000000..602e809835de --- /dev/null +++ b/app/lib/config/desktop.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:acter/common/utils/routes.dart'; +import 'package:acter/router/router.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::desktop'); + +class DesktopSupport extends StatefulWidget { + final Widget child; + + const DesktopSupport({super.key, required this.child}); + + @override + State createState() => _DesktopSupportState(); +} + +class _DesktopSupportState extends State + with WindowListener, TrayListener { + @override + void initState() { + super.initState(); + trayManager.addListener(this); + windowManager.addListener(this); + _initDesktop(); + } + + Future _initDesktop() async { + // Must add this line. + await windowManager.ensureInitialized(); + + WindowOptions windowOptions = const WindowOptions( + title: 'Acter', + titleBarStyle: TitleBarStyle.normal, + ); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + await windowManager.setPreventClose(true); + }); + + await trayManager.setIcon( + Platform.isWindows + ? 'assets/icon/tray_icon.ico' + : 'assets/icon/tray_icon.png', + ); + Menu menu = Menu( + items: [ + MenuItem( + key: 'home', + label: 'Home', + ), + MenuItem( + key: 'chat', + label: 'Chat', + ), + MenuItem( + key: 'activities', + label: 'Activities', + ), + MenuItem.separator(), + MenuItem( + key: 'exit_app', + label: 'Exit App', + ), + ], + ); + if (!Platform.isMacOS) { + // the menu crashes on macos if hidden for some reason. + await trayManager.setContextMenu(menu); + } + if (!Platform.isLinux) { + // not supported on linux; + await trayManager.setToolTip('Acter'); + } + } + + @override + void dispose() { + windowManager.removeListener(this); + trayManager.removeListener(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + @override + void onWindowClose() async { + bool isPreventClose = await windowManager.isPreventClose(); + if (isPreventClose) { + await windowManager.hide(); + } + } + + @override + void onTrayIconMouseDown() async { + // toggle visiblity + if (await windowManager.isVisible()) { + _log.info('hiding window on toggle'); + await windowManager.hide(); + } else { + _log.info('showing window on toggle'); + await windowManager.show(); + } + } + + @override + void onTrayIconRightMouseDown() async { + // do something + await trayManager.popUpContextMenu(); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) async { + if (menuItem.key == 'exit_app') { + _log.info('exit app'); + await windowManager.destroy(); + return; + } + + await windowManager.show(); + WidgetsBinding.instance.addPostFrameCallback((Duration duration) async { + if (menuItem.key == 'home') { + _log.info('route home'); + rootNavKey.currentContext!.pushNamed(Routes.main.name); + } else if (menuItem.key == 'chat') { + _log.info('route chat'); + rootNavKey.currentContext!.pushNamed(Routes.chat.name); + } else if (menuItem.key == 'activities') { + _log.info('route activities'); + rootNavKey.currentContext!.pushNamed(Routes.activities.name); + } + }); + } +} diff --git a/app/lib/features/auth/pages/forgot_password.dart b/app/lib/features/auth/pages/forgot_password.dart index b87305321ae3..4b798bc4f3db 100644 --- a/app/lib/features/auth/pages/forgot_password.dart +++ b/app/lib/features/auth/pages/forgot_password.dart @@ -130,7 +130,7 @@ class _AskForEmail extends StatelessWidget { var screenHeight = MediaQuery.of(context).size.height; var imageSize = screenHeight / 4; return SvgPicture.asset( - 'assets/icon/forgot_password.svg', + 'assets/images/forgot_password.svg', height: imageSize, width: imageSize, ); diff --git a/app/lib/features/intro/pages/intro_page.dart b/app/lib/features/intro/pages/intro_page.dart index 374f0cc55f91..75fbbec51dcc 100644 --- a/app/lib/features/intro/pages/intro_page.dart +++ b/app/lib/features/intro/pages/intro_page.dart @@ -68,7 +68,7 @@ class IntroPage extends StatelessWidget { // limit the to always show the button even if the keyboard is opened final imageSize = MediaQuery.of(context).size.height / 5; return Image.asset( - 'assets/icon/intro.png', + 'assets/images/intro.png', height: imageSize, width: imageSize, ); diff --git a/app/lib/main.dart b/app/lib/main.dart index ca9f15c6b2d9..fb2b2eb89357 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:acter/common/themes/app_theme.dart'; +import 'package:acter/config/desktop.dart'; import 'package:acter/config/notifications/init.dart'; import 'package:acter/common/providers/app_state_provider.dart'; import 'package:acter/common/themes/acter_theme.dart'; @@ -55,6 +57,10 @@ Future _startAppInner(Widget app, bool withSentry) async { await initLogging(); final initialLocationFromNotification = await initializeNotifications(); + if (isDesktop) { + app = DesktopSupport(child: app); + } + if (initialLocationFromNotification != null) { WidgetsBinding.instance.addPostFrameCallback((Duration duration) { // push after the next render to ensure we still have the "initial" location diff --git a/app/linux/my_application.cc b/app/linux/my_application.cc index 53c91d8dad09..e4424ea92ae2 100644 --- a/app/linux/my_application.cc +++ b/app/linux/my_application.cc @@ -17,8 +17,15 @@ G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GList *list = gtk_application_get_windows(GTK_APPLICATION(application)); + GtkWindow* existing_window = list ? GTK_WINDOW(list->data) : NULL; + + if (existing_window) { + gtk_window_present(existing_window); + return; + } + + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu @@ -99,6 +106,5 @@ static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); + "flags", nullptr)); } diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index f2a7d15f94af..65cbc1045496 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -54,6 +54,8 @@ PODS: - PromisesObjC (2.4.0) - screen_brightness_macos (0.1.0): - FlutterMacOS + - screen_retriever (0.0.1): + - FlutterMacOS - Sentry/HybridSDK (8.30.1) - sentry_flutter (8.4.0): - Flutter @@ -67,6 +69,8 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - tray_manager (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - video_player_avfoundation (0.0.1): @@ -74,6 +78,8 @@ PODS: - FlutterMacOS - wakelock_plus (0.0.1): - FlutterMacOS + - window_manager (0.2.0): + - FlutterMacOS DEPENDENCIES: - acter_flutter_sdk (from `Flutter/ephemeral/.symlinks/plugins/acter_flutter_sdk/macos`) @@ -93,13 +99,16 @@ DEPENDENCIES: - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`) + - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) SPEC REPOS: trunk: @@ -145,6 +154,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_brightness_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos + screen_retriever: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos sentry_flutter: :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos share_plus: @@ -153,12 +164,16 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos video_player_avfoundation: :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin wakelock_plus: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + window_manager: + :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: acter_flutter_sdk: a2ce9616c3d66cc8f3ca770b6832f320b9a7ea1b @@ -183,15 +198,18 @@ SPEC CHECKSUMS: path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda + screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 Sentry: 514a3ea653886e9a48c6287d8b7bf05ec24bf3be sentry_flutter: edc037f7af0dc1512d6c33a5c2c7c838bd0d6806 share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: ce7dbe26c78bfc7ba46736094c1e2d25982870fa -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift index d53ef6437726..be53980876d2 100644 --- a/app/macos/Runner/AppDelegate.swift +++ b/app/macos/Runner/AppDelegate.swift @@ -1,9 +1,21 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + for window in NSApp.windows { + if !window.isVisible { + window.setIsVisible(true) + } + window.makeKeyAndOrderFront(self) + NSApp.activate(ignoringOtherApps: true) + } + } + return true + } } diff --git a/app/pubspec.lock b/app/pubspec.lock index b1635ba2287c..cc95ff69347a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1428,6 +1428,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.4" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -1910,6 +1918,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" + url: "https://pub.dev" + source: hosted + version: "0.1.9" screenshot: dependency: "direct main" description: @@ -2085,6 +2101,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" skeletonizer: dependency: "direct main" description: @@ -2274,6 +2298,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.5" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: c9a63fd88bd3546287a7eb8ccc978d707eef82c775397af17dda3a4f4c039e64 + url: "https://pub.dev" + source: hosted + version: "0.2.3" tuple: dependency: transitive description: @@ -2570,6 +2602,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + window_manager: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "7fdebf9b1b3dac140d10590306ad068a2abb6bf4" + url: "https://github.com/acterglobal/window_manager" + source: git + version: "0.4.0" windows_notification: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8a8bc3ab1dd2..f96b4be3d326 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -129,6 +129,13 @@ dependencies: scrolls_to_top: ^2.1.1 scrollable_positioned_list: ^0.3.8 diffutil_dart: ^4.0.1 + tray_manager: ^0.2.3 + window_manager: #^0.4.0 + # path: ../../window_manager + git: + url: https://github.com/acterglobal/window_manager + ref: main + shake_detector: path: ../packages/shake_detector diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp index 9d45e73663e0..4e886940ceae 100644 --- a/app/windows/runner/main.cpp +++ b/app/windows/runner/main.cpp @@ -7,6 +7,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + + HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", L"Acter"); + if (hwnd != NULL) { + ::ShowWindow(hwnd, SW_NORMAL); + ::SetForegroundWindow(hwnd); + return EXIT_FAILURE; + } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {