From c4db17f73c3634a47d3c9d56744484f361e724eb Mon Sep 17 00:00:00 2001
From: Vincent Chan <okcdz@diverse.space>
Date: Fri, 27 May 2022 10:34:12 +0800
Subject: [PATCH] feat: frameless window for mac

---
 .../app_flowy/lib/core/frameless_window.dart  | 67 ++++++++++++++++++
 .../workspace/application/home/home_bloc.dart |  6 ++
 .../workspace/application/menu/menu_bloc.dart |  7 --
 .../presentation/home/home_stack.dart         | 15 +++-
 .../presentation/home/menu/menu.dart          | 34 ++++++---
 .../presentation/home/navigation.dart         |  2 +
 .../macos/Runner/MainFlutterWindow.swift      | 70 +++++++++++++++++++
 7 files changed, 183 insertions(+), 18 deletions(-)
 create mode 100644 frontend/app_flowy/lib/core/frameless_window.dart

diff --git a/frontend/app_flowy/lib/core/frameless_window.dart b/frontend/app_flowy/lib/core/frameless_window.dart
new file mode 100644
index 0000000000..a7d6417cd3
--- /dev/null
+++ b/frontend/app_flowy/lib/core/frameless_window.dart
@@ -0,0 +1,67 @@
+import 'package:flutter/services.dart';
+import 'package:flutter/material.dart';
+import 'dart:io' show Platform;
+
+class CocoaWindowChannel {
+  CocoaWindowChannel._();
+
+  final MethodChannel _channel = const MethodChannel("flutter/cocoaWindow");
+
+  static final CocoaWindowChannel instance = CocoaWindowChannel._();
+
+  Future<void> setWindowPosition(Offset offset) async {
+    await _channel.invokeMethod("setWindowPosition", [offset.dx, offset.dy]);
+  }
+
+  Future<List<double>> getWindowPosition() async {
+    final raw = await _channel.invokeMethod("getWindowPosition");
+    final arr = raw as List<dynamic>;
+    final List<double> result = arr.map((s) => s as double).toList();
+    return result;
+  }
+
+  Future<void> zoom() async {
+    await _channel.invokeMethod("zoom");
+  }
+}
+
+class MoveWindowDetector extends StatefulWidget {
+  const MoveWindowDetector({Key? key, this.child}) : super(key: key);
+
+  final Widget? child;
+
+  @override
+  _MoveWindowDetectorState createState() => _MoveWindowDetectorState();
+}
+
+class _MoveWindowDetectorState extends State<MoveWindowDetector> {
+  double winX = 0;
+  double winY = 0;
+
+  @override
+  Widget build(BuildContext context) {
+    if (!Platform.isMacOS) {
+      return widget.child ?? Container();
+    }
+    return GestureDetector(
+      // https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack
+      behavior: HitTestBehavior.translucent,
+      onDoubleTap: () async {
+        await CocoaWindowChannel.instance.zoom();
+      },
+      onPanStart: (DragStartDetails details) {
+        winX = details.globalPosition.dx;
+        winY = details.globalPosition.dy;
+      },
+      onPanUpdate: (DragUpdateDetails details) async {
+        final windowPos = await CocoaWindowChannel.instance.getWindowPosition();
+        final double dx = windowPos[0];
+        final double dy = windowPos[1];
+        final deltaX = details.globalPosition.dx - winX;
+        final deltaY = details.globalPosition.dy - winY;
+        await CocoaWindowChannel.instance.setWindowPosition(Offset(dx + deltaX, dy - deltaY));
+      },
+      child: widget.child,
+    );
+  }
+}
diff --git a/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart b/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart
index 796a0357b9..f3d9930842 100644
--- a/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart
+++ b/frontend/app_flowy/lib/workspace/application/home/home_bloc.dart
@@ -49,6 +49,9 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
         unauthorized: (_Unauthorized value) {
           emit(state.copyWith(unauthorized: true));
         },
+        collapseMenu: (e) {
+          emit(state.copyWith(isMenuCollapsed: !state.isMenuCollapsed));
+        },
       );
     });
   }
@@ -77,6 +80,7 @@ class HomeEvent with _$HomeEvent {
   const factory HomeEvent.dismissEditPannel() = _DismissEditPannel;
   const factory HomeEvent.didReceiveWorkspaceSetting(CurrentWorkspaceSetting setting) = _DidReceiveWorkspaceSetting;
   const factory HomeEvent.unauthorized(String msg) = _Unauthorized;
+  const factory HomeEvent.collapseMenu() = _CollapseMenu;
 }
 
 @freezed
@@ -87,6 +91,7 @@ class HomeState with _$HomeState {
     required Option<EditPannelContext> pannelContext,
     required CurrentWorkspaceSetting workspaceSetting,
     required bool unauthorized,
+    required bool isMenuCollapsed,
   }) = _HomeState;
 
   factory HomeState.initial(CurrentWorkspaceSetting workspaceSetting) => HomeState(
@@ -95,5 +100,6 @@ class HomeState with _$HomeState {
         pannelContext: none(),
         workspaceSetting: workspaceSetting,
         unauthorized: false,
+        isMenuCollapsed: false,
       );
 }
diff --git a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart b/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart
index a2c167cde4..db8f2c534b 100644
--- a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart
+++ b/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart
@@ -25,10 +25,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
           listener.start(addAppCallback: _handleAppsOrFail);
           await _fetchApps(emit);
         },
-        collapse: (e) async {
-          final isCollapse = state.isCollapse;
-          emit(state.copyWith(isCollapse: !isCollapse));
-        },
         openPage: (e) async {
           emit(state.copyWith(plugin: e.plugin));
         },
@@ -94,7 +90,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
 @freezed
 class MenuEvent with _$MenuEvent {
   const factory MenuEvent.initial() = _Initial;
-  const factory MenuEvent.collapse() = _Collapse;
   const factory MenuEvent.openPage(Plugin plugin) = _OpenPage;
   const factory MenuEvent.createApp(String name, {String? desc}) = _CreateApp;
   const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;
@@ -104,14 +99,12 @@ class MenuEvent with _$MenuEvent {
 @freezed
 class MenuState with _$MenuState {
   const factory MenuState({
-    required bool isCollapse,
     required List<App> apps,
     required Either<Unit, FlowyError> successOrFailure,
     required Plugin plugin,
   }) = _MenuState;
 
   factory MenuState.initial() => MenuState(
-        isCollapse: false,
         apps: [],
         successOrFailure: left(unit),
         plugin: makePlugin(pluginType: DefaultPlugin.blank.type()),
diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart
index 65e315f56d..c16c965a82 100644
--- a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart
+++ b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart
@@ -1,8 +1,12 @@
+import 'dart:io' show Platform;
+
 import 'package:app_flowy/startup/startup.dart';
+import 'package:app_flowy/workspace/application/home/home_bloc.dart';
 import 'package:app_flowy/workspace/presentation/home/home_screen.dart';
 import 'package:flowy_infra/theme.dart';
 import 'package:flowy_sdk/log.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:provider/provider.dart';
 import 'package:time/time.dart';
 import 'package:fluttertoast/fluttertoast.dart';
@@ -11,6 +15,7 @@ import 'package:app_flowy/plugin/plugin.dart';
 import 'package:app_flowy/workspace/presentation/plugins/blank/blank.dart';
 import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
 import 'package:app_flowy/workspace/presentation/home/navigation.dart';
+import 'package:app_flowy/core/frameless_window.dart';
 import 'package:flowy_infra_ui/widget/spacing.dart';
 import 'package:flowy_infra_ui/style_widget/extension.dart';
 import 'package:flowy_infra/notifier.dart';
@@ -152,7 +157,7 @@ class HomeStackManager {
       child: Selector<HomeStackNotifier, Widget>(
         selector: (context, notifier) => notifier.titleWidget,
         builder: (context, widget, child) {
-          return const HomeTopBar();
+          return const MoveWindowDetector(child: HomeTopBar());
         },
       ),
     );
@@ -191,6 +196,14 @@ class HomeTopBar extends StatelessWidget {
       child: Row(
         crossAxisAlignment: CrossAxisAlignment.center,
         children: [
+          BlocBuilder<HomeBloc, HomeState>(
+              buildWhen: ((previous, current) => previous.isMenuCollapsed != current.isMenuCollapsed),
+              builder: (context, state) {
+                if (state.isMenuCollapsed && Platform.isMacOS) {
+                  return const HSpace(80);
+                }
+                return const HSpace(0);
+              }),
           const FlowyNavigation(),
           const HSpace(16),
           ChangeNotifierProvider.value(
diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart
index b888ab7631..0eb22e3d1f 100644
--- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart
+++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart
@@ -1,6 +1,8 @@
 export './app/header/header.dart';
 export './app/menu_app.dart';
 
+import 'dart:io' show Platform;
+import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
 import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
 import 'package:app_flowy/workspace/presentation/plugins/trash/menu.dart';
 import 'package:flowy_infra/notifier.dart';
@@ -18,7 +20,9 @@ import 'package:expandable/expandable.dart';
 import 'package:flowy_infra/time/duration.dart';
 import 'package:app_flowy/startup/startup.dart';
 import 'package:app_flowy/workspace/application/menu/menu_bloc.dart';
-import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
+import 'package:app_flowy/workspace/application/home/home_bloc.dart';
+import 'package:app_flowy/core/frameless_window.dart';
+// import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra_ui/style_widget/icon_button.dart';
 
@@ -59,10 +63,10 @@ class HomeMenu extends StatelessWidget {
               getIt<HomeStackManager>().setPlugin(state.plugin);
             },
           ),
-          BlocListener<MenuBloc, MenuState>(
-            listenWhen: (p, c) => p.isCollapse != c.isCollapse,
+          BlocListener<HomeBloc, HomeState>(
+            listenWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed,
             listener: (context, state) {
-              _collapsedNotifier.value = state.isCollapse;
+              _collapsedNotifier.value = state.isMenuCollapsed;
             },
           )
         ],
@@ -179,6 +183,17 @@ class MenuSharedState {
 
 class MenuTopBar extends StatelessWidget {
   const MenuTopBar({Key? key}) : super(key: key);
+
+  Widget renderIcon(BuildContext context) {
+    if (Platform.isMacOS) {
+      return Container();
+    }
+    final theme = context.watch<AppTheme>();
+    return (theme.isDark
+        ? svgWithSize("flowy_logo_dark_mode", const Size(92, 17))
+        : svgWithSize("flowy_logo_with_text", const Size(92, 17)));
+  }
+
   @override
   Widget build(BuildContext context) {
     final theme = context.watch<AppTheme>();
@@ -186,20 +201,19 @@ class MenuTopBar extends StatelessWidget {
       builder: (context, state) {
         return SizedBox(
           height: HomeSizes.topBarHeight,
-          child: Row(
+          child: MoveWindowDetector(
+              child: Row(
             children: [
-              (theme.isDark
-                  ? svgWithSize("flowy_logo_dark_mode", const Size(92, 17))
-                  : svgWithSize("flowy_logo_with_text", const Size(92, 17))),
+              renderIcon(context),
               const Spacer(),
               FlowyIconButton(
                 width: 28,
-                onPressed: () => context.read<MenuBloc>().add(const MenuEvent.collapse()),
+                onPressed: () => context.read<HomeBloc>().add(const HomeEvent.collapseMenu()),
                 iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
                 icon: svgWidget("home/hide_menu", color: theme.iconColor),
               )
             ],
-          ),
+          )),
         );
       },
     );
diff --git a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart b/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart
index b6947c79fe..f52b7224f6 100644
--- a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart
+++ b/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart
@@ -1,3 +1,4 @@
+import 'package:app_flowy/workspace/application/home/home_bloc.dart';
 import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
 import 'package:flowy_infra/image.dart';
 import 'package:flowy_infra/notifier.dart';
@@ -95,6 +96,7 @@ class FlowyNavigation extends StatelessWidget {
                 width: 24,
                 onPressed: () {
                   notifier.value = false;
+                  ctx.read<HomeBloc>().add(const HomeEvent.collapseMenu());
                 },
                 iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
                 icon: svgWidget("home/hide_menu", color: theme.iconColor),
diff --git a/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift b/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift
index 2722837ec9..8e357d7ca1 100644
--- a/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift
+++ b/frontend/app_flowy/macos/Runner/MainFlutterWindow.swift
@@ -1,12 +1,82 @@
 import Cocoa
 import FlutterMacOS
 
+private let kTrafficLightOffetTop = 22
+
 class MainFlutterWindow: NSWindow {
+  func registerMethodChannel(flutterViewController: FlutterViewController) {
+    let cocoaWindowChannel = FlutterMethodChannel(name: "flutter/cocoaWindow", binaryMessenger: flutterViewController.engine.binaryMessenger)
+    cocoaWindowChannel.setMethodCallHandler({
+      (call: FlutterMethodCall, result: FlutterResult) -> Void in
+      if call.method == "setWindowPosition" {
+        guard let position = call.arguments as? NSArray else {
+          result(nil)
+          return
+        }
+        let nX = position[0] as! NSNumber
+        let nY = position[1] as! NSNumber
+        let x = nX.doubleValue
+        let y = nY.doubleValue
+        
+        self.setFrameOrigin(NSPoint(x: x, y: y))
+        result(nil)
+        return
+      } else if call.method == "getWindowPosition" {
+        let frame = self.frame
+        result([frame.origin.x, frame.origin.y])
+        return
+      } else if call.method == "zoom" {
+        self.zoom(self)
+        result(nil)
+        return
+      }
+      
+      result(FlutterMethodNotImplemented)
+    })
+  }
+
+  func layoutTrafficLightButton(titlebarView: NSView, button: NSButton, offsetTop: CGFloat, offsetLeft: CGFloat) {
+    button.translatesAutoresizingMaskIntoConstraints = false;
+    titlebarView.addConstraint(NSLayoutConstraint.init(
+      item: button,
+      attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1, constant: offsetTop))
+    titlebarView.addConstraint(NSLayoutConstraint.init(
+      item: button,
+      attribute: NSLayoutConstraint.Attribute.left, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.left, multiplier: 1, constant: offsetLeft))
+  }
+
+  func layoutTrafficLights() {
+    let closeButton = self.standardWindowButton(ButtonType.closeButton)!
+    let minButton = self.standardWindowButton(ButtonType.miniaturizeButton)!
+    let zoomButton = self.standardWindowButton(ButtonType.zoomButton)!
+    let titlebarView = closeButton.superview!
+
+    self.layoutTrafficLightButton(titlebarView: titlebarView, button: closeButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 20)
+    self.layoutTrafficLightButton(titlebarView: titlebarView, button: minButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 38)
+    self.layoutTrafficLightButton(titlebarView: titlebarView, button: zoomButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 56)
+
+    let customToolbar = NSTitlebarAccessoryViewController()
+    let newView = NSView()
+    newView.frame = NSRect(origin: CGPoint(), size: CGSize(width: 0, height: 40))  // only the height is cared
+    customToolbar.view = newView
+    self.addTitlebarAccessoryViewController(customToolbar)
+  }
+
   override func awakeFromNib() {
     let flutterViewController = FlutterViewController.init()
     let windowFrame = self.frame
     self.contentViewController = flutterViewController
+
+    self.registerMethodChannel(flutterViewController: flutterViewController)
+
     self.setFrame(windowFrame, display: true)
+    self.titlebarAppearsTransparent = true
+    self.titleVisibility = .hidden
+    self.styleMask.insert(StyleMask.fullSizeContentView)
+    self.isMovableByWindowBackground = true
+    self.isMovable = false
+
+    self.layoutTrafficLights()
 
     RegisterGeneratedPlugins(registry: flutterViewController)