Repository: jiusanzhou/flutter_floatwing Branch: main Commit: 277ad38435a7 Files: 60 Total size: 187.6 KB Directory structure: gitextract_f7f2ess3/ ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .idea/ │ ├── libraries/ │ │ ├── Dart_SDK.xml │ │ ├── Flutter_Plugins.xml │ │ └── Flutter_for_Android.xml │ ├── modules.xml │ ├── runConfigurations/ │ │ └── example_lib_main_dart.xml │ └── workspace.xml ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle/ │ │ └── wrapper/ │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── settings.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── im/ │ └── zoe/ │ └── labs/ │ └── flutter_floatwing/ │ ├── FloatWindow.kt │ ├── FloatwingService.kt │ ├── FlutterFloatwingPlugin.kt │ └── Utils.kt ├── example/ │ ├── .gitignore │ ├── .metadata │ ├── README.md │ ├── android/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── build.gradle │ │ │ └── src/ │ │ │ ├── debug/ │ │ │ │ └── AndroidManifest.xml │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── kotlin/ │ │ │ │ │ └── im/ │ │ │ │ │ └── zoe/ │ │ │ │ │ └── labs/ │ │ │ │ │ └── flutter_floatwing_example/ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── res/ │ │ │ │ ├── drawable/ │ │ │ │ │ └── launch_background.xml │ │ │ │ └── values/ │ │ │ │ └── styles.xml │ │ │ └── profile/ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle │ │ ├── gradle/ │ │ │ └── wrapper/ │ │ │ └── gradle-wrapper.properties │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── lib/ │ │ ├── main.dart │ │ └── views/ │ │ ├── assistive_touch.dart │ │ ├── night.dart │ │ └── normal.dart │ ├── pubspec.yaml │ └── test/ │ └── widget_test.dart ├── flutter_floatwing.iml ├── integration_test/ │ └── floatwing_integration_test.dart ├── lib/ │ ├── flutter_floatwing.dart │ └── src/ │ ├── constants.dart │ ├── event.dart │ ├── plugin.dart │ ├── provider.dart │ ├── utils.dart │ └── window.dart ├── pubspec.yaml └── test/ ├── event_test.dart ├── flutter_floatwing_test.dart ├── plugin_test.dart ├── utils_test.dart ├── window_config_test.dart └── window_test.dart ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: zoeim # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] - 'https://payone.wencai.app/s/zoe' ================================================ FILE: .gitignore ================================================ .DS_Store .dart_tool/ .packages .pub/ build/ ================================================ FILE: .idea/libraries/Dart_SDK.xml ================================================ ================================================ FILE: .idea/libraries/Flutter_Plugins.xml ================================================ ================================================ FILE: .idea/libraries/Flutter_for_Android.xml ================================================ ================================================ FILE: .idea/modules.xml ================================================ ================================================ FILE: .idea/runConfigurations/example_lib_main_dart.xml ================================================ ================================================ FILE: .idea/workspace.xml ================================================ ================================================ FILE: .metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe channel: unknown project_type: plugin ================================================ FILE: CHANGELOG.md ================================================ ## 0.3.1 - fix: replace deprecated `window` with `PlatformDispatcher.instance.implicitView` - fix: remove unnecessary null assertions in provider - fix: suppress unused element warnings for reserved future APIs - chore: add analysis_options.yaml ## 0.3.0 - fix: resolve screen size 0x0 bug causing incorrect window positioning - fix: wait for valid physicalSize before sending system config to Android - fix: update cached config when existing config has invalid 0x0 screen size - feat: improve assistive touch menu animation with scale from touch ball position - chore: upgrade Android Gradle Plugin to 8.5.0 - chore: upgrade Kotlin to 1.9.22 - chore: upgrade Gradle to 8.10.2 - chore: migrate to new Flutter Gradle plugin declarative syntax - chore: update compileSdk/targetSdk to 34, minSdk to 21 - test: add comprehensive unit tests (73 tests) - test: add integration tests (13 tests) ## 0.1.1 - fix: fix build error for version of kotlin ## 0.1.0 - feature: basic support for overlay window - chore: add exmaples ================================================ FILE: LICENSE ================================================ Copyright (c) 2021 wellwell.work, LLC by Zoe Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
# flutter_floatwing [![Pub Version](https://img.shields.io/pub/v/flutter_floatwing?color=blue&logo=dart)](https://pub.dev/packages/flutter_floatwing) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Android-green.svg)](https://flutter.dev) A Flutter plugin that makes it easier to create floating/overlay windows for Android with pure Flutter. **Android only**
--- ## ✨ Features | Feature | Description | |---------|-------------| | 🎨 **Pure Flutter** | Write your entire overlay window in pure Flutter | | 🚀 **Simple** | Start your overlay window with as little as 1 line of code | | 📐 **Auto Resize** | Just focus on your Flutter widget size — the Android view resizes automatically | | 🪟 **Multi-window** | Create multiple overlay windows with parent-child relationships | | 💬 **Communicable** | Main app and overlay windows can communicate seamlessly with each other | | 📡 **Event Mechanism** | Subscribe to window lifecycle events and drag actions for flexible control | *More features are coming...* ## 📸 Previews | Night Mode | Simple Example | Assistive Touch | |:----------:|:--------------:|:---------------:| | ![Night mode](./assets/flutter-floatwing-example-1.gif) | ![Simple example](./assets/flutter-floatwing-example-2.gif) | ![Assistive touch](./assets/flutter-floatwing-example-3.gif) | ## 📦 Installation Add `flutter_floatwing` to your `pubspec.yaml` file: ```yaml dependencies: flutter_floatwing: ^0.2.1 ``` Then install it: - **Terminal**: Run `flutter pub get` - **Android Studio/IntelliJ**: Click "Packages get" in the action ribbon at the top of `pubspec.yaml` - **VS Code**: Click "Get Packages" on the right side of the action ribbon at the top of `pubspec.yaml` Or simply run: ```bash flutter pub add flutter_floatwing ``` ## 🚀 Quick Start Since we use Android's system alert window for display, you need to add the permission to `AndroidManifest.xml` first: ```xml ``` Add a route for the widget that will be displayed in the overlay window: ```dart @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, initialRoute: "/", routes: { "/": (_) => HomePage(), // Add a route as the entry point for your overlay window "/my-overlay-window": (_) => MyOverlayWindow(), }, ); } ``` Before starting the floating window, check and request permission, then initialize the `flutter_floatwing` plugin in `initState` or a button callback: ```dart // Check and request the system alert window permission FloatwingPlugin().checkPermission().then((granted) { if (!granted) FloatwingPlugin().openPermissionSetting(); }); // Initialize the plugin first FloatwingPlugin().initialize(); ``` Now create and start your overlay window: ```dart // Define window config and start the window WindowConfig(route: "/my-overlay-window") .to() // Create a window object .create(start: true); // Create and start the overlay window ``` --- **Notes:** - `route` is one of 3 ways to define an entry point for the overlay window. See the [Entry Point](#-entry-point) section for more details. - See the [Usage](#-usage) section for more features. ## 🏗️ Architecture Before diving into how `flutter_floatwing` manages windows, here are some key concepts: - **`id`** is the unique identifier for each window. All operations on a window are based on this `id` — you must provide one before creating a window. - The first engine created when opening the main application is called the **main engine** (or **plugin engine**). Engines created by the service are called **window engines**. - Different engines run in **different threads** and cannot communicate directly. - You can subscribe to events from all windows in the main engine. In a window engine, you can subscribe to events from itself and its child windows, but not from sibling or parent windows. - **`share` data** is the only way to communicate between engines. The only restriction is that the data must be serializable — you can share data from anywhere to anywhere. A floatwing window object consists of a Flutter engine that runs a widget via `runApp` and a view that is added to the Android window manager. ![floatwing window](./assets/flutter-floatwing-window.png) The overall view hierarchy looks like this: ![flutter floatwing architecture](./assets/flutter-floatwing-arch.png) ## 📖 Usage Here's how `flutter_floatwing` creates a new overlay window: 1. Start a background service as the window manager from the main app. 2. Send a create window request to the service. 3. In the service, start a Flutter engine with the specified entry point. 4. Create a new Flutter view and attach it to the Flutter engine. 5. Add the view to the Android window manager. ### Window & Config `WindowConfig` contains all configuration options for a window. You can create a window using configuration like this: ```dart void _createWindow() { var config = WindowConfig(); var w = Window(config, id: "my-window"); w.create(); } ``` If you don't need to register event or data handlers, you can create a window directly from the config: ```dart void _createWindow() { WindowConfig(id: "my-window").create(); } ``` Note that if you want to specify a window ID, you must provide it in `WindowConfig`. If you want to register handlers, use the `to()` function to convert a config to a window first — this is useful for keeping your code clean: ```dart void _createWindow() { WindowConfig(id: "my-window") .to() .on(EventType.WindowCreated, (w, _) {}) .create(); } ``` #### Window Lifecycle - created - started - paused - resumed - destroyed ### 🎯 Entry Point The entry point determines where the engine starts execution. We support 3 configuration modes: | Name | Config | How to Use | |:-----|:-------|:-----------| | `route` | `WindowConfig(route: "/my-overlay")` | Add a route for the overlay window in your main routes, then start with: `WindowConfig(route: "/my-overlay")` | | `static function` | `WindowConfig(callback: myOverlayMain)` | Define a static `void Function()` that calls `runApp` to start a widget, then start with: `WindowConfig(callback: myOverlayMain)` | | `entry-point` | `WindowConfig(entry: "myOverlayMain")` | Same as static function, but add `@pragma("vm:entry-point")` above the function and use the function name as a string: `WindowConfig(entry: "myOverlayMain")` | #### Example: Using `route` 1. Add a route for your overlay widget in the main application: ```dart @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, initialRoute: "/", routes: { "/": (_) => HomePage(), // Add a route as the entry point for your overlay window "/my-overlay-window": (_) => MyOverlayWindow(), }, ); } ``` 2. Start the window with `route`: ```dart void _startWindow() { WindowConfig(route: "/my-overlay-window") .to() .create(start: true); } ``` #### Example: Using `static function` 1. Define a static function that calls `runApp`: ```dart void myOverlayMain() { runApp(MaterialApp( home: AssistivePanel(), )); // Or use the floatwing helper to inject MaterialApp // runApp(AssistivePanel().floatwing(app: true)); } ``` 2. Start the window with `callback`: ```dart void _startWindow() { WindowConfig(callback: myOverlayMain) .to() .create(start: true); } ``` #### Example: Using `entry-point` 1. Define a static function that calls `runApp` and add the pragma annotation: ```dart @pragma("vm:entry-point") void myOverlayMain() { runApp(MaterialApp( home: AssistivePanel(), )); // Or use the floatwing helper to inject MaterialApp // runApp(AssistivePanel().floatwing(app: true)); } ``` 2. Start the window with `entry`: ```dart void _startWindow() { WindowConfig(entry: "myOverlayMain") .to() .create(start: true); } ``` ### Wrapping Your Widget For simple widgets, no special wrapping is needed. But if you want additional functionality and cleaner code, we provide an injector for your widget. Current features include: - Auto-resize the window view - Auto-sync and ensure the window - Wrap with `MaterialApp` - *More features coming...* Previously, you would write your overlay main function like this: ```dart void overlayMain() { runApp(MaterialApp( home: MyOverlayView(), )); } ``` Now you can simplify it to: ```dart void overlayMain() { runApp(MyOverlayView().floatwing(app: true)); } ``` You can wrap both `Widget` and `WidgetBuilder`. When wrapping a `WidgetBuilder`, you can access the window instance using `Window.of(context)`. For wrapped `Widget`, use `FloatwingPlugin().currentWindow` instead. To access the window via `Window.of(context)`, use this pattern: ```dart void overlayMain() { runApp(((_) => MyOverlayView()).floatwing(app: true).make()); } ``` ### Accessing Window in Overlay In your window engine, you can access the window object in two ways: - Directly access the plugin's cached field: `FloatwingPlugin().currentWindow` - If the widget is wrapped with `.floatwing()`, use `Window.of(context)` `FloatwingPlugin().currentWindow` returns `null` until initialization is complete. If you inject a `WidgetBuilder` with `.floatwing()`, you can access the current window. It will always return a non-null value, unless you enable debug mode with `.floatwing(debug: true)`. For example, to get the `id` of the current window: ```dart import 'package:flutter_floatwing/flutter_floatwing.dart'; class _ExampleViewState extends State { Window? w; @override void initState() { super.initState(); SchedulerBinding.instance?.addPostFrameCallback((_) { w = Window.of(context); print("My window ID is ${w?.id}"); }); } } ``` ### Subscribing to Events You can subscribe to window events and trigger actions when they fire. Window events are sent to the main engine, the window's own engine, and the parent window engine. This means you can subscribe to window events from the main application, the overlay window itself, or the parent overlay window. Currently supported events include window lifecycle and drag actions: ```dart enum EventType { WindowCreated, WindowStarted, WindowPaused, WindowResumed, WindowDestroy, WindowDragStart, WindowDragging, WindowDragEnd, } ``` *More event types are coming — contributions are welcome!* For example, to perform an action when a window starts: ```dart @override void initState() { super.initState(); SchedulerBinding.instance?.addPostFrameCallback((_) { w = Window.of(context); w?.on(EventType.WindowStarted, (window, _) { print("$w has started."); }).on(EventType.WindowDestroy, (window, data) { // data is a boolean indicating whether the window was force-closed print("$w has been destroyed, force: $data"); }); }); } ``` ### Sharing Data with Windows Sharing data is the only way to communicate with windows. Use `window.share(data)` for this purpose. For example, to share data from the main application to an overlay window: First, get the target window in the main application (either the one you created or from the `windows` cache by ID): ```dart Window w; void _startWindow() { w = WindowConfig(route: "/my-overlay-window").to(); } void _shareData(dynamic data) { w.share(data).then((value) { // The window can return a value }); // Or get the window from cache // FloatwingPlugin().windows["default"]?.share(data); } ``` To share data with a specific name, add the name parameter: `w.share(data, name: "name-1")`. Then register a data handler in the window to receive the data: ```dart @override void initState() { super.initState(); SchedulerBinding.instance?.addPostFrameCallback((_) { w = Window.of(context); w?.onData((source, name, data) async { print("Received $name data from $source: $data"); }); }); } ``` The handler function signature is `Future Function(String? source, String? name, dynamic data)`: - `source`: Where the data comes from. `null` if from the main application; otherwise, the `id` of the source window. - `name`: The data name, useful for sharing data for different purposes. - `data`: The actual data received. - Return a value if you want to respond. You can send data to any window as long as you know its ID — the only restriction is that you cannot send data to yourself. *Note: Sharing data to the main application is not yet implemented.* **Important: The data you share must be serializable.** ## 📚 API Reference ### FloatwingPlugin ```dart FloatwingPlugin() // Permission ..checkPermission() // Check overlay permission → Future ..openPermissionSetting() // Open system settings → Future // Initialization ..initialize() // Initialize the plugin → Future // Service Management ..isServiceRunning() // Check if background service is running → Future ..startService() // Start the background service → Future ..syncWindows() // Sync windows from service → Future ..cleanCache() // Clean cached data → Future // Window Access ..currentWindow // Get current window (in overlay) → Window? ..windows // Map of all windows by ID → Map ..isWindow // Check if running in window engine → bool ``` `FloatwingPlugin` is a singleton class that returns the same instance every time you call the `FloatwingPlugin()` factory method. ### WindowConfig Complete configuration options for overlay windows: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `id` | `String` | `"default"` | Unique window identifier | | `entry` | `String` | `"main"` | Entry point function name | | `route` | `String?` | `null` | Flutter route for the window | | `callback` | `Function?` | `null` | Static function to run (must be static) | | `width` | `int?` | `null` | Window width in pixels | | `height` | `int?` | `null` | Window height in pixels | | `x` | `int?` | `null` | X position on screen | | `y` | `int?` | `null` | Y position on screen | | `autosize` | `bool?` | `null` | Auto-resize to fit content | | `gravity` | `GravityType?` | `null` | Window position alignment | | `clickable` | `bool?` | `null` | Allow click-through when `false` | | `draggable` | `bool?` | `null` | Enable drag to move | | `focusable` | `bool?` | `null` | Allow window to receive focus | | `immersion` | `bool?` | `null` | Immersive status bar mode | | `visible` | `bool?` | `null` | Initial visibility state | #### WindowSize Constants ```dart WindowSize.MatchParent // -1: Fill entire screen WindowSize.WrapContent // -2: Fit to content size ``` #### GravityType Enum ```dart GravityType.Center // Center of screen GravityType.CenterTop // Top center GravityType.CenterBottom // Bottom center GravityType.LeftTop // Top left corner GravityType.LeftCenter // Left center GravityType.LeftBottom // Bottom left corner GravityType.RightTop // Top right corner GravityType.RightCenter // Right center GravityType.RightBottom // Bottom right corner ``` **Example — Full-screen non-clickable overlay (night mode):** ```dart WindowConfig( id: "night-mode", route: "/night", width: WindowSize.MatchParent, height: WindowSize.MatchParent, clickable: false, // Touch passes through ) ``` **Example — Draggable floating button:** ```dart WindowConfig( id: "float-button", route: "/button", draggable: true, gravity: GravityType.RightBottom, ) ``` ### Window | Method | Returns | Description | |--------|---------|-------------| | `create({start: bool})` | `Future` | Create window, optionally start immediately | | `start()` | `Future` | Start/show the window | | `close({force: bool})` | `Future` | Close the window | | `show({visible: bool})` | `Future` | Show or hide the window | | `hide()` | `Future` | Hide the window (shortcut for `show(visible: false)`) | | `update(WindowConfig)` | `Future` | Update window configuration | | `share(data, {name})` | `Future` | Send data to this window | | `on(EventType, handler)` | `Window` | Subscribe to events (chainable) | | `onData(handler)` | `Window` | Register data receive handler | | `launchMainActivity()` | `Future` | Open main app from overlay | | `createChildWindow(...)` | `Future` | Create a child window (from overlay only) | **Static Methods:** | Method | Returns | Description | |--------|---------|-------------| | `Window.of(context)` | `Window?` | Get window instance from BuildContext | | `Window.sync()` | `Future` | Sync window state from Android | ### Child Windows You can create nested windows from within an overlay: ```dart // In your overlay window widget final parentWindow = Window.of(context); parentWindow?.createChildWindow( "child-popup", WindowConfig( route: "/popup", width: 200, height: 100, ), start: true, ); ``` ## ❤️ Support Did you find this plugin useful? Please consider making a donation to help improve it! ## 🔧 Troubleshooting ### Release Mode Error: "No top-level getter declared" If you see this error in release mode when using `entry-point`: ``` NoSuchMethodError: No top-level getter 'xxx' declared. Could not resolve main entrypoint function. ``` **Solution**: Make sure your entry point function is: 1. **Defined in `main.dart`** or imported into `main.dart` 2. **Marked with `@pragma("vm:entry-point")`** to prevent tree-shaking ```dart // In main.dart @pragma("vm:entry-point") void myOverlayMain() { runApp(MyOverlayWidget().floatwing(app: true)); } ``` If defined in another file, import it in `main.dart`: ```dart // main.dart import 'package:myapp/overlay_entry.dart'; // Contains myOverlayMain void main() { runApp(MyApp()); } // Re-export to ensure it's included in the build export 'package:myapp/overlay_entry.dart'; ``` ### Buttons Get Stuck Pressed When Dragging If buttons inside a draggable overlay widget get stuck in pressed state when pressing and dragging simultaneously: **Workaround**: Disable dragging while the button is pressed: ```dart ElevatedButton( style: ButtonStyle( foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.pressed)) { Window.of(context)?.update(WindowConfig(draggable: false)); return Colors.blue; } else { Window.of(context)?.update(WindowConfig(draggable: true)); return Colors.white; } }), ), onPressed: () { /* ... */ }, child: Text("Button"), ) ``` ### MissingPluginException If you see `MissingPluginException(No implementation found for method window.start...)`: 1. **Clean rebuild**: `flutter clean && flutter pub get && flutter run` 2. **Check permissions**: Ensure `SYSTEM_ALERT_WINDOW` permission is granted 3. **Update to latest version**: This was fixed in recent updates ## 🤝 Contributing Contributions are always welcome! - Report bugs or request features via [Issues](https://github.com/jiusanzhou/flutter_floatwing/issues) - Submit pull requests - Improve documentation ## 📄 License ``` Apache License 2.0 Copyright (c) 2022 Zoe ```
**[⬆ Back to Top](#flutter_floatwing)**
================================================ FILE: analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml analyzer: errors: unused_element: ignore unused_element_parameter: ignore ================================================ FILE: android/.gitignore ================================================ *.iml .gradle /local.properties /.idea/workspace.xml /.idea/libraries .DS_Store /build /captures ================================================ FILE: android/build.gradle ================================================ group 'im.zoe.labs.flutter_floatwing' version '1.0-SNAPSHOT' buildscript { ext.kotlin_version = '1.9.22' repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:8.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } rootProject.allprojects { repositories { google() mavenCentral() } } apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { namespace 'im.zoe.labs.flutter_floatwing' compileSdk 34 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' } defaultConfig { minSdk 21 } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } ================================================ FILE: android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip ================================================ FILE: android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true ================================================ FILE: android/settings.gradle ================================================ package android rootProject.name = 'flutter_floatwing' ================================================ FILE: android/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/src/main/kotlin/im/zoe/labs/flutter_floatwing/FloatWindow.kt ================================================ package im.zoe.labs.flutter_floatwing import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.graphics.PixelFormat import android.os.Build import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.view.WindowManager.LayoutParams import android.view.WindowManager.LayoutParams.* import io.flutter.embedding.android.FlutterTextureView import io.flutter.embedding.android.FlutterView import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.plugin.common.BasicMessageChannel import io.flutter.plugin.common.JSONMessageCodec import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @SuppressLint("ClickableViewAccessibility") class FloatWindow( context: Context, wmr: WindowManager, engKey: String, eng: FlutterEngine, cfg: Config): View.OnTouchListener, MethodChannel.MethodCallHandler, BasicMessageChannel.MessageHandler { var parent: FloatWindow? = null var config = cfg var key: String = "default" var engineKey = engKey var engine = eng var wm = wmr var subscribedEvents: HashMap = HashMap() var view: FlutterView = FlutterView(context, FlutterTextureView(context)) lateinit var layoutParams: LayoutParams lateinit var service: FloatwingService // method and message channel for window engine call var _channel: MethodChannel = MethodChannel(eng.dartExecutor.binaryMessenger, "${FloatwingService.METHOD_CHANNEL}/window").also { it.setMethodCallHandler(this) } var _message: BasicMessageChannel = BasicMessageChannel(eng.dartExecutor.binaryMessenger, "${FloatwingService.MESSAGE_CHANNEL}/window_msg", JSONMessageCodec.INSTANCE) .also { it.setMessageHandler(this) } var _started = false fun init(): FloatWindow { layoutParams = config.to() config.focusable?.let{ view.isFocusable = it view.isFocusableInTouchMode = it } view.setBackgroundColor(Color.TRANSPARENT) view.fitsSystemWindows = true config.visible?.let{ setVisible(it) } view.setOnTouchListener(this) // view.attachToFlutterEngine(engine) return this } fun destroy(force: Boolean = true): Boolean { Log.i(TAG, "[window] destroy window: $key force: $force") // remote from manager must be first if (_started) wm.removeView(view) view.detachFromFlutterEngine() // TODO: should we stop the engine for flutter? if (force) { // stop engine and remove from cache FlutterEngineCache.getInstance().remove(engineKey) engine.destroy() service.windows.remove(key) emit("destroy", null) } else { _started = false engine.lifecycleChannel.appIsPaused() emit("paused", null) } return true } fun setVisible(visible: Boolean = true): Boolean { Log.d(TAG, "[window] set window $key => $visible") emit("visible", visible) view.visibility = if (visible) View.VISIBLE else View.GONE return visible } fun update(cfg: Config): Map { Log.d(TAG, "[window] update window $key => $cfg") config = config.update(cfg).also { layoutParams = it.to() if (_started) wm.updateViewLayout(view, layoutParams) } return toMap() } fun start(): Boolean { if (_started) { Log.d(TAG, "[window] window $key already started") return true } _started = true Log.d(TAG, "[window] start window: $key") engine.lifecycleChannel.appIsResumed() // if engine is paused, send re-render message // make sure reuse engine can be re-render emit("resumed") view.attachToFlutterEngine(engine) wm.addView(view, layoutParams) emit("started") return true } fun shareData(data: Map<*, *>, source: String? = null, result: MethodChannel.Result? = null) { shareData(_channel, data, source, result) } fun simpleEmit(msgChannel: BasicMessageChannel, name: String, data: Any?=null) { val map = HashMap() map["name"] = name map["id"] = key // this is special for main engine map["data"] = data msgChannel.send(map) } fun emit(name: String, data: Any? = null, prefix: String?="window", pluginNeed: Boolean = true) { val evtName = "$prefix.$name" // Log.i(TAG, "[window] emit event: Window[$key] $name ") // check if need to send to my self if (true||subscribedEvents.containsKey(name)||subscribedEvents.containsKey("*")) { // emit to window engine simpleEmit(_message, evtName, data) } // plugin // check if we need to fire to plugin if (pluginNeed&&(true||service.subscribedEvents.containsKey("*")||service.subscribedEvents.containsKey(evtName))) { simpleEmit(service._message, evtName, data) } // emit parent engine // if fire to parent need have no need to fire to service again if(parent!=null&&parent!=this) { parent!!.simpleEmit(parent!!._message, evtName, data) } // _channel.invokeMethod("window.$name", data) // we need to send to man engine // service._channel.invokeMethod("window.$name", key) } fun toMap(): Map { // must not null if success created val map = HashMap() map["id"] = key map["pixelRadio"] = service.pixelRadio map["system"] = service.systemConfig map["config"] = config.toMap().filter { it.value != null } return map } override fun toString(): String { return "${toMap()}" } // return window from svc.windows by id fun take(id: String): FloatWindow? { return service.windows[id] } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { return when (call.method) { // just take current engine's window "window.sync" -> { // when flutter is ready should call this to sync the window object. Log.i(TAG, "[window] window.sync from flutter side: $key") result.success(toMap()) } // we need to support call window.* in window engine // but the window engine register as window channel // so we should take the id first and then get window from windows cache // TODO: those code should move to service "window.create_child" -> { val id = call.argument("id") ?: "default" val cfg = call.argument>("config")!! val start = call.argument("start") ?: false val config = FloatWindow.Config.from(cfg) Log.d(TAG, "[service] window.create_child request_id: $id") return result.success(FloatwingService.createWindow(service.applicationContext, id, config, start, this)) } "window.close" -> { val id = call.argument("id")?:"" Log.d(TAG, "[window] window.close request_id: $id, my_id: $key") val force = call.argument("force") ?: false return result.success(take(id)?.destroy(force)) } "window.destroy" -> { val id = call.argument("id")?:"" Log.d(TAG, "[window] window.destroy request_id: $id, my_id: $key") return result.success(take(id)?.destroy(true)) } "window.start" -> { val id = call.argument("id")?:"" Log.d(TAG, "[window] window.start request_id: $id, my_id: $key") return result.success(take(id)?.start()) } "window.update" -> { val id = call.argument("id")?:"" Log.d(TAG, "[window] window.update request_id: $id, my_id: $key") val config = Config.from(call.argument>("config")!!) return result.success(take(id)?.update(config)) } "window.show" -> { val id = call.argument("id")?:"" Log.d(TAG, "[window] window.show request_id: $id, my_id: $key") val visible = call.argument("visible") ?: true return result.success(take(id)?.setVisible(visible)) } "window.launch_main" -> { Log.d(TAG, "[window] window.launch_main") return result.success(service.launchMainActivity()) } "window.lifecycle" -> { } "event.subscribe" -> { val id = call.argument("id")?:"" } "data.share" -> { // communicate with other window, only 1 - 1 with id val args = call.arguments as Map<*, *> val targetId = call.argument("target") Log.d(TAG, "[window] share data from $key with $targetId: $args") if (targetId == null) { Log.d(TAG, "[window] share data with plugin") return result.success(shareData(service._channel, args, source=key, result=result)) } if (targetId == key) { Log.d(TAG, "[window] can't share data with self") return result.error("no allow", "share data from $key to $targetId", "") } val target = service.windows[targetId] ?: return result.error("not found", "target window $targetId not exits", ""); return target.shareData(args, source=key, result=result) } else -> { result.notImplemented() } } } override fun onMessage(msg: Any?, reply: BasicMessageChannel.Reply) { // stream message } companion object { private const val TAG = "FloatWindow" fun shareData(channel: MethodChannel, data: Map<*, *>, source: String? = null, result: MethodChannel.Result? = null): Any? { // id is the data comes from // invoke the method channel val map = HashMap() map["source"] = source data.forEach { map[it.key as String] = it.value } channel.invokeMethod("data.share", map, result) // how to get data back return null } } // window is dragging private var dragging = false // start point private var lastX = 0f private var lastY = 0f // border around // TODO: support generate around edge override fun onTouch(view: View?, event: MotionEvent?): Boolean { // default draggable should be false if (config.draggable != true) return false when (event?.action) { MotionEvent.ACTION_DOWN -> { // touch start dragging = false lastX = event.rawX lastY = event.rawY // TODO: support generate around edge } MotionEvent.ACTION_MOVE -> { // touch move val dx = event.rawX - lastX val dy = event.rawY - lastY // ignore too small fist start moving(some time is click) if (!dragging && dx*dx+dy*dy < 25) { return false } // update the last point lastX = event.rawX lastY = event.rawY val xx = layoutParams.x + dx.toInt() val yy = layoutParams.y + dy.toInt() if (!dragging) { // first time dragging emit("drag_start", listOf(xx, yy)) } dragging = true // update x, y, need to update config so use config to update update(Config().apply { // calculate with the border x = xx y = yy }) emit("dragging", listOf(xx, yy)) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { // touch end if (dragging) emit("drag_end", listOf(event.rawX, event.rawY)) return dragging } else -> { return false } } return false } class Config { // this three fields can not be changed // var id: String = "default" var entry: String? = null var route: String? = null var callback: Long? = null var autosize: Boolean? = null var width: Int? = null var height: Int? = null var x: Int? = null var y: Int? = null var format: Int? = null var gravity: Int? = null var type: Int? = null var clickable: Boolean? = null var draggable: Boolean? = null var focusable: Boolean? = null var immersion: Boolean? = null var visible: Boolean? = null // inline fun to(): T { fun to(): LayoutParams { val cfg = this return LayoutParams().apply { // set size width = cfg.width ?: 1 // we must have 1 pixel, let flutter can generate the pixel radio height = cfg.height ?: 1 // we must have 1 pixel, let flutter can generate the pixel radio // set position fixed if with (x, y) cfg.x?.let { x = it } // default not set cfg.y?.let { y = it } // default not set // format format = cfg.format ?: PixelFormat.TRANSPARENT // default start from center gravity = cfg.gravity ?: Gravity.TOP or Gravity.LEFT // default flags flags = FLAG_LAYOUT_IN_SCREEN or FLAG_NOT_TOUCH_MODAL // if immersion add flag no limit cfg.immersion?.let{ if (it) flags = flags or FLAG_LAYOUT_NO_LIMITS } // default we should be clickable // if not clickable, add flag not touchable cfg.clickable?.let{ if (!it) flags = flags or FLAG_NOT_TOUCHABLE } // default we should be no focusable if (cfg.focusable == null) { cfg.focusable = false } // if not focusable, add no focusable flag cfg.focusable?.let { if (!it) flags = flags or FLAG_NOT_FOCUSABLE } // default type is overlay type = cfg.type ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) TYPE_APPLICATION_OVERLAY else TYPE_PHONE } } fun toMap(): Map { val map = HashMap() map["entry"] = entry map["route"] = route map["callback"] = callback map["autosize"] = autosize map["width"] = width map["height"] = height map["x"] = x map["y"] = y map["format"] = format map["gravity"] = gravity map["type"] = type map["clickable"] = clickable map["draggable"] = draggable map["focusable"] = focusable map["immersion"] = immersion map["visible"] = visible return map } fun update(cfg: Config): Config { // entry, route, callback shouldn't be updated cfg.autosize?.let { autosize = it } cfg.width?.let { width = it } cfg.height?.let { height = it } cfg.x?.let { x = it } cfg.y?.let { y = it } cfg.format?.let { format = it } cfg.gravity?.let { gravity = it } cfg.type?.let { type = it } cfg.clickable?.let{ clickable = it } cfg.draggable?.let { draggable = it } cfg.focusable?.let { focusable = it } cfg.immersion?.let { immersion = it } cfg.visible?.let { visible = it } return this } override fun toString(): String { val map = toMap()?.filter { it.value != null } return "$map" } companion object { fun from(data: Map): Config { val cfg = Config() // (data["id"]?.let { it as String } ?: "default").also { cfg.id = it } cfg.entry = data["entry"] as String? cfg.route = data["route"] as String? val int_callback = data["callback"] as Number? cfg.callback = int_callback?.toLong() cfg.autosize = data["autosize"] as Boolean? cfg.width = data["width"] as Int? cfg.height = data["height"] as Int? cfg.x = data["x"] as Int? cfg.y = data["y"] as Int? cfg.gravity = data["gravity"] as Int? cfg.format = data["format"] as Int? cfg.type = data["type"] as Int? cfg.clickable = data["clickable"] as Boolean? cfg.draggable = data["draggable"] as Boolean? cfg.focusable = data["focusable"] as Boolean? cfg.immersion = data["immersion"] as Boolean? cfg.visible = data["visible"] as Boolean? return cfg } } } } ================================================ FILE: android/src/main/kotlin/im/zoe/labs/flutter_floatwing/FloatwingService.kt ================================================ package im.zoe.labs.flutter_floatwing import android.annotation.SuppressLint import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Context import android.content.Intent import android.content.Intent.ACTION_SHUTDOWN import android.os.Build import android.os.IBinder import android.os.PowerManager import android.util.Log import android.view.WindowManager import androidx.core.app.NotificationCompat import im.zoe.labs.flutter_floatwing.Utils.Companion.toMap import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.FlutterEngineGroup import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.plugin.common.BasicMessageChannel import io.flutter.plugin.common.JSONMessageCodec import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.view.FlutterCallbackInformation import org.json.JSONObject import java.lang.Exception class FloatwingService : MethodChannel.MethodCallHandler, BasicMessageChannel.MessageHandler, Service() { private lateinit var mContext: Context private lateinit var windowManager: WindowManager private lateinit var engGroup: FlutterEngineGroup lateinit var _channel: MethodChannel lateinit var _message: BasicMessageChannel var subscribedEvents: HashMap = HashMap() var pixelRadio = 2.0 var systemConfig = emptyMap() // store the window object use the id as key val windows = HashMap() override fun onCreate() { super.onCreate() // set the instance instance = this mContext = applicationContext engGroup = FlutterEngineGroup(mContext) Log.i(TAG, "[service] the background service onCreate") // get the window manager and store (getSystemService(WINDOW_SERVICE) as WindowManager).also { windowManager = it } // load pixel from store pixelRadio = mContext.getSharedPreferences(FlutterFloatwingPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) .getFloat(FlutterFloatwingPlugin.PIXEL_RADIO_KEY, 2F).toDouble() Log.d(TAG, "[service] load the pixel radio: $pixelRadio") // load system config from store try { val str = mContext.getSharedPreferences(FlutterFloatwingPlugin.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) .getString(FlutterFloatwingPlugin.SYSTEM_CONFIG_KEY, "{}") val map = JSONObject(str) systemConfig = map.toMap() }catch (e: Exception) { e.printStackTrace() } // install this method channel for the main engine FlutterEngineCache.getInstance().get(FlutterFloatwingPlugin.FLUTTER_ENGINE_CACHE_KEY) ?.also { Log.d(TAG, "[service] install the service handler for main engine") installChannel(it) } } override fun onBind(p0: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_SHUTDOWN -> { (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply { if (isHeld) release() } } Log.d(TAG, "[service] stop the background service!") stopSelf() } else -> { } } return START_STICKY } override fun onDestroy() { super.onDestroy() // clean up: remove all views in the window manager windows.forEach { it.value.destroy() Log.d(TAG, "[service] service destroy: remove the float window ${it.key}") } } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) { "service.stop_service" -> { Log.d(TAG, "[service] stop the service") result.success(stopService(Intent(baseContext, this.javaClass))) } "service.promote" -> { Log.d(TAG, "[service] promote service") result.success(promoteService(call.arguments as Map<*, *>?)) } "service.demote" -> { Log.d(TAG, "[service] demote service") result.success(demoteService()) } "service.create_window" -> { val id = call.argument("id") ?: "default" val cfg = call.argument>("config")!! val start = call.argument("start") ?: false Log.d(TAG, "[service] window.create request_id: $id") result.success(createWindow(mContext, id, FloatWindow.Config.from(cfg), start, null)) } "window.close" -> { val id = call.argument("id")!! Log.d(TAG, "[service] window.close request_id: $id") val force = call.argument("force") ?: false result.success(windows[id]?.destroy(force)) } "window.start" -> { val id = call.argument("id") ?: "default" Log.d(TAG, "[service] window.start request_id: $id ${windows[id]}") result.success(windows[id]?.start()) } "window.show" -> { val id = call.argument("id")!! val visible = call.argument("visible") ?: true Log.d(TAG, "[service] window.show request_id: $id") result.success(windows[id]?.setVisible(visible)) } "window.update" -> { val id = call.argument("id")!! Log.d(TAG, "[service] window.update request_id: $id") val config = FloatWindow.Config.from(call.argument>("config")!!) result.success(windows[id]?.update(config)) } "window.sync" -> { Log.d(TAG, "[service] fake window.sync") result.success(null) } "data.share" -> { val args = call.arguments as Map<*, *> val targetId = call.argument("target") Log.d(TAG, "[service] share data from with $targetId: $args") if (targetId == null) { Log.d(TAG, "[service] can't share data with self") result.error("no allow", "share data from plugin to plugin", "") } else { val target = windows[targetId] if (target != null) { target.shareData(args, result = result) } else { result.error("not found", "target window $targetId not exits", "") } } } else -> { Log.d(TAG, "[service] unknown method ${call.method}") result.notImplemented() } } override fun onMessage(message: Any?, reply: BasicMessageChannel.Reply) { // update the windows from message } private fun promoteService(map: Map<*, *>?): Boolean { Log.i(TAG, "[service] promote service to foreground") if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { Log.e(TAG, "[service] promoteToForeground need sdk >= 26") return false } if (map == null) { Log.e(TAG, "[service] promote service config is null") return false } /* (getSystemService(Context.POWER_SERVICE) as PowerManager).run { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG).apply { setReferenceCounted(false) acquire() } } */ val title = map["title"] as String? ?: "Floatwing Service" val description = map["description"] as String? ?: "Floatwing service is running" val showWhen = map["showWhen"] as Boolean? ?: false val ticker = map["ticker"] as String? val subText = map["subText"] as String? val channel = NotificationChannel("flutter_floatwing", "Floatwing Service", NotificationManager.IMPORTANCE_HIGH) val imageId = resources.getIdentifier("ic_launcher", "mipmap", packageName) (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(channel) val notification = NotificationCompat.Builder(this, "flutter_floatwing") .setContentTitle(title) .setContentText(description) .setShowWhen(showWhen) .setTicker(ticker) .setSubText(subText) .setSmallIcon(imageId) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_SERVICE) .build() startForeground(1, notification) return true } private fun demoteService(): Boolean { Log.i(TAG, "[service] demote service to background") stopForeground(true) return true } fun launchMainActivity(): Boolean { if (mActivityClass == null) { Log.e(TAG, "[service] the main activity is null, maybe the service start from background") return false } Log.d(TAG, "[service] launch the main activity") val intent = Intent(this, mActivityClass) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) return true } private fun createWindow(id: String, config: FloatWindow.Config, start: Boolean = false, p: FloatWindow?): Map? { // check if id exits if (windows.contains(id)) { Log.e(TAG, "[service] window with id $id exits") return null } // get flutter engine val fKey = id.flutterKey() val (eng, fromCache) = getFlutterEngine(fKey, config.entry, config.route, config.callback) val svc = this return FloatWindow(mContext, windowManager, fKey, eng, config).apply { key = id service = svc parent = p Log.d(TAG, "[service] set window as handler $METHOD_CHANNEL/window for $eng") }.init().also { Log.d(TAG, "[service] created window: $id $config") it.emit("created", !fromCache) windows[it.key] = it if (start) it.start() }.toMap() } // this function is useful when we want to start service automatically private fun getFlutterEngine(key: String, entryName: String?, route: String?, callback: Long?): Pair { // first take from cache var eng = FlutterEngineCache.getInstance().get(key) if (eng != null) { Log.i(TAG, "[service] use the flutter exits in cache, id: $key") return Pair(eng, true) } Log.d(TAG, "[service] miss from cache need to create a new flutter engine") // then create a flutter engine // ensure initialization // FlutterInjector.instance().flutterLoader().startInitialization(mContext) // FlutterInjector.instance().flutterLoader().ensureInitializationComplete(mContext, arrayOf()) // first let's use callback to start engine first if (callback!=null&&callback>0L) { Log.i(TAG, "[service] start flutter engine, id: $key callback: $callback") eng = FlutterEngine(mContext) val info = FlutterCallbackInformation.lookupCallbackInformation(callback) val args = DartExecutor.DartCallback(mContext.assets, FlutterInjector.instance().flutterLoader().findAppBundlePath(), info) // execute the callback function eng.dartExecutor.executeDartCallback(args) // store the engine to cache FlutterEngineCache.getInstance().put(key, eng) return Pair(eng, false) } var entry = entryName if (entry==null) { // try use the main entrypoint entry = "main" Log.w(TAG, "[service] recommend to use a entrypoint") } // check the main and default route if (entry == "main" && route == null) { Log.w(TAG, "[service] use the main entrypoint and default route") } Log.i(TAG, "[service] start flutter engine, id: $key entrypoint: $entry, route: $route") // make sure the entrypoint exits val entrypoint = DartExecutor.DartEntrypoint( FlutterInjector.instance().flutterLoader().findAppBundlePath(), entry) // start the dart executor with special entrypoint eng = engGroup.createAndRunEngine(mContext, entrypoint, route) // store the engine to cache FlutterEngineCache.getInstance().put(key, eng) return Pair(eng, false) } // window engine won't call this, so just window method private fun installChannel(eng: FlutterEngine): Boolean { Log.d(TAG, "[service] set service as handler $METHOD_CHANNEL/window for $eng") // set the method and message channel // this must be same as window, because we use the same method to call invoke _channel = MethodChannel(eng.dartExecutor.binaryMessenger, "$METHOD_CHANNEL/window").also { it.setMethodCallHandler(this) } _message = BasicMessageChannel(eng.dartExecutor.binaryMessenger, "$METHOD_CHANNEL/window_msg", JSONMessageCodec.INSTANCE).also { it.setMessageHandler(this) } return true } private fun String.flutterKey(): String { return FLUTTER_ENGINE_KEY + this } companion object { @JvmStatic private val TAG = "FloatwingService" // TODO: improve @SuppressLint("StaticFieldLeak") var mActivity: Activity? = null var mActivityClass: Class? = null @SuppressLint("StaticFieldLeak") var instance: FloatwingService? = null const val WAKELOCK_TAG = "FloatwingService::WAKE_LOCK" const val FLUTTER_ENGINE_KEY = "floatwing_flutter_engine_" const val METHOD_CHANNEL = "im.zoe.labs/flutter_floatwing" const val MESSAGE_CHANNEL = "im.zoe.labs/flutter_floatwing" fun initialize(): Boolean { Log.i(TAG, "[service] initialize") return true } fun createWindow(context: Context, id: String, config: FloatWindow.Config, start: Boolean = false, parent: FloatWindow?): Map? { Log.i(TAG, "[service] create a window: $id $config") // make sure the service started if (!ensureService(context)) return null // If instance not ready yet, wait a bit with handler if (instance == null) { Log.i(TAG, "[service] instance null after ensureService, waiting...") // Can't block main thread, return null and let caller retry return null } // start the window return instance?.createWindow(id, config, start, parent) } fun createWindowAsync(context: Context, id: String, config: FloatWindow.Config, start: Boolean = false, parent: FloatWindow?, callback: (Map?) -> Unit) { Log.i(TAG, "[service] create a window async: $id") fun doCreate() { val result = instance?.createWindow(id, config, start, parent) callback(result) } if (instance != null) { doCreate() return } ensureServiceAsync(context) { success -> if (success && instance != null) { doCreate() } else { Log.e(TAG, "[service] failed to ensure service for window creation") callback(null) } } } // ensure the service is started private fun ensureService(context: Context): Boolean { if (instance != null) return true // let's start the service // make sure we granted permission if (!FlutterFloatwingPlugin.permissionGiven(context)) { Log.e(TAG, "[service] don't have permission to create overlay window") return false } // start the service val intent = Intent(context, FloatwingService::class.java) context.startService(intent) // Service.onCreate() runs on main thread, so we can't block here // Return true optimistically - the service will be ready when needed // Since we're on main thread, service onCreate will run after this returns Log.i(TAG, "[service] service start requested, returning optimistically") return true } // Async version for callbacks fun ensureServiceAsync(context: Context, callback: (Boolean) -> Unit) { if (instance != null) { callback(true) return } if (!FlutterFloatwingPlugin.permissionGiven(context)) { Log.e(TAG, "[service] don't have permission to create overlay window") callback(false) return } val intent = Intent(context, FloatwingService::class.java) context.startService(intent) // Use Handler to check after service has chance to start android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ callback(instance != null) }, 100) } fun onActivityAttached(activity: Activity) { Log.i(TAG, "[service] activity attached") // maybe instance is null, so set failed if (mActivity != null) { Log.w(TAG, "[service] main activity already set") return } mActivity = activity // store the class mActivityClass = mActivity?.javaClass } fun installChannel(eng: FlutterEngine): Boolean { Log.i(TAG, "[service] install the service channel for engine") return instance?.installChannel(eng) ?: false } fun isRunning(context: Context): Boolean { return Utils.getRunningService(context, FloatwingService::class.java) != null } fun start(context: Context): Boolean { return ensureService(context) } } } ================================================ FILE: android/src/main/kotlin/im/zoe/labs/flutter_floatwing/FlutterFloatwingPlugin.kt ================================================ package im.zoe.labs.flutter_floatwing import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import android.util.Log import androidx.annotation.NonNull; import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry import org.json.JSONObject import java.lang.Exception /** FlutterFloatwingPlugin */ class FlutterFloatwingPlugin: FlutterPlugin, ActivityAware, MethodCallHandler, PluginRegistry.ActivityResultListener { private lateinit var mContext: Context private lateinit var mActivity: Activity private lateinit var channel : MethodChannel private lateinit var engine: FlutterEngine private lateinit var waitPermissionResult: Result private var serviceChannelInstalled = false private var isMain = false override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { mContext = binding.applicationContext // should window's engine install method channel? channel = MethodChannel(binding.binaryMessenger, CHANNEL_NAME) channel.setMethodCallHandler(this) // how to known i'm a window engine not the main one? // if contains engine already, means we are coming from window // TODO: take first engine as main, but if service auto start the window // this will cause error if (FlutterEngineCache.getInstance().contains(FLUTTER_ENGINE_CACHE_KEY)) { Log.d(TAG, "[plugin] on attached to window engine") } else { // update the main flag isMain = true // store the flutter engine @only main engine = binding.flutterEngine FlutterEngineCache.getInstance().put(FLUTTER_ENGINE_CACHE_KEY, engine) // should install service handler for every engine? @only main // window has already set in this own logic serviceChannelInstalled = FloatwingService.installChannel(engine) .also { r -> if (!r) { MethodChannel(engine.dartExecutor.binaryMessenger, "${FloatwingService.METHOD_CHANNEL}/window").also { it.setMethodCallHandler(this) } } } Log.d(TAG, "[plugin] on attached to main engine") } } private fun saveSystemConfig(data: Map<*, *>?): Boolean { if (data == null) return false val newScreen = data["screen"] as? Map<*, *> val newWidth = (newScreen?.get("width") as? Number)?.toInt() ?: 0 val newHeight = (newScreen?.get("height") as? Number)?.toInt() ?: 0 val newConfigValid = newWidth > 0 && newHeight > 0 val old = mContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) .getString(SYSTEM_CONFIG_KEY, null) if (old != null) { try { val oldJson = JSONObject(old) val oldScreen = oldJson.optJSONObject("screen") val oldWidth = oldScreen?.optInt("width", 0) ?: 0 val oldHeight = oldScreen?.optInt("height", 0) ?: 0 val oldConfigValid = oldWidth > 0 && oldHeight > 0 if (oldConfigValid) { Log.d(TAG, "[plugin] system config already exists with valid screen: $old") return false } if (!newConfigValid) { Log.d(TAG, "[plugin] both old and new config have invalid screen size, skipping update") return false } Log.d(TAG, "[plugin] updating system config: old has 0x0 screen, new has ${newWidth}x${newHeight}") } catch (e: Exception) { Log.e(TAG, "[plugin] error parsing old system config: ${e.message}") } } @Suppress("UNCHECKED_CAST") FloatwingService.instance?.systemConfig = data as Map return try { val str = JSONObject(data).toString() // json encode map to string // try to save mContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).edit() .putString(SYSTEM_CONFIG_KEY, str) .apply() true } catch (e: Exception) { e.printStackTrace() false } } private fun savePixelRadio(pixelRadio: Double): Boolean { val old = mContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) .getFloat(PIXEL_RADIO_KEY, 0F) if (old > 1F) { Log.d(TAG, "[plugin] pixel radio already exits") return false } FloatwingService.instance?.pixelRadio = pixelRadio // we need to save pixel radio Log.d(TAG, "[plugin] pixel radio need to be saved") mContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).edit() .putFloat(PIXEL_RADIO_KEY, pixelRadio.toFloat()) .apply() return true } private fun cleanCache(): Boolean { // delete all of cache files Log.w(TAG, "[plugin] will delete all of contents") mContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).edit() .clear().apply() return true } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { "plugin.initialize" -> { // the main engine should call initialize? // but the sub engine don't val pixelRadio = call.argument("pixelRadio") ?: 1.0 val systemConfig = call.argument?>("system") as Map<*, *> val map = HashMap() map["permission_grated"] = permissionGiven(mContext) map["service_running"] = FloatwingService.isRunning(mContext) map["windows"] = FloatwingService.instance?.windows?.map { it.value.toMap() } map["pixel_radio_updated"] = savePixelRadio(pixelRadio) map["system_config_updated"] = saveSystemConfig(systemConfig) return result.success(map) } "plugin.has_permission" -> { return result.success(permissionGiven(mContext)) } "plugin.open_permission_setting" -> { return result.success(requestPermissions()) } "plugin.grant_permission" -> { return grantPermission(result) } // remove "plugin.create_window" -> { val id = call.argument("id") ?: "default" val cfg = call.argument>("config")!! val start = call.argument("start") ?: false val config = FloatWindow.Config.from(cfg) // Use async version to avoid main thread blocking FloatwingService.createWindowAsync(mContext, id, config, start, null) { windowResult -> result.success(windowResult) } return } "plugin.is_service_running" -> { return result.success(FloatwingService.isRunning(mContext)) } "plugin.start_service" -> { return result.success(FloatwingService.isRunning(mContext) .or(FloatwingService.start(mContext))) } "plugin.clean_cache" -> { return result.success(cleanCache()) } "plugin.sync_windows" -> { return result.success(FloatwingService.instance?.windows?.map { it.value.toMap() }) } "window.sync" -> { Log.d(TAG, "[plugin] fake window.sync") return result.success(null) } else -> { Log.d(TAG, "[plugin] method ${call.method} not implement") result.notImplemented() } } } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { mActivity = binding.activity // TODO: notify the window to show and return the result? Log.d(TAG, "[plugin] on attached to activity") // how to known are the main FloatwingService.onActivityAttached(mActivity) } override fun onDetachedFromActivityForConfigChanges() { } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { mActivity = binding.activity } override fun onDetachedFromActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { if (requestCode == ALERT_WINDOW_PERMISSION) { waitPermissionResult.success(permissionGiven(mContext)) return true } return false } private fun requestPermissions(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mActivity.startActivityForResult(Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${mContext.packageName}") ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ALERT_WINDOW_PERMISSION) return true } return false } private fun grantPermission(result: Result) { waitPermissionResult = result requestPermissions() } companion object { private const val TAG = "FloatwingPlugin" private const val CHANNEL_NAME = "im.zoe.labs/flutter_floatwing/method" private const val ALERT_WINDOW_PERMISSION = 1248 const val FLUTTER_ENGINE_CACHE_KEY = "flutter_engine_main" const val SHARED_PREFERENCES_KEY = "flutter_floatwing_cache" const val CALLBACK_KEY = "callback_key" const val PIXEL_RADIO_KEY = "pixel_radio" const val SYSTEM_CONFIG_KEY = "system_config" fun permissionGiven(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return Settings.canDrawOverlays(context) } return false } } } ================================================ FILE: android/src/main/kotlin/im/zoe/labs/flutter_floatwing/Utils.kt ================================================ package im.zoe.labs.flutter_floatwing import android.app.ActivityManager import android.content.Context import org.json.JSONArray import org.json.JSONObject import java.math.BigInteger import java.security.MessageDigest class Utils { companion object { fun getRunningService(context: Context, serviceClass: Class<*>): ActivityManager.RunningServiceInfo? { val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { if (serviceClass.name == service.service.className) { return service } } return null } fun md5(input:String): String { val md = MessageDigest.getInstance("MD5") return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') } fun genKey(vararg items: Any?): String { return Utils.md5(items.joinToString(separator="-"){ "$it" }).slice(IntRange(0, 12)) } fun JSONObject.toMap(): Map = keys().asSequence().associateWith { when (val value = this[it]) { is JSONArray -> { val map = (0 until value.length()).associate { Pair(it.toString(), value[it]) } JSONObject(map).toMap().values.toList() } is JSONObject -> value.toMap() JSONObject.NULL -> null else -> value } } } } ================================================ FILE: example/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/doc/api/ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ /build/ # Web related lib/generated_plugin_registrant.dart # Symbolication related app.*.symbols # Obfuscation related app.*.map.json # Exceptions to above rules. !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages ================================================ FILE: example/.metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe channel: unknown project_type: app ================================================ FILE: example/README.md ================================================ # flutter_floatwing Example This example demonstrates the core features of `flutter_floatwing` plugin. ## Demos Included | Demo | Description | |------|-------------| | **Normal** | Basic draggable floating window | | **Assistive Touch** | iOS-style assistive touch button | | **Night Mode** | Full-screen non-clickable overlay filter | ## Running the Example ### Prerequisites - Flutter SDK (>=1.20.0) - Android device or emulator (API 21+) - USB debugging enabled ### Steps ```bash # Clone the repository git clone https://github.com/jiusanzhou/flutter_floatwing.git cd flutter_floatwing/example # Install dependencies flutter pub get # Run on connected device flutter run ``` ### Grant Permission When you first run the app, you'll need to grant the **"Display over other apps"** permission: 1. Click "Start" button in the app 2. System will redirect to permission settings 3. Enable the permission for the example app 4. Return to the app ## Project Structure ``` example/ ├── lib/ │ ├── main.dart # App entry & home page │ └── views/ │ ├── normal.dart # Basic floating window │ ├── assistive_touch.dart # Assistive touch demo │ └── night.dart # Night mode overlay └── android/ └── app/src/main/ └── AndroidManifest.xml # Permission declaration ``` ## Key Code Examples ### Creating Multiple Windows ```dart var _configs = [ WindowConfig(id: "normal", route: "/normal", draggable: true), WindowConfig(id: "assistive", route: "/assistive", draggable: true), WindowConfig( id: "night", route: "/night", width: WindowSize.MatchParent, height: WindowSize.MatchParent, clickable: false, // Touch passes through ), ]; ``` ### Using Route-based Entry Points ```dart // Register routes with .floatwing() wrapper Map _routes = { "/": (_) => HomePage(), "/normal": (_) => NormalView().floatwing(), "/assistive": (_) => AssistiveTouch().floatwing(), "/night": (_) => NightView().floatwing(), }; ``` ## Troubleshooting **Window not appearing?** - Ensure permission is granted - Check if service is running: `FloatwingPlugin().isServiceRunning()` **MissingPluginException?** - Clean rebuild: `flutter clean && flutter pub get && flutter run` **Debug mode issues?** - Use route-based entry points for easier debugging - Navigate to the route directly to test your overlay widget ================================================ FILE: example/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java ================================================ FILE: example/android/app/build.gradle ================================================ plugins { id "com.android.application" id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } android { namespace 'im.zoe.labs.flutter_floatwing_example' compileSdk 34 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' } defaultConfig { applicationId "im.zoe.labs.flutter_floatwing_example" minSdkVersion flutter.minSdkVersion targetSdk 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } buildTypes { release { signingConfig signingConfigs.debug } } } flutter { source '../..' } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.22" } ================================================ FILE: example/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: example/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: example/android/app/src/main/kotlin/im/zoe/labs/flutter_floatwing_example/MainActivity.kt ================================================ package im.zoe.labs.flutter_floatwing_example import android.os.Bundle import android.os.PersistableBundle import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: example/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: example/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: example/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: example/android/build.gradle ================================================ allprojects { repositories { google() mavenCentral() } } rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { delete rootProject.buildDir } ================================================ FILE: example/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME ================================================ FILE: example/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false ================================================ FILE: example/android/settings.gradle ================================================ pluginManagement { def flutterSdkPath = { def properties = new Properties() file("local.properties").withInputStream { properties.load(it) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" return flutterSdkPath }() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.5.0" apply false id "org.jetbrains.kotlin.android" version "1.9.22" apply false } include ":app" ================================================ FILE: example/lib/main.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; import 'package:flutter_floatwing_example/views/assistive_touch.dart'; import 'package:flutter_floatwing_example/views/night.dart'; import 'package:flutter_floatwing_example/views/normal.dart'; void main() { runApp(MyApp()); } @pragma("vm:entry-point") void floatwing() { runApp(((_) => NonrmalView()).floatwing().make()); } void floatwing2(Window w) { runApp(MaterialApp( // floatwing on widget can't use Window.of(context) // to access window instance // should use FloatwingPlugin().currentWindow home: NonrmalView().floatwing(), )); } class MyApp extends StatefulWidget { @override _MyAppState createState() => _MyAppState(); } class _MyAppState extends State { var _configs = [ WindowConfig( id: "normal", // entry: "floatwing", route: "/normal", draggable: true, ), WindowConfig( id: "assitive_touch", // entry: "floatwing", route: "/assitive_touch", draggable: true, ), WindowConfig( id: "night", // entry: "floatwing", route: "/night", width: WindowSize.MatchParent, height: WindowSize.MatchParent, clickable: false, ) ]; Map _builders = { "normal": (_) => NonrmalView(), "assitive_touch": (_) => AssistiveTouch(), "night": (_) => NightView(), }; Map _routes = {}; @override void initState() { super.initState(); _routes["/"] = (_) => HomePage(configs: _configs); _configs.forEach((c) => { if (c.route != null && _builders[c.id] != null) {_routes[c.route!] = _builders[c.id]!.floatwing(debug: false)} }); } @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, initialRoute: "/", routes: _routes, ); } } class HomePage extends StatefulWidget { final List configs; const HomePage({Key? key, required this.configs}) : super(key: key); @override State createState() => _HomePageState(); } class _HomePageState extends State { @override void initState() { super.initState(); widget.configs.forEach((c) => _windows.add(c.to())); FloatwingPlugin().initialize(); initAsyncState(); } List _windows = []; Map _readys = {}; bool _ready = false; initAsyncState() async { var p1 = await FloatwingPlugin().checkPermission(); var p2 = await FloatwingPlugin().isServiceRunning(); // get permission first if (!p1) { FloatwingPlugin().openPermissionSetting(); return; } // start service if (!p2) { FloatwingPlugin().startService(); } _createWindows(); setState(() { _ready = true; }); } _createWindows() async { await FloatwingPlugin().isServiceRunning().then((v) async { if (!v) await FloatwingPlugin().startService().then((_) { print("start the backgroud service success."); }); }); _windows.forEach((w) { var _w = FloatwingPlugin().windows[w.id]; if (null != _w) { // replace w with _w _readys[w] = true; return; } w.on(EventType.WindowCreated, (window, data) { _readys[window] = true; setState(() {}); }).create(); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Floatwing example app'), ), body: _ready ? ListView( children: _windows.map((e) => _item(e)).toList(), ) : Center( child: ElevatedButton( onPressed: () { initAsyncState(); }, child: Text("Start")), ), ); } _debug(Window w) { Navigator.of(context).pushNamed(w.config!.route!); } Widget _item(Window w) { return Card( margin: EdgeInsets.all(10), child: Padding( padding: EdgeInsets.all(10), child: Column( children: [ Text(w.id, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), SizedBox(height: 10), Container( width: double.infinity, padding: EdgeInsets.all(5), decoration: BoxDecoration( color: Color.fromARGB(255, 214, 213, 213), borderRadius: BorderRadius.all(Radius.circular(4))), child: Text(w.config?.toString() ?? ""), ), SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: (_readys[w] == true) ? () => w.start() : null, child: Text("Open"), ), TextButton( onPressed: w.config?.route != null ? () => _debug(w) : null, child: Text("Debug")), TextButton( onPressed: (_readys[w] == true) ? () => {w.close(), w.share("close")} : null, child: Text("Close", style: TextStyle(color: Colors.red)), ), ], ) ], )), ); } } ================================================ FILE: example/lib/views/assistive_touch.dart ================================================ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; class AssistiveTouch extends StatefulWidget { const AssistiveTouch({Key? key}) : super(key: key); @override State createState() => _AssistiveTouchState(); } @pragma("vm:entry-point") void _pannelMain() { runApp(((_) => AssistivePannel()).floatwing(app: true).make()); } class _AssistiveTouchState extends State { /// The state of the touch state bool expend = false; bool pannelReady = false; Window? pannelWindow; Window? touchWindow; @override void initState() { super.initState(); initAsyncState(); SchedulerBinding.instance?.addPostFrameCallback((_) { touchWindow = Window.of(context); touchWindow?.on(EventType.WindowStarted, (window, data) { print("touch window start ..."); expend = false; setState(() {}); }); }); } void initAsyncState() async { // create the pannel window pannelWindow = WindowConfig( id: "assistive_pannel", callback: _pannelMain, width: WindowSize.MatchParent, height: WindowSize.MatchParent, autosize: false, ).to(); pannelWindow?.create(); // we can't subscribe the events from other windows // that means pannelWindow's events can't be fired to here. // This is a feature, make sure window only care about events // from self. If we want to communicate with the other windows, // we can use the data communicatting method. pannelWindow?.on(EventType.WindowCreated, (window, data) { pannelReady = true; setState(() {}); }).on(EventType.WindowPaused, (window, data) { // open the assitive_touch touchWindow?.start(); }); } @override Widget build(BuildContext context) { return AssistiveButton(onTap: _onTap); } void _onTap() { var w = Window.of(context); var pixelRatio = w?.pixelRadio ?? 3.0; var x = (w?.config?.x ?? 0) / pixelRatio; var y = (w?.config?.y ?? 0) / pixelRatio; pannelWindow?.share([x, y]); pannelWindow?.start(); setState(() { expend = true; }); } } @immutable class AssistiveButton extends StatefulWidget { const AssistiveButton({ Key? key, this.child = const _DefaultChild(), this.visible = true, this.shouldStickToSide = true, this.margin = const EdgeInsets.all(8.0), this.initialOffset = Offset.infinite, this.onTap, this.animatedBuilder, }) : super(key: key); /// The widget below this widget in the tree. final Widget child; /// Switches between showing the [child] or hiding it. final bool visible; /// Whether it sticks to the side. final bool shouldStickToSide; /// Empty space to surround the [child]. final EdgeInsets margin; final Offset initialOffset; /// A tap with a primary button has occurred. final VoidCallback? onTap; /// Custom animated builder. final Widget Function( BuildContext context, Widget child, bool visible, )? animatedBuilder; @override _AssistiveButtonState createState() => _AssistiveButtonState(); } class _AssistiveButtonState extends State with TickerProviderStateMixin { bool isInitialized = false; late Offset offset = widget.initialOffset; late Offset largerOffset = offset; Size size = Size.zero; bool isDragging = false; bool isIdle = true; Timer? timer; late final AnimationController _scaleAnimationController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, )..addListener(() { setState(() {}); }); late final Animation _scaleAnimation = CurvedAnimation( parent: _scaleAnimationController, curve: Curves.easeInOut, ); Timer? scaleTimer; Window? window; @override void initState() { super.initState(); scaleTimer = Timer.periodic(const Duration(milliseconds: 60), (_) { if (mounted == false) { return; } if (widget.visible) { _scaleAnimationController.forward(); } else { _scaleAnimationController.reverse(); } }); FocusManager.instance.addListener(listener); } @override void didChangeDependencies() { super.didChangeDependencies(); if (isInitialized == false) { isInitialized = true; _setOffset(offset); } } @override void dispose() { timer?.cancel(); scaleTimer?.cancel(); _scaleAnimationController.dispose(); FocusManager.instance.removeListener(listener); super.dispose(); } void listener() { Timer(const Duration(milliseconds: 200), () { if (mounted == false) return; largerOffset = Offset( max(largerOffset.dx, offset.dx), max(largerOffset.dy, offset.dy), ); _setOffset(largerOffset, false); }); } @override Widget build(BuildContext context) { var child = widget.child; if (window == null) { window = Window.of(context); window?.on(EventType.WindowDragStart, (window, data) => _onDragStart()); window?.on(EventType.WindowDragging, (window, data) { var p = data as List; _onDragUpdate(p[0], p[1]); }); window?.on(EventType.WindowDragEnd, (window, windowdata) => _onDragEnd()); } child = GestureDetector( onTap: _onTap, child: child, ); child = widget.animatedBuilder != null ? widget.animatedBuilder!(context, child, widget.visible) : ScaleTransition( scale: _scaleAnimation, child: AnimatedOpacity( opacity: isIdle ? .3 : 1, duration: const Duration(milliseconds: 300), child: child, ), ); return child; } void _onTap() async { if (widget.onTap != null) { setState(() { isIdle = false; }); _scheduleIdle(); widget.onTap!(); } } void _onDragStart() { setState(() { isDragging = true; isIdle = false; }); timer?.cancel(); } Offset _old = Offset.zero; void _onDragUpdate(int x, int y) { _old = Offset(x.toDouble(), y.toDouble()); _setOffset(_old); } void _onDragEnd() { setState(() { isDragging = false; }); _scheduleIdle(); _setOffset(offset); } void _scheduleIdle() { timer?.cancel(); timer = Timer(const Duration(seconds: 2), () { if (isDragging == false) { setState(() { isIdle = true; }); } }); } void _updatePosition() { window?.update(WindowConfig( x: offset.dx.toInt(), y: offset.dy.toInt(), )); } /// TODO: this function should depend on the gravity to calcute the position void _setOffset(Offset offset, [bool shouldUpdateLargerOffset = true]) { if (shouldUpdateLargerOffset) { largerOffset = offset; } if (isDragging) { this.offset = offset; return; } final screenSize = window?.system?.screenSize ?? MediaQuery.of(context).size; final screenPadding = MediaQuery.of(context).padding; final viewInsets = MediaQuery.of(context).viewInsets; final left = screenPadding.left + viewInsets.left + widget.margin.left; final top = screenPadding.top + viewInsets.top + widget.margin.top; final right = screenSize.width - screenPadding.right - viewInsets.right - widget.margin.right - size.width; final bottom = screenSize.height - screenPadding.bottom - viewInsets.bottom - widget.margin.bottom - size.height; final halfWidth = (right - left) / 2; if (widget.shouldStickToSide) { final normalizedTop = max(min(offset.dy, bottom), top); final normalizedLeft = max( min( normalizedTop == bottom || normalizedTop == top ? offset.dx : offset.dx < halfWidth ? left : right, right, ), left, ); this.offset = Offset(normalizedLeft, normalizedTop); } else { final normalizedTop = max(min(offset.dy, bottom), top); final normalizedLeft = max(min(offset.dx, right), left); this.offset = Offset(normalizedLeft, normalizedTop); } _updatePosition(); } // Offset _applyGravity(Offset o) { // return window?.config?.gravity.apply(o) ?? o; // } } class AssistivePannel extends StatefulWidget { const AssistivePannel({ Key? key, }) : super(key: key); @override State createState() => _AssistivePannelState(); } class _AssistivePannelState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController = AnimationController( duration: _duration, vsync: this, ); Duration _duration = Duration(milliseconds: 250); Window? window; @override void dispose() { _animationController.dispose(); super.dispose(); } @override void initState() { super.initState(); SchedulerBinding.instance?.addPostFrameCallback((_) { window = Window.of(context); window?.on(EventType.WindowStarted, (window, data) { print("pannel just start ..."); setState(() { _show = true; }); }).onData((source, name, data) async { double x = (data[0] as num).toDouble(); double y = (data[1] as num).toDouble(); _updatePostion(x, y); return; }); }); } double _touchX = 0.0; double _touchY = 0.0; double _touchSize = 56.0; _updatePostion(double x, double y) { setState(() { _touchX = x; _touchY = y; }); } var factor = 0.8; double screenWidth = 0.0; double screenHeight = 0.0; double size = 0.0; @override Widget build(BuildContext context) { screenWidth = MediaQuery.of(context).size.width; screenHeight = MediaQuery.of(context).size.height; size = screenWidth * factor; final touchCenterX = _touchX + _touchSize / 2; final touchCenterY = _touchY + _touchSize / 2; final expandedLeft = (screenWidth - size) / 2; final expandedTop = max(20.0, min(touchCenterY - size / 2, screenHeight - size - 20)); final panelCenterX = expandedLeft + size / 2; final panelCenterY = expandedTop + size / 2; final alignX = (touchCenterX - panelCenterX) / (size / 2); final alignY = (touchCenterY - panelCenterY) / (size / 2); return GestureDetector( onTap: _onTap, child: Container( color: Colors.transparent, child: Stack( children: [ Positioned( left: expandedLeft, top: expandedTop, width: size, height: size, child: GestureDetector( onTap: () => null, child: AnimatedScale( scale: _show ? 1.0 : 0.0, alignment: Alignment( alignX.clamp(-1.0, 1.0), alignY.clamp(-1.0, 1.0)), duration: _duration, curve: Curves.easeOutCubic, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(16)), color: Color.fromARGB(255, 25, 24, 24), ), child: Stack( children: [], ), ), ), ), ), ], ), ), ); } bool _show = false; _onTap() { setState(() { _show = false; }); Timer(_duration, () => window?.close()); } } class _DefaultChild extends StatelessWidget { const _DefaultChild({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Container( height: 56, width: 56, alignment: Alignment.center, decoration: BoxDecoration( color: Colors.grey[900], borderRadius: const BorderRadius.all(Radius.circular(28)), ), child: Container( height: 40, width: 40, alignment: Alignment.center, decoration: BoxDecoration( color: Colors.grey[400]!.withOpacity(.6), borderRadius: const BorderRadius.all(Radius.circular(28)), ), child: Container( height: 32, width: 32, alignment: Alignment.center, decoration: BoxDecoration( color: Colors.grey[300]!.withOpacity(.6), borderRadius: const BorderRadius.all(Radius.circular(28)), ), child: Container( height: 24, width: 24, alignment: Alignment.center, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(28)), ), child: const SizedBox.expand(), ), ), ), ); } } ================================================ FILE: example/lib/views/night.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; class NightView extends StatefulWidget { const NightView({Key? key}) : super(key: key); @override State createState() => _NightViewState(); } class _NightViewState extends State { Color color = Color.fromARGB(255, 192, 200, 41).withOpacity(0.20); @override void initState() { super.initState(); } Window? w; var _show = true; @override Widget build(BuildContext context) { return Container( height: _show ? MediaQuery.of(context).size.height : 0, width: _show ? MediaQuery.of(context).size.width : 0, color: color, ); } } ================================================ FILE: example/lib/views/normal.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; class NonrmalView extends StatefulWidget { const NonrmalView({Key? key}) : super(key: key); @override State createState() => _NonrmalViewState(); } class _NonrmalViewState extends State { bool _expend = false; double _size = 150; @override void initState() { super.initState(); SchedulerBinding.instance?.addPostFrameCallback((_) { w = Window.of(context); w?.on(EventType.WindowDragStart, (window, data) { if (mounted) setState(() => {dragging = true}); }).on(EventType.WindowDragEnd, (window, data) { if (mounted) setState(() => {dragging = false}); }); }); } Window? w; bool dragging = false; _changeSize() { _expend = !_expend; _size = _expend ? 250 : 150; setState(() {}); } @override Widget build(BuildContext context) { return Center( child: Container( width: _size, height: _size, color: dragging ? Colors.yellowAccent : null, child: Card( child: Stack( children: [ Center( child: ElevatedButton( onPressed: () { w?.launchMainActivity(); }, child: Text("Start Activity"))), Positioned( right: 5, top: 5, child: Icon(Icons.drag_handle_rounded)), Positioned( right: 5, bottom: 5, child: RotationTransition( turns: AlwaysStoppedAnimation(-45 / 360), child: InkWell( onTap: _changeSize, child: Icon(Icons.unfold_more_rounded)))) ], )), ), ); } } ================================================ FILE: example/pubspec.yaml ================================================ name: flutter_floatwing_example description: Demonstrates how to use the flutter_floatwing plugin. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter flutter_floatwing: # When depending on this package from a real application you should use: # flutter_floatwing: ^x.y.z # See https://dart.dev/tools/pub/dependencies#version-constraints # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter. flutter: # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. # For details regarding adding assets from package dependencies, see # https://flutter.dev/assets-and-images/#from-packages # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: # fonts: # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf # - asset: fonts/Schyler-Italic.ttf # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages ================================================ FILE: example/test/widget_test.dart ================================================ // This is a basic Flutter widget test. // // To perform an interaction with a widget in your test, use the WidgetTester // utility that Flutter provides. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing_example/main.dart'; void main() { testWidgets('Verify Platform version', (WidgetTester tester) async { // Build our app and trigger a frame. await tester.pumpWidget(MyApp()); }); } ================================================ FILE: flutter_floatwing.iml ================================================ ================================================ FILE: integration_test/floatwing_integration_test.dart ================================================ // Integration tests for flutter_floatwing plugin // // These tests simulate end-to-end workflows by mocking the native layer. // Since flutter_floatwing requires Android-specific features (SYSTEM_ALERT_WINDOW), // true integration testing requires running on an actual Android device. // // These tests verify the Dart layer integration works correctly. import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('End-to-end workflow tests', () { const MethodChannel methodChannel = MethodChannel('im.zoe.labs/flutter_floatwing/method'); const MethodChannel windowChannel = MethodChannel('im.zoe.labs/flutter_floatwing/window'); // Track method calls for verification final List methodCalls = []; setUp(() { methodCalls.clear(); // Mock main plugin channel TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { methodCalls.add(methodCall.method); switch (methodCall.method) { case 'plugin.has_permission': return true; case 'plugin.open_permission_setting': return true; case 'plugin.is_service_running': return false; // Start as not running case 'plugin.start_service': return true; case 'plugin.initialize': return { 'permission_grated': true, 'service_running': true, 'windows': [], }; case 'plugin.sync_windows': return []; case 'plugin.create_window': final id = methodCall.arguments['id'] ?? 'default'; final config = methodCall.arguments['config']; return { 'id': id, 'pixelRadio': 2.0, 'config': config, }; default: return null; } }); // Mock window channel TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(windowChannel, (MethodCall methodCall) async { methodCalls.add(methodCall.method); switch (methodCall.method) { case 'window.start': return true; case 'window.show': return true; case 'window.close': return true; case 'window.update': return { 'id': methodCall.arguments['id'], 'config': methodCall.arguments['config'], }; case 'data.share': return {'received': true}; default: return null; } }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(windowChannel, null); }); test('Complete window creation workflow', () async { final plugin = FloatwingPlugin(); // Step 1: Check permission final hasPermission = await plugin.checkPermission(); expect(hasPermission, isTrue); expect(methodCalls, contains('plugin.has_permission')); // Step 2: Create window config final config = WindowConfig( id: 'test-overlay', route: '/overlay', width: 200, height: 100, draggable: true, gravity: GravityType.RightBottom, ); // Step 3: Create and start window final window = await plugin.createWindow('test-overlay', config, start: true); expect(window, isNotNull); expect(window?.id, equals('test-overlay')); expect(methodCalls, contains('plugin.create_window')); }); test('Window lifecycle workflow', () async { final plugin = FloatwingPlugin(); // Create window final config = WindowConfig(route: '/test'); final window = await plugin.createWindow('lifecycle-test', config); expect(window, isNotNull); // Start window final started = await window?.start(); expect(started, isTrue); expect(methodCalls, contains('window.start')); // Show/hide window final shown = await window?.show(visible: true); expect(shown, isTrue); expect(methodCalls, contains('window.show')); final hidden = await window?.hide(); expect(hidden, isTrue); // Close window final closed = await window?.close(); expect(closed, isTrue); expect(methodCalls, contains('window.close')); }); test('Window update workflow', () async { final plugin = FloatwingPlugin(); // Create window final config = WindowConfig( route: '/update-test', width: 100, height: 100, ); final window = await plugin.createWindow('update-test', config); expect(window, isNotNull); // Update window position final updateResult = await window?.update(WindowConfig( x: 50, y: 100, gravity: GravityType.Center, )); expect(updateResult, isTrue); expect(methodCalls, contains('window.update')); }); test('Data sharing workflow', () async { final plugin = FloatwingPlugin(); // Create window final config = WindowConfig(route: '/share-test'); final window = await plugin.createWindow('share-test', config); expect(window, isNotNull); // Share data with window final shareResult = await window?.share( {'message': 'Hello from main app!'}, name: 'greeting', ); expect(shareResult, isNotNull); expect(methodCalls, contains('data.share')); }); test('Event registration workflow', () async { final plugin = FloatwingPlugin(); // Create window final config = WindowConfig(route: '/event-test'); final window = await plugin.createWindow('event-test', config); expect(window, isNotNull); // Register event handlers window?.on(EventType.WindowCreated, (w, data) { // Handle created }).on(EventType.WindowStarted, (w, data) { // Handle started }).on(EventType.WindowDestroy, (w, data) { // Handle destroy }).on(EventType.WindowDragging, (w, data) { // Handle dragging }); // Verify chaining works expect(window, isNotNull); }); test('WindowConfig to Window workflow using to()', () async { // Using the fluent API final window = WindowConfig( id: 'fluent-test', route: '/fluent', width: 150, height: 150, draggable: true, clickable: true, ).to(); expect(window, isNotNull); expect(window.id, equals('fluent-test')); expect(window.config?.route, equals('/fluent')); expect(window.config?.width, equals(150)); expect(window.config?.draggable, isTrue); // Register events before creating window .on(EventType.WindowCreated, (w, data) {}) .on(EventType.WindowStarted, (w, data) {}); // Now create - this would actually create the window final created = await window.create(start: true); expect(created, isNotNull); }); test('Multiple windows workflow', () async { final plugin = FloatwingPlugin(); // Clear any existing windows plugin.windows.clear(); // Create multiple windows final window1 = await plugin.createWindow( 'window-1', WindowConfig(route: '/window1'), ); final window2 = await plugin.createWindow( 'window-2', WindowConfig(route: '/window2'), ); final window3 = await plugin.createWindow( 'window-3', WindowConfig(route: '/window3'), ); expect(window1, isNotNull); expect(window2, isNotNull); expect(window3, isNotNull); // Verify all windows are cached expect(plugin.windows.length, equals(3)); expect(plugin.windows.containsKey('window-1'), isTrue); expect(plugin.windows.containsKey('window-2'), isTrue); expect(plugin.windows.containsKey('window-3'), isTrue); // Close windows await window1?.close(); await window2?.close(); await window3?.close(); }); }); group('Error handling integration tests', () { const MethodChannel methodChannel = MethodChannel('im.zoe.labs/flutter_floatwing/method'); test('Should handle permission denied gracefully', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { if (methodCall.method == 'plugin.has_permission') { return false; } return null; }); final plugin = FloatwingPlugin(); final hasPermission = await plugin.checkPermission(); expect(hasPermission, isFalse); // Cleanup TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); }); test('Should throw when creating window without permission', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { if (methodCall.method == 'plugin.has_permission') { return false; } return null; }); final plugin = FloatwingPlugin(); final config = WindowConfig(route: '/test'); expect( () => plugin.createWindow('no-permission', config), throwsException, ); // Cleanup TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); }); }); group('Configuration combinations', () { test('Full-screen overlay config (night mode style)', () { final config = WindowConfig( id: 'night-mode', route: '/night', width: WindowSize.MatchParent, height: WindowSize.MatchParent, clickable: false, // Touch passes through focusable: false, ); expect(config.width, equals(WindowSize.MatchParent)); expect(config.height, equals(WindowSize.MatchParent)); expect(config.clickable, isFalse); expect(config.focusable, isFalse); }); test('Floating button config (assistive touch style)', () { final config = WindowConfig( id: 'float-button', route: '/button', width: 56, height: 56, draggable: true, gravity: GravityType.RightBottom, autosize: true, ); expect(config.width, equals(56)); expect(config.height, equals(56)); expect(config.draggable, isTrue); expect(config.gravity, equals(GravityType.RightBottom)); expect(config.autosize, isTrue); }); test('Popup config', () { final config = WindowConfig( id: 'popup', route: '/popup', width: 300, height: 200, gravity: GravityType.Center, clickable: true, focusable: true, draggable: false, ); expect(config.gravity, equals(GravityType.Center)); expect(config.clickable, isTrue); expect(config.focusable, isTrue); expect(config.draggable, isFalse); }); test('All gravity types should be distinct', () { final gravities = [ GravityType.Center, GravityType.CenterTop, GravityType.CenterBottom, GravityType.LeftTop, GravityType.LeftCenter, GravityType.LeftBottom, GravityType.RightTop, GravityType.RightCenter, GravityType.RightBottom, ]; // All should have unique int values final intValues = gravities.map((g) => g.toInt()).toSet(); expect(intValues.length, equals(gravities.length)); }); }); } ================================================ FILE: lib/flutter_floatwing.dart ================================================ export 'src/plugin.dart'; export 'src/window.dart'; export 'src/provider.dart'; export 'src/event.dart'; export 'src/utils.dart'; export 'src/constants.dart'; ================================================ FILE: lib/src/constants.dart ================================================ import 'package:flutter/material.dart'; /// window size /// class WindowSize { static const int MatchParent = -1; static const int WrapContent = -2; } enum GravityType { Center, CenterTop, CenterBottom, LeftTop, LeftCenter, LeftBottom, RightTop, RightCenter, RightBottom, Unknown, } extension GravityTypeConverter on GravityType { // ignore: slash_for_doc_comments /** public static final int AXIS_CLIP = 8; public static final int AXIS_PULL_AFTER = 4; public static final int AXIS_PULL_BEFORE = 2; public static final int AXIS_SPECIFIED = 1; public static final int AXIS_X_SHIFT = 0; public static final int AXIS_Y_SHIFT = 4; public static final int BOTTOM = 80; public static final int CENTER = 17; public static final int CENTER_HORIZONTAL = 1; public static final int CENTER_VERTICAL = 16; public static final int CLIP_HORIZONTAL = 8; public static final int CLIP_VERTICAL = 128; public static final int DISPLAY_CLIP_HORIZONTAL = 16777216; public static final int DISPLAY_CLIP_VERTICAL = 268435456; public static final int END = 8388613; public static final int FILL = 119; public static final int FILL_HORIZONTAL = 7; public static final int FILL_VERTICAL = 112; public static final int HORIZONTAL_GRAVITY_MASK = 7; public static final int LEFT = 3; public static final int NO_GRAVITY = 0; public static final int RELATIVE_HORIZONTAL_GRAVITY_MASK = 8388615; public static final int RELATIVE_LAYOUT_DIRECTION = 8388608; public static final int RIGHT = 5; public static final int START = 8388611; public static final int TOP = 48; public static final int VERTICAL_GRAVITY_MASK = 112; */ // 0001 0001 static const Center = 17; // 0011 0000 static const Top = 48; // 0101 0000 static const Bottom = 80; // 0000 0011 static const Left = 3; // 0000 0101 static const Right = 5; static final _values = { GravityType.Center: Center, GravityType.CenterTop: Top | Center, GravityType.CenterBottom: Bottom | Center, GravityType.LeftTop: Top | Left, GravityType.LeftCenter: Center | Left, GravityType.LeftBottom: Bottom | Left, GravityType.RightTop: Top | Right, GravityType.RightCenter: Center | Right, GravityType.RightBottom: Bottom | Right, }; int? toInt() { return _values[this]; } GravityType? fromInt(int? v) { if (v == null) return null; var r = _values.keys .firstWhere((e) => _values[e] == v, orElse: () => GravityType.Unknown); return r == GravityType.Unknown ? null : r; } /// convert offset in topleft to others Offset apply( Offset o, { required double width, required double height, }) { var v = this.toInt(); if (v == null) return o; var dx = o.dx; var dy = o.dy; // Reserved for future gravity calculation // var halfWidth = width / 2; // var halfHeight = height / 2; // calcute the x: & 0000 1111 = 15 // 3 1 5 => -1 0 1 => 0 1 2 // dx += ((v&15) / 2) * halfWidth; // if (v&15 == 1) { // dx += halfWidth; // } else if (v&15 == 2) { // dx += width; // } // // calcute the y: & 1111 0000 = 240 // // 48 16 80 => 0 1 2 // dy += ((v&240) / 2) * halfHeight; return Offset(dx, dy); } } ================================================ FILE: lib/src/event.dart ================================================ import 'dart:developer'; import 'package:flutter/services.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; typedef WindowListener = dynamic Function(Window window, dynamic data); /// events name enum EventType { WindowCreated, WindowStarted, WindowPaused, WindowResumed, WindowDestroy, WindowDragStart, WindowDragging, WindowDragEnd, } extension _EventType on EventType { static final _names = { EventType.WindowCreated: "window.created", EventType.WindowStarted: "window.started", EventType.WindowPaused: "window.paused", EventType.WindowResumed: "window.resumed", EventType.WindowDestroy: "window.destroy", EventType.WindowDragStart: "window.drag_start", EventType.WindowDragging: "window.dragging", EventType.WindowDragEnd: "window.drag_end", }; /// Parse event type from string (reserved for future use) // ignore: unused_element static EventType? fromString(String v) { try { return EventType.values.firstWhere((e) => e.name == v); } catch (_) { return null; } } String get name => _names[this]!; } /// Event is a common event class Event { /// id is window id String? id; /// name is the event name String? name; /// data is the payload for event dynamic data; Event({ this.id, this.name, this.data, }); factory Event.fromMap(Map map) { return Event(id: map["id"], name: map["name"], data: map["data"]); } } // final SendPort? _send = IsolateNameServer.lookupPortByName(SEND_PORT_NAME); class EventManager { EventManager._(this._msgChannel) { // set just for window, so window have no need to do this _msgChannel.setMessageHandler((msg) { var map = msg as Map?; if (map == null) { log("[event] unsupported message, we except a map"); } var evt = Event.fromMap(map!); var rs = sink(evt); log("[event] handled event: ${evt.name}, handlers: ${rs.length}"); return Future.value(null); }); } // event listenders // because enum from string O(n), so just use string // Map>> _listeners = {}; // w.id -> type -> w -> [cb] Map>>> _listeners = {}; Map> _windows = {}; BasicMessageChannel _msgChannel; // make sure one channel must only have one instance static final Map _instances = {}; factory EventManager( BasicMessageChannel _msgChannel, { Window? window, }) { if (_instances[_msgChannel.name] == null) { _instances[_msgChannel.name] = EventManager._(_msgChannel); } var current = _instances[_msgChannel.name]!; // store the window which create the event manager if (window != null) { if (current._windows[window.id] == null) current._windows[window.id] = []; current._windows[window.id]!.add(window); } // make sure one message channel only one event manager return current; } List sink(Event evt) { var res = []; // w.id -> type -> w -> [cb] // get windows var ws = (_listeners[evt.id] ?? {})[evt.name] ?? {}; ws.forEach((w, cbs) { (cbs).forEach((c) { res.add(c(w, evt.data)); }); }); return res; } EventManager on(Window window, EventType type, WindowListener callback) { var key = type.name; log("[event] register listener $key for $window"); // w.id -> w -> type -> [cb] if (_listeners[window.id] == null) _listeners[window.id] = {}; if (_listeners[window.id]![key] == null) _listeners[window.id]![key] = {}; if (_listeners[window.id]![key]![window] == null) _listeners[window.id]![key]![window] = []; if (!_listeners[window.id]![key]![window]!.contains(callback)) _listeners[window.id]![key]![window]!.add(callback); return this; } @override String toString() { return "EventManager@${super.hashCode}"; } } ================================================ FILE: lib/src/plugin.dart ================================================ import 'dart:async'; import 'dart:developer'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; class FloatwingPlugin { FloatwingPlugin._() { WidgetsFlutterBinding.ensureInitialized(); // make sure this only be called once // what happens when multiple window instances // are created and register event handlers? // Window().on(): id -> [Window, Window] // _eventManager = EventManager(_msgChannel); // _bgChannel.setMethodCallHandler((call) { // var id = call.arguments as String; // // if we are window egine, should call main engine // FloatwingPlugin().windows[id]?.eventManager?.sink(call.method, call.arguments); // switch (call.method) { // } // return Future.value(null); // }); } static const String channelID = "im.zoe.labs/flutter_floatwing"; static final MethodChannel _channel = MethodChannel('$channelID/method'); // Reserved for future background communication // ignore: unused_field static final MethodChannel _bgChannel = MethodChannel('$channelID/bg_method'); // Reserved for future message-based communication // ignore: unused_field static final BasicMessageChannel _msgChannel = BasicMessageChannel('$channelID/bg_message', JSONMessageCodec()); static final FloatwingPlugin _instance = FloatwingPlugin._(); /// event manager // EventManager? _eventManager; /// flag for inited bool _inited = false; /// permission granted already (updated by initialize) // ignore: unused_field bool? _permissionGranted; /// service running already (updated by initialize) // ignore: unused_field bool? _serviceRunning; /// _windows for the main engine to manage the windows started /// items added by start function Map _windows = {}; /// reutrn all windows only works for main engine Map get windows => _windows; // _windows.entries.map((e) => e.value).toList(); /// _window for the sub window engine to manage it's self /// setted after window's engine start and initital call Window? _window; /// return current window for window's engine Window? get currentWindow => _window; /// i'm window engine, default is the main engine /// if we sync success, we set to true. bool get isWindow => _isWindow; bool _isWindow = false; factory FloatwingPlugin() { return _instance; } FloatwingPlugin get instance { return _instance; } /// sync make the plugin to sync windows from services Future syncWindows() async { var _ws = await _channel.invokeListMethod("plugin.sync_windows"); _ws?.forEach((e) { var w = Window.fromMap(e); _windows[w.id] = w; }); return true; } Future _getValidSystemConfig() async { var config = SystemConfig(); if (config.screenWidth != null && config.screenWidth! > 0 && config.screenHeight != null && config.screenHeight! > 0) { return config; } final completer = Completer(); void checkMetrics(Duration _) { final view = PlatformDispatcher.instance.implicitView; final size = view?.physicalSize ?? Size.zero; if (size.width > 0 && size.height > 0) { completer.complete(SystemConfig()); } else { WidgetsBinding.instance.addPostFrameCallback(checkMetrics); } } WidgetsBinding.instance.addPostFrameCallback(checkMetrics); return completer.future.timeout( const Duration(seconds: 5), onTimeout: () => config, ); } Future initialize() async { if (_inited) return false; _inited = true; final systemConfig = await _getValidSystemConfig(); final view = PlatformDispatcher.instance.implicitView; var map = await _channel.invokeMapMethod("plugin.initialize", { "pixelRadio": view?.devicePixelRatio ?? 1.0, "system": systemConfig.toMap(), }); log("[plugin] initialize result: $map"); _serviceRunning = map?["service_running"]; _permissionGranted = map?["permission_grated"]; var ws = map?["windows"] as List?; ws?.forEach((e) { var w = Window.fromMap(e); _windows[w.id] = w; }); log("[plugin] there are ${_windows.length} windows already started"); return true; } Future checkPermission() async { return await _channel.invokeMethod("plugin.has_permission"); } Future openPermissionSetting() async { return await _channel.invokeMethod("plugin.open_permission_setting"); } Future isServiceRunning() async { return await _channel.invokeMethod("plugin.is_service_running"); } Future startService() async { return await _channel.invokeMethod("plugin.start_service"); } Future cleanCache() async { return await _channel.invokeMethod("plugin.clean_cache"); } /// create window to create a window Future createWindow( String? id, WindowConfig config, { bool start = false, // start immediately if true Window? window, }) async { var w = isWindow ? await currentWindow?.createChildWindow(id, config, start: start, window: window) : await internalCreateWindow(id, config, start: start, window: window, channel: _channel); if (w == null) return null; // store current window for window engine // for window engine use, update the current window // if we use create_window first? // _window = w; // we should don't use create_window first!!! // store the window to cache _windows[w.id] = w; return w; } // create window object for main engine Future internalCreateWindow( String? id, WindowConfig config, { bool start = false, // start immediately if true Window? window, required MethodChannel channel, String name = "plugin.create_window", }) async { // check permission first if (!await checkPermission()) { throw Exception("no permission to create window"); } // store the window first // window.id can't be updated // for main engine use // if (window != null) _windows[window.id] = window; var updates = await channel.invokeMapMethod(name, { "id": id, "config": config.toMap(), "start": start, }); // if window is not created, new one return updates == null ? null : (window ?? Window()).applyMap(updates); } /// ensure window make sure the window object sync from android /// call this as soon at posible when engine start /// you should only call this in the window engine /// if only main as entry point, it's ok to call this /// and return nothing // only window engine call this // make sure window engine return only one window from every where Future ensureWindow() async { // window object don't have sync method, we must do at here // assert if you are in main engine should call this var map = await Window.sync(); log("[window] sync window object from android: $map"); if (map == null) return null; // store current window if needed // use the static window first // so sync will return only one instance of window // improve this logic // means first time call sync, just create a new window if (_window == null) _window = Window(); _window!.applyMap(map); _isWindow = true; return _window; } /// `on` register event handlers for all windows /// or we can use stream mode FloatwingPlugin on(EventType type, WindowListener callback) { // TODO: return this; } } ================================================ FILE: lib/src/provider.dart ================================================ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; // typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child); // typedef WidgetBuilder = Widget Function(BuildContext context); class FloatwingProvider extends InheritedWidget { final Window? window; final Widget child; FloatwingProvider({ Key? key, required this.child, required this.window, }) : super(key: key, child: child); @override bool updateShouldNotify(FloatwingProvider oldWidget) { return true; } } class FloatwingContainer extends StatefulWidget { final Widget? child; final WidgetBuilder? builder; final bool debug; final bool app; const FloatwingContainer({ Key? key, this.child, this.builder, this.debug = false, this.app = false, }) : assert(child != null || builder != null), super(key: key); @override State createState() => _FloatwingContainerState(); } class _FloatwingContainerState extends State { Window? _window = FloatwingPlugin().currentWindow; var _ignorePointer = false; var _autosize = true; @override void initState() { super.initState(); initSyncState(); } initSyncState() async { // send started message to service // this make sure ui already if (_window == null) { log("[provider] have not sync window at init, need to do at here"); await FloatwingPlugin().ensureWindow().then((w) => _window = w); } // init window from engine and save, only call this int here // sync a window from engine _changed(); _window?.on(EventType.WindowResumed, (w, _) => _changed()); } Widget _empty = Container(); @override Widget build(BuildContext context) { // make sure window is ready? if (!widget.debug && _window == null) return _empty; // in production, make sure builder when window is ready return Builder(builder: widget.builder ?? (_) => widget.child!) ._provider(_window) ._autosize(enabled: _autosize, onChange: _onSizeChanged) ._material(color: Colors.transparent) ._pointerless(_ignorePointer) ._app(enabled: widget.app, debug: widget.debug); } @override void dispose() { super.dispose(); // TODO: remove event listener // w.un("resumed").un("") } _changed() async { // clickable == !ignorePointer _ignorePointer = !(_window?.config?.clickable ?? true); _autosize = _window?.config?.autosize ?? true; // update the flutter ui if (mounted) setState(() {}); } _onSizeChanged(Size size) { var radio = _window?.pixelRadio ?? 1; _window?.update(WindowConfig( width: (size.width * radio).toInt(), height: (size.height * radio).toInt(), )); } } class _MeasuredSized extends StatefulWidget { const _MeasuredSized({ Key? key, required this.onChange, required this.child, this.delay = 0, }) : super(key: key); final Widget child; final int delay; final void Function(Size size)? onChange; @override _MeasuredSizedState createState() => _MeasuredSizedState(); } class _MeasuredSizedState extends State<_MeasuredSized> { @override void initState() { SchedulerBinding.instance.addPostFrameCallback(postFrameCallback); super.initState(); } @override Widget build(BuildContext context) { if (widget.onChange == null) return widget.child; SchedulerBinding.instance.addPostFrameCallback(postFrameCallback); return UnconstrainedBox( child: Container( key: widgetKey, child: NotificationListener( onNotification: (_) { SchedulerBinding.instance.addPostFrameCallback(postFrameCallback); return true; }, child: SizeChangedLayoutNotifier(child: widget.child), ), ), ); } final widgetKey = GlobalKey(); Size? oldSize; void postFrameCallback(Duration _) async { final ctx = widgetKey.currentContext; if (ctx == null) return; if (widget.delay > 0) { await Future.delayed(Duration(milliseconds: widget.delay)); } if (mounted == false) return; final newSize = ctx.size; if (newSize == null || newSize == Size.zero) return; // if (oldSize == newSize) return; oldSize = newSize; widget.onChange!(newSize); } } typedef DragCallback = void Function(Offset offset); class _DragAnchor extends StatefulWidget { final Widget child; // TODO: // final bool horizontal; // final bool vertical; // final DragCallback? onDragStart; // final DragCallback? onDragUpdate; // final DragCallback? onDragEnd; const _DragAnchor({ Key? key, required this.child, // this.horizontal = true, // this.vertical = true, // this.onDragStart, // this.onDragUpdate, // this.onDragEnd, }) : super(key: key); @override State<_DragAnchor> createState() => _DragAnchorState(); } class _DragAnchorState extends State<_DragAnchor> { @override Widget build(BuildContext context) { // return Draggable(); return GestureDetector( onTapDown: _enableDrag, onTapUp: _disableDrag2, onTapCancel: _disableDrag, child: widget.child, ); } _enableDrag(_) { // enabe drag Window.of(context)?.update(WindowConfig( draggable: true, )); } _disableDrag() { // disable drag Window.of(context)?.update(WindowConfig( draggable: false, )); } _disableDrag2(_) { _disableDrag(); } } class _ResizeAnchor extends StatefulWidget { final Widget child; // Reserved for future resize direction support // ignore: unused_element final bool horizontal; // ignore: unused_element final bool vertical; const _ResizeAnchor({ Key? key, required this.child, this.horizontal = true, this.vertical = true, }) : super(key: key); @override State<_ResizeAnchor> createState() => __ResizeAnchorState(); } class __ResizeAnchorState extends State<_ResizeAnchor> { @override Widget build(BuildContext context) { return GestureDetector( onScaleStart: (v) { print("=======> scale start $v"); }, onScaleUpdate: (v) { print("=======> scale update $v"); }, onScaleEnd: (v) { print("=======> scale end $v"); }, child: widget.child, ); } } extension WidgetProviderExtension on Widget { /// Export floatwing extension function to inject for root widget Widget floatwing({ bool debug = false, bool app = false, }) { return FloatwingContainer(child: this, debug: debug, app: app); } /// Export draggable extension function to inject for child widget // Widget draggable({ // bool enabled = true, // }) { // return enabled?_DragAnchor(child: this):this; // } /// Export resizable extension function to inject for child // Widget resizable({ // bool enabled = true, // }) { // return enabled?_ResizeAnchor(child: this):this; // } Widget _provider(Window? window) { return FloatwingProvider(child: this, window: window); } Widget _autosize({ bool enabled = false, void Function(Size)? onChange, int delay = 0, }) { return !enabled ? this : _MeasuredSized(child: this, delay: delay, onChange: onChange); } Widget _pointerless([bool ignoring = false]) { return IgnorePointer(child: this, ignoring: ignoring); } Widget _material({ bool enabled = false, Color? color, }) { return !enabled ? this : Material(color: color, child: this); } Widget _app({ bool enabled = false, bool debug = false, }) { return !enabled ? this : MaterialApp(debugShowCheckedModeBanner: debug, home: this); } } extension WidgetBuilderProviderExtension on WidgetBuilder { WidgetBuilder floatwing({ bool debug = false, bool app = false, }) { return (_) => FloatwingContainer( builder: this, debug: debug, app: app, ); } Widget make() { return Builder(builder: this); } } ================================================ FILE: lib/src/utils.dart ================================================ import 'dart:ui'; class SystemConfig { int? pixelRadio; int? screenWidth; int? screenHeight; Size? screenSize; SystemConfig._({ this.pixelRadio, this.screenWidth, this.screenHeight, }) { var w = screenWidth?.toDouble(); var h = screenHeight?.toDouble(); if (w != null && h != null) screenSize = Size(w, h); } Map toMap() { return { "pixelRadio": pixelRadio, "screen": { "height": screenHeight, "width": screenWidth, }, }; } @override String toString() { return "${toMap()} $screenSize"; } factory SystemConfig() { final view = PlatformDispatcher.instance.implicitView; return SystemConfig._( pixelRadio: view?.devicePixelRatio.toInt() ?? 1, screenHeight: view?.physicalSize.height.toInt() ?? 0, screenWidth: view?.physicalSize.width.toInt() ?? 0, ); } factory SystemConfig.fromMap(Map map) { var screen = map["screen"] ?? {}; return SystemConfig._( pixelRadio: map["pixelRadio"], screenHeight: screen["height"], screenWidth: screen["width"], ); } } ================================================ FILE: lib/src/window.dart ================================================ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'dart:convert'; import 'package:flutter_floatwing/flutter_floatwing.dart'; typedef OnDataHanlder = Future Function( String? source, String? name, dynamic data); class Window { String id = "default"; WindowConfig? config; double? pixelRadio; SystemConfig? system; OnDataHanlder? _onDataHandler; late EventManager _eventManager; Window({this.id = "default", this.config}) { _eventManager = EventManager(_message, window: this); // share data use the call _channel.setMethodCallHandler((call) { switch (call.method) { case "data.share": { var map = call.arguments as Map; // source, name, data // if not provided, should not call this return _onDataHandler?.call( map["source"], map["name"], map["data"]) ?? Future.value(null); } } return Future.value(null); }); } static final MethodChannel _channel = MethodChannel('${FloatwingPlugin.channelID}/window'); static final BasicMessageChannel _message = BasicMessageChannel( '${FloatwingPlugin.channelID}/window_msg', JSONMessageCodec()); factory Window.fromMap(Map? map) { return Window().applyMap(map); } @override String toString() { return "Window[$id]@${super.hashCode}, ${_eventManager.toString()}, config: $config"; } Window applyMap(Map? map) { // apply the map to config and object if (map == null) return this; id = map["id"]; pixelRadio = map["pixelRadio"] ?? 1.0; system = SystemConfig.fromMap(map["system"] ?? {}); config = WindowConfig.fromMap(map["config"]); return this; } /// `of` extact window object window from context /// The data from the closest instance of this class that encloses the given /// context. static Window? of(BuildContext context) { return context .dependOnInheritedWidgetOfExactType() ?.window; } Future hide() { return show(visible: false); // return FloatwingPlugin().showWindow(id, false); } Future close({bool force = false}) async { // return await FloatwingPlugin().closeWindow(id, force: force); return await _channel.invokeMethod("window.close", { "id": id, "force": force, }).then((v) { // remove the window from plugin FloatwingPlugin().windows.remove(id); return v; }); } Future create({bool start = false}) async { // // create the engine first return await FloatwingPlugin() .createWindow(this.id, this.config!, start: start, window: this); } /// create child window /// just method shoudld only called in window engine Future createChildWindow( String? id, WindowConfig config, { bool start = false, // start immediately if true Window? window, }) async { return FloatwingPlugin().internalCreateWindow(id, config, start: start, window: window, channel: _channel, name: "window.create_child"); } Future start() async { assert(config != null, "config can't be null"); return await _channel.invokeMethod("window.start", { "id": id, }); // return await FloatwingPlugin().startWindow(id); } Future update(WindowConfig cfg) async { // update window with config, config con't update with id, entry, route var size = config?.size; if (size != null && size < Size.zero) { // special case, should updated cfg.width = null; cfg.height = null; } var updates = await _channel.invokeMapMethod("window.update", { "id": id, // don't set pixelRadio "config": cfg.toMap(), }); // var updates = await FloatwingPlugin().updateWindow(id, cfg); // update the plugin store applyMap(updates); return true; } Future show({bool visible = true}) async { config?.visible = visible; return await _channel.invokeMethod("window.show", { "id": id, "visible": visible, }).then((v) { // update the plugin store if (v) FloatwingPlugin().windows[id]?.config?.visible = visible; return v; }); } /// share data with current window /// send data use current window id as target id /// and get value return Future share( dynamic data, { String name = "default", }) async { var map = {}; map["target"] = id; map["data"] = data; map["name"] = name; // make sure data is serialized return await _channel.invokeMethod("data.share", map); } /// launch main activity Future launchMainActivity() async { return await _channel.invokeMethod("window.launch_main"); } /// on data to receive data from other shared /// maybe same like event handler /// but one window in engine can only have one data handler /// to make sure data not be comsumed multiple times. Window onData(OnDataHanlder handler) { assert(_onDataHandler == null, "onData can only called once"); _onDataHandler = handler; return this; } // sync window object from android service // only window engine call this // if we manage other windows in some window engine // this will not works, we must improve it static Future?> sync() async { return await _channel.invokeMapMethod("window.sync"); } /// on register callback to listener Window on(EventType type, WindowListener callback) { _eventManager.on(this, type, callback); return this; } Map toMap() { var map = Map(); map["id"] = id; map["pixelRadio"] = pixelRadio; map["config"] = config?.toMap(); return map; } } class WindowConfig { String? id; String? entry; String? route; Function? callback; // use callback to start engine bool? autosize; int? width; int? height; int? x; int? y; int? format; GravityType? gravity; int? type; bool? clickable; bool? draggable; bool? focusable; /// immersion status bar bool? immersion; bool? visible; /// we need this for update, so must wihtout default value WindowConfig({ this.id = "default", this.entry = "main", this.route, this.callback, this.autosize, this.width, this.height, this.x, this.y, this.format, this.gravity, this.type, this.clickable, this.draggable, this.focusable, this.immersion, this.visible, }) : assert( callback == null || PluginUtilities.getCallbackHandle(callback) != null, "callback is not a static function"); factory WindowConfig.fromMap(Map map) { var _cb; if (map["callback"] != null) _cb = PluginUtilities.getCallbackFromHandle( CallbackHandle.fromRawHandle(map["callback"])); return WindowConfig( // id: map["id"], entry: map["entry"], route: map["route"], callback: _cb, // get the callback from id autosize: map["autosize"], width: map["width"], height: map["height"], x: map["x"], y: map["y"], format: map["format"], gravity: GravityType.Unknown.fromInt(map["gravity"]), type: map["type"], clickable: map["clickable"], draggable: map["draggable"], focusable: map["focusable"], immersion: map["immersion"], visible: map["visible"], ); } Map toMap() { var map = Map(); // map["id"] = id; map["entry"] = entry; map["route"] = route; // find the callback id from callback function map["callback"] = callback != null ? PluginUtilities.getCallbackHandle(callback!)?.toRawHandle() : null; map["autosize"] = autosize; map["width"] = width; map["height"] = height; map["x"] = x; map["y"] = y; map["format"] = format; map["gravity"] = gravity?.toInt(); map["type"] = type; map["clickable"] = clickable; map["draggable"] = draggable; map["focusable"] = focusable; map["immersion"] = immersion; map["visible"] = visible; return map; } // return a window frm config Window to() { // will lose window instance return Window(id: this.id ?? "default", config: this); } Future create({ String? id = "default", bool start = false, }) async { assert(!(entry == "main" && route == null)); return await FloatwingPlugin().createWindow(id, this, start: start); } Size get size => Size((width ?? 0).toDouble(), (height ?? 0).toDouble()); @override String toString() { var map = this.toMap(); map.removeWhere((key, value) => value == null); return json.encode(map).toString(); } } ================================================ FILE: pubspec.yaml ================================================ name: flutter_floatwing description: A Flutter plugin that makes it easier to make floating/overlay window for Android with pure Flutter. version: 0.3.1 homepage: https://github.com/jiusanzhou/flutter_floatwing environment: sdk: '>=3.0.0 <4.0.0' flutter: ">=1.20.0" dependencies: flutter: sdk: flutter dev_dependencies: flutter_test: sdk: flutter # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter. flutter: # This section identifies this Flutter project as a plugin project. # The 'pluginClass' and Android 'package' identifiers should not ordinarily # be modified. They are used by the tooling to maintain consistency when # adding or updating assets for this project. plugin: platforms: android: package: im.zoe.labs.flutter_floatwing pluginClass: FlutterFloatwingPlugin # To add assets to your plugin package, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # # For details regarding assets in packages, see # https://flutter.dev/assets-and-images/#from-packages # # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. # To add custom fonts to your plugin package, add a fonts section here, # in this "flutter" section. Each entry in this list should have a # "family" key with the font family name, and a "fonts" key with a # list giving the asset and other descriptors for the font. For # example: # fonts: # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf # - asset: fonts/Schyler-Italic.ttf # style: italic # - family: Trajan Pro # fonts: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # # For details regarding fonts in packages, see # https://flutter.dev/custom-fonts/#from-packages ================================================ FILE: test/event_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('EventType', () { test('should have all expected event types', () { expect(EventType.values, contains(EventType.WindowCreated)); expect(EventType.values, contains(EventType.WindowStarted)); expect(EventType.values, contains(EventType.WindowPaused)); expect(EventType.values, contains(EventType.WindowResumed)); expect(EventType.values, contains(EventType.WindowDestroy)); expect(EventType.values, contains(EventType.WindowDragStart)); expect(EventType.values, contains(EventType.WindowDragging)); expect(EventType.values, contains(EventType.WindowDragEnd)); }); test('should have correct number of event types', () { expect(EventType.values.length, equals(8)); }); }); group('Event', () { test('should create with all parameters', () { final event = Event( id: 'window-1', name: 'window.created', data: {'key': 'value'}, ); expect(event.id, equals('window-1')); expect(event.name, equals('window.created')); expect(event.data, isA()); expect(event.data['key'], equals('value')); }); test('should create with null parameters', () { final event = Event(); expect(event.id, isNull); expect(event.name, isNull); expect(event.data, isNull); }); test('should create from map correctly', () { final map = { 'id': 'window-2', 'name': 'window.started', 'data': {'position': 'center'}, }; final event = Event.fromMap(map); expect(event.id, equals('window-2')); expect(event.name, equals('window.started')); expect(event.data, isA()); expect(event.data['position'], equals('center')); }); test('should handle missing fields in fromMap', () { final map = { 'id': 'window-3', }; final event = Event.fromMap(map); expect(event.id, equals('window-3')); expect(event.name, isNull); expect(event.data, isNull); }); test('should handle dynamic data types', () { final eventWithString = Event(data: 'string data'); expect(eventWithString.data, equals('string data')); final eventWithInt = Event(data: 42); expect(eventWithInt.data, equals(42)); final eventWithList = Event(data: [1, 2, 3]); expect(eventWithList.data, equals([1, 2, 3])); final eventWithBool = Event(data: true); expect(eventWithBool.data, isTrue); }); }); group('Event name mapping', () { // Test that event names are correctly mapped (based on the _EventType extension) test('WindowCreated should map to window.created', () { // We can't directly test the private extension, but we can verify // the event types exist and are usable expect(EventType.WindowCreated, isNotNull); }); test('WindowDragStart should map to window.drag_start', () { expect(EventType.WindowDragStart, isNotNull); }); test('all event types should be distinct', () { final types = EventType.values.toSet(); expect(types.length, equals(EventType.values.length)); }); }); group('Window event registration', () { test('should allow registering multiple event handlers', () { final window = Window(id: 'test-window'); int handlerCount = 0; window.on(EventType.WindowCreated, (w, data) { handlerCount++; }).on(EventType.WindowStarted, (w, data) { handlerCount++; }).on(EventType.WindowDestroy, (w, data) { handlerCount++; }); // The handlers are registered but not called yet expect(handlerCount, equals(0)); }); test('on() should return window for chaining', () { final window = Window(id: 'test-window'); final result = window.on(EventType.WindowCreated, (w, data) {}); expect(result, same(window)); }); test('should handle all event types registration', () { final window = Window(id: 'test-window'); // Register handlers for all event types for (final eventType in EventType.values) { window.on(eventType, (w, data) {}); } // If we get here without errors, all event types are registrable expect(true, isTrue); }); }); group('WindowListener typedef', () { test('should accept correct function signature', () { // WindowListener = dynamic Function(Window window, dynamic data) WindowListener listener = (Window w, dynamic data) { return 'handled'; }; final window = Window(id: 'test'); final result = listener(window, {'event': 'data'}); expect(result, equals('handled')); }); test('should work with async handlers', () async { WindowListener asyncListener = (Window w, dynamic data) async { await Future.delayed(Duration(milliseconds: 10)); return 'async handled'; }; final window = Window(id: 'test'); final result = await asyncListener(window, null); expect(result, equals('async handled')); }); test('should allow void return', () { WindowListener voidListener = (Window w, dynamic data) { // No return }; final window = Window(id: 'test'); final result = voidListener(window, null); expect(result, isNull); }); }); } ================================================ FILE: test/flutter_floatwing_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { const MethodChannel channel = MethodChannel('flutter_floatwing'); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { channel.setMockMethodCallHandler((MethodCall methodCall) async { return '42'; }); }); tearDown(() { channel.setMockMethodCallHandler(null); }); test('getPlatformVersion', () async { // expect(await FlutterFloatwing.platformVersion, '42'); }); } ================================================ FILE: test/plugin_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('FloatwingPlugin', () { const MethodChannel methodChannel = MethodChannel('im.zoe.labs/flutter_floatwing/method'); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { switch (methodCall.method) { case 'plugin.has_permission': return true; case 'plugin.open_permission_setting': return true; case 'plugin.is_service_running': return true; case 'plugin.start_service': return true; case 'plugin.clean_cache': return true; case 'plugin.initialize': return { 'permission_grated': true, 'service_running': true, 'windows': [], }; case 'plugin.sync_windows': return [ { 'id': 'window-1', 'config': {'entry': 'main', 'route': '/test'}, }, { 'id': 'window-2', 'config': {'entry': 'main', 'route': '/test2'}, }, ]; case 'plugin.create_window': return { 'id': methodCall.arguments['id'] ?? 'default', 'config': methodCall.arguments['config'], }; default: return null; } }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); }); test('should be a singleton', () { final plugin1 = FloatwingPlugin(); final plugin2 = FloatwingPlugin(); expect(identical(plugin1, plugin2), isTrue); }); test('instance getter should return same instance', () { final plugin = FloatwingPlugin(); expect(identical(plugin, plugin.instance), isTrue); }); test('checkPermission should return true', () async { final result = await FloatwingPlugin().checkPermission(); expect(result, isTrue); }); test('openPermissionSetting should return true', () async { final result = await FloatwingPlugin().openPermissionSetting(); expect(result, isTrue); }); test('isServiceRunning should return true', () async { final result = await FloatwingPlugin().isServiceRunning(); expect(result, isTrue); }); test('startService should return true', () async { final result = await FloatwingPlugin().startService(); expect(result, isTrue); }); test('cleanCache should return true', () async { final result = await FloatwingPlugin().cleanCache(); expect(result, isTrue); }); test('syncWindows should populate windows map', () async { // Clear any existing state FloatwingPlugin().windows.clear(); final result = await FloatwingPlugin().syncWindows(); expect(result, isTrue); expect(FloatwingPlugin().windows.length, equals(2)); expect(FloatwingPlugin().windows.containsKey('window-1'), isTrue); expect(FloatwingPlugin().windows.containsKey('window-2'), isTrue); }); test('windows should return map of windows', () { final windows = FloatwingPlugin().windows; expect(windows, isA>()); }); test('currentWindow should be null initially for main engine', () { // In main engine, currentWindow should be null until ensureWindow is called // Since we're testing as main engine, this is expected behavior expect(FloatwingPlugin().currentWindow, isNull); }); test('isWindow should be false for main engine', () { // isWindow is false until ensureWindow succeeds with valid data // For main engine tests, this should be false expect(FloatwingPlugin().isWindow, isFalse); }); test('createWindow should create and cache window', () async { final config = WindowConfig(route: '/new-window'); final window = await FloatwingPlugin().createWindow('new-window', config); expect(window, isNotNull); expect(window?.id, equals('new-window')); expect(FloatwingPlugin().windows.containsKey('new-window'), isTrue); }); test('createWindow with start=true should create started window', () async { final config = WindowConfig(route: '/started-window'); final window = await FloatwingPlugin() .createWindow('started-window', config, start: true); expect(window, isNotNull); expect(window?.id, equals('started-window')); }); test('on should return plugin for chaining', () { final plugin = FloatwingPlugin(); final result = plugin.on(EventType.WindowCreated, (window, data) {}); expect(result, same(plugin)); }); }); group('FloatwingPlugin channel constants', () { test('channelID should be correct', () { expect( FloatwingPlugin.channelID, equals('im.zoe.labs/flutter_floatwing')); }); }); group('FloatwingPlugin permission flow', () { const MethodChannel methodChannel = MethodChannel('im.zoe.labs/flutter_floatwing/method'); test('should handle permission denied', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { if (methodCall.method == 'plugin.has_permission') { return false; } return null; }); final result = await FloatwingPlugin().checkPermission(); expect(result, isFalse); // Cleanup TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); }); test('createWindow should throw when permission denied', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { if (methodCall.method == 'plugin.has_permission') { return false; } return null; }); final config = WindowConfig(route: '/test'); expect( () => FloatwingPlugin().createWindow('test', config), throwsException, ); // Cleanup TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); }); }); } ================================================ FILE: test/utils_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/src/utils.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('SystemConfig', () { test('should create from map correctly', () { final map = { 'pixelRadio': 2, 'screen': { 'width': 1080, 'height': 1920, }, }; final config = SystemConfig.fromMap(map); expect(config.pixelRadio, equals(2)); expect(config.screenWidth, equals(1080)); expect(config.screenHeight, equals(1920)); expect(config.screenSize, isNotNull); expect(config.screenSize?.width, equals(1080.0)); expect(config.screenSize?.height, equals(1920.0)); }); test('should handle missing screen data', () { final map = { 'pixelRadio': 3, }; final config = SystemConfig.fromMap(map); expect(config.pixelRadio, equals(3)); expect(config.screenWidth, isNull); expect(config.screenHeight, isNull); expect(config.screenSize, isNull); }); test('should handle empty map', () { final config = SystemConfig.fromMap({}); expect(config.pixelRadio, isNull); expect(config.screenWidth, isNull); expect(config.screenHeight, isNull); expect(config.screenSize, isNull); }); test('should convert to map correctly', () { final map = { 'pixelRadio': 2, 'screen': { 'width': 1080, 'height': 1920, }, }; final config = SystemConfig.fromMap(map); final result = config.toMap(); expect(result['pixelRadio'], equals(2)); expect(result['screen'], isA()); expect(result['screen']['width'], equals(1080)); expect(result['screen']['height'], equals(1920)); }); test('toString should return map and size representation', () { final map = { 'pixelRadio': 2, 'screen': { 'width': 1080, 'height': 1920, }, }; final config = SystemConfig.fromMap(map); final str = config.toString(); expect(str, contains('pixelRadio')); expect(str, contains('1080')); expect(str, contains('1920')); }); test('should create Size object only when both dimensions are present', () { // Both dimensions present final config1 = SystemConfig.fromMap({ 'screen': {'width': 100, 'height': 200}, }); expect(config1.screenSize, isNotNull); // Only width present final config2 = SystemConfig.fromMap({ 'screen': {'width': 100}, }); expect(config2.screenSize, isNull); // Only height present final config3 = SystemConfig.fromMap({ 'screen': {'height': 200}, }); expect(config3.screenSize, isNull); // Neither present final config4 = SystemConfig.fromMap({ 'screen': {}, }); expect(config4.screenSize, isNull); }); test('should preserve exact values through toMap', () { final originalMap = { 'pixelRadio': 3, 'screen': { 'width': 2560, 'height': 1440, }, }; final config = SystemConfig.fromMap(originalMap); final resultMap = config.toMap(); expect(resultMap['pixelRadio'], equals(originalMap['pixelRadio'])); final originalScreen = originalMap['screen'] as Map; final resultScreen = resultMap['screen'] as Map; expect( resultScreen['width'], equals(originalScreen['width']), ); expect( resultScreen['height'], equals(originalScreen['height']), ); }); }); } ================================================ FILE: test/window_config_test.dart ================================================ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('WindowConfig', () { test('should create with default values', () { final config = WindowConfig(); expect(config.id, equals('default')); expect(config.entry, equals('main')); expect(config.route, isNull); expect(config.callback, isNull); expect(config.width, isNull); expect(config.height, isNull); expect(config.x, isNull); expect(config.y, isNull); expect(config.autosize, isNull); expect(config.gravity, isNull); expect(config.clickable, isNull); expect(config.draggable, isNull); expect(config.focusable, isNull); expect(config.immersion, isNull); expect(config.visible, isNull); }); test('should create with custom values', () { final config = WindowConfig( id: 'test-window', entry: 'customEntry', route: '/test', width: 200, height: 300, x: 10, y: 20, autosize: true, gravity: GravityType.Center, clickable: true, draggable: true, focusable: false, immersion: true, visible: true, ); expect(config.id, equals('test-window')); expect(config.entry, equals('customEntry')); expect(config.route, equals('/test')); expect(config.width, equals(200)); expect(config.height, equals(300)); expect(config.x, equals(10)); expect(config.y, equals(20)); expect(config.autosize, isTrue); expect(config.gravity, equals(GravityType.Center)); expect(config.clickable, isTrue); expect(config.draggable, isTrue); expect(config.focusable, isFalse); expect(config.immersion, isTrue); expect(config.visible, isTrue); }); test('should convert to map correctly', () { final config = WindowConfig( id: 'test-window', route: '/test', width: 200, height: 300, draggable: true, ); final map = config.toMap(); expect(map['entry'], equals('main')); expect(map['route'], equals('/test')); expect(map['width'], equals(200)); expect(map['height'], equals(300)); expect(map['draggable'], isTrue); }); test('should create from map correctly', () { final map = { 'entry': 'customEntry', 'route': '/test', 'width': 200, 'height': 300, 'x': 10, 'y': 20, 'autosize': true, 'clickable': true, 'draggable': true, 'focusable': false, 'immersion': true, 'visible': true, }; final config = WindowConfig.fromMap(map); expect(config.entry, equals('customEntry')); expect(config.route, equals('/test')); expect(config.width, equals(200)); expect(config.height, equals(300)); expect(config.x, equals(10)); expect(config.y, equals(20)); expect(config.autosize, isTrue); expect(config.clickable, isTrue); expect(config.draggable, isTrue); expect(config.focusable, isFalse); expect(config.immersion, isTrue); expect(config.visible, isTrue); }); test('should return correct size', () { final config = WindowConfig(width: 200, height: 300); expect(config.size.width, equals(200.0)); expect(config.size.height, equals(300.0)); }); test('should return zero size when dimensions are null', () { final config = WindowConfig(); expect(config.size.width, equals(0.0)); expect(config.size.height, equals(0.0)); }); test('should convert to Window using to()', () { final config = WindowConfig(id: 'test-window', route: '/test'); final window = config.to(); expect(window, isA()); expect(window.id, equals('test-window')); expect(window.config, equals(config)); }); test('toString should return JSON representation', () { final config = WindowConfig( route: '/test', width: 200, draggable: true, ); final str = config.toString(); expect(str, contains('route')); expect(str, contains('/test')); expect(str, contains('width')); expect(str, contains('200')); expect(str, contains('draggable')); expect(str, contains('true')); }); }); group('WindowSize', () { test('should have correct constant values', () { expect(WindowSize.MatchParent, equals(-1)); expect(WindowSize.WrapContent, equals(-2)); }); }); group('GravityType', () { test('should convert to int correctly', () { expect(GravityType.Center.toInt(), isNotNull); expect(GravityType.LeftTop.toInt(), isNotNull); expect(GravityType.RightBottom.toInt(), isNotNull); }); test('should convert from int correctly', () { final centerInt = GravityType.Center.toInt(); final result = GravityType.Unknown.fromInt(centerInt); expect(result, equals(GravityType.Center)); }); test('should return null for unknown int', () { final result = GravityType.Unknown.fromInt(999); expect(result, isNull); }); test('should return null for null int', () { final result = GravityType.Unknown.fromInt(null); expect(result, isNull); }); test('all gravity types should have valid int values', () { for (final gravity in GravityType.values) { if (gravity != GravityType.Unknown) { expect(gravity.toInt(), isNotNull, reason: '$gravity should have a valid int value'); } } }); }); } ================================================ FILE: test/window_test.dart ================================================ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_floatwing/flutter_floatwing.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Window', () { test('should create with default id', () { final window = Window(); expect(window.id, equals('default')); expect(window.config, isNull); expect(window.pixelRadio, isNull); expect(window.system, isNull); }); test('should create with custom id and config', () { final config = WindowConfig( route: '/test', width: 200, height: 300, ); final window = Window(id: 'test-window', config: config); expect(window.id, equals('test-window')); expect(window.config, equals(config)); expect(window.config?.route, equals('/test')); expect(window.config?.width, equals(200)); expect(window.config?.height, equals(300)); }); test('should create from map correctly', () { final map = { 'id': 'map-window', 'pixelRadio': 2.0, 'system': { 'pixelRadio': 2, 'screen': {'width': 1080, 'height': 1920}, }, 'config': { 'entry': 'main', 'route': '/test', 'width': 200, 'height': 300, 'draggable': true, }, }; final window = Window.fromMap(map); expect(window.id, equals('map-window')); expect(window.pixelRadio, equals(2.0)); expect(window.system, isNotNull); expect(window.system?.screenWidth, equals(1080)); expect(window.system?.screenHeight, equals(1920)); expect(window.config?.route, equals('/test')); expect(window.config?.width, equals(200)); expect(window.config?.height, equals(300)); expect(window.config?.draggable, isTrue); }); test('should apply map to existing window', () { final window = Window(id: 'original'); final map = { 'id': 'updated', 'pixelRadio': 3.0, 'config': { 'entry': 'custom', 'width': 400, }, }; window.applyMap(map); expect(window.id, equals('updated')); expect(window.pixelRadio, equals(3.0)); expect(window.config?.entry, equals('custom')); expect(window.config?.width, equals(400)); }); test('should handle null map in applyMap', () { final window = Window(id: 'test'); final result = window.applyMap(null); expect(result.id, equals('test')); }); test('should convert to map correctly', () { final config = WindowConfig( route: '/test', width: 200, height: 300, ); final window = Window(id: 'test-window', config: config); window.pixelRadio = 2.5; final map = window.toMap(); expect(map['id'], equals('test-window')); expect(map['pixelRadio'], equals(2.5)); expect(map['config'], isA()); expect(map['config']['route'], equals('/test')); }); test('toString should contain window id', () { final window = Window(id: 'my-window'); final str = window.toString(); expect(str, contains('Window[my-window]')); }); test('should register onData handler', () { final window = Window(id: 'test'); bool handlerCalled = false; window.onData((source, name, data) async { handlerCalled = true; return null; }); // Handler registered, but we can't easily test it without a real channel expect(window, isNotNull); }); test( 'should register event handler with on() and return window for chaining', () { final window = Window(id: 'test'); final result = window .on(EventType.WindowCreated, (w, data) {}) .on(EventType.WindowStarted, (w, data) {}); expect(result, equals(window)); }); }); group('Window MethodChannel operations', () { const MethodChannel windowChannel = MethodChannel('im.zoe.labs/flutter_floatwing/window'); setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(windowChannel, (MethodCall methodCall) async { switch (methodCall.method) { case 'window.show': return true; case 'window.close': return true; case 'window.start': return true; case 'window.update': return { 'id': methodCall.arguments['id'], 'config': methodCall.arguments['config'], }; case 'data.share': return 'shared'; case 'window.launch_main': return true; case 'window.sync': return { 'id': 'synced-window', 'pixelRadio': 2.0, 'config': {'entry': 'main', 'route': '/synced'}, }; default: return null; } }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(windowChannel, null); }); test('hide should call show with visible=false', () async { final config = WindowConfig(visible: true); final window = Window(id: 'test', config: config); final result = await window.hide(); expect(result, isTrue); expect(window.config?.visible, isFalse); }); test('show should update visibility', () async { final config = WindowConfig(visible: false); final window = Window(id: 'test', config: config); final result = await window.show(visible: true); expect(result, isTrue); }); test('close should return true', () async { final window = Window(id: 'test'); final result = await window.close(); expect(result, isTrue); }); test('close with force should work', () async { final window = Window(id: 'test'); final result = await window.close(force: true); expect(result, isTrue); }); test('start should return true', () async { final config = WindowConfig(route: '/test'); final window = Window(id: 'test', config: config); final result = await window.start(); expect(result, isTrue); }); test('share should send data and return response', () async { final window = Window(id: 'test'); final result = await window.share({'key': 'value'}, name: 'test-data'); expect(result, equals('shared')); }); test('launchMainActivity should return true', () async { final window = Window(id: 'test'); final result = await window.launchMainActivity(); expect(result, isTrue); }); test('Window.sync should return map', () async { final result = await Window.sync(); expect(result, isNotNull); expect(result?['id'], equals('synced-window')); expect(result?['config']['route'], equals('/synced')); }); test('update should apply new config', () async { final config = WindowConfig(width: 100, height: 100); final window = Window(id: 'test', config: config); final result = await window.update(WindowConfig(width: 200, height: 200)); expect(result, isTrue); }); }); }