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()) {