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
[](https://pub.dev/packages/flutter_floatwing)
[](LICENSE)
[](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 |
|:----------:|:--------------:|:---------------:|
|  |  |  |
## 📦 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.

The overall view hierarchy looks like this:

## 📖 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);
});
});
}