diff --git a/skills/create-flutter-app-native-ui/SKILL.md b/skills/create-flutter-app-native-ui/SKILL.md new file mode 100644 index 0000000..8b3ac49 --- /dev/null +++ b/skills/create-flutter-app-native-ui/SKILL.md @@ -0,0 +1,94 @@ +--- +name: create-flutter-app-native-ui +description: Guide to bootstrap, coordinate, and maintain a headless Flutter engine integrated with 100% native platforms (SwiftUI, Jetpack Compose, HTML5 Web Components). +--- + +# Creating a Flutter App with 100% Native UIs + +This skill provides step-by-step guidance on implementing a decoupled architectural design where a **headless Flutter engine** acts as the shared single source of truth (SSOT) running in the background, while the user interfaces are built using platform-native toolkits. + +```mermaid +graph TD + subgraph Native Frontends + iOS[SwiftUI View] + Android[Jetpack Compose] + Web[Web Components / JS] + end + + subgraph Headless Flutter Context + Engine[Flutter Engine] + StateManager[State Manager / Tasks] + Bridge[Platform Channels / Interop] + end + + iOS <-->|Pigeon IPC| Bridge + Android <-->|Pigeon IPC| Bridge + Web <-->|dart:js_interop| Bridge + Bridge <--> StateManager +``` + +--- + +## 1. Interop Layer Setup + +### Mobile (Pigeon Code-Gen) +Pigeon is used for type-safe IPC communication between Dart and Swift/Kotlin. +Create a pigeon definition file at `pigeons/tasks_api.dart`: +- Use `@HostApi` for calls originating from native code that Dart answers. +- Use `@FlutterApi` for calls originating from Dart that native platforms listen to. + +Compile the interfaces using the Pigeon CLI: +```bash +dart run pigeon --input pigeons/tasks_api.dart +``` + +### Web (JS Interop) +Use `dart:js_interop` to bind functions to the global `window` object: +- Define `@JS('window.methodName')` setters to bind Dart triggers. +- Define `@JS('window.callbackName')` getters to notify host JS components of updates. + +--- + +## 2. Bootstrapping Headless Engines + +### iOS (AppDelegate.swift) +Instantiate `FlutterEngine` without a visual viewport controller: +1. Declare `var flutterEngine: FlutterEngine?`. +2. Instantiate `let engine = FlutterEngine(name: "my-engine")` and call `engine.run()`. +3. Set up the custom event handler store (`TaskStore`) to listen on the binary messenger: + `store.setup(binaryMessenger: engine.binaryMessenger)`. +4. Wrap your SwiftUI view using `UIHostingController` and present it in the main `UIWindow`. + +### Android (MainActivity.kt) +Instantiate a headless `FlutterEngine` inside a standard Compose activity: +1. Override `onCreate` in a subclass of `ComponentActivity`. +2. Instantiate `FlutterEngine(this)` and invoke `executeDartEntrypoint(...)`. +3. Connect your Pigeon host/client endpoints using the engine's `dartExecutor.binaryMessenger`. +4. Set up Compose UI using `setContent { ... }`. +5. Call `flutterEngine.destroy()` on `onDestroy()`. + +### Web (flutter_bootstrap.js) +Boot Flutter Web headlessly by specifying a hidden target container: +1. Create a `flutter_bootstrap.js` mapping: + ```javascript + engineInitializer.initializeEngine({ + hostElement: document.getElementById('flutter-host') // Styled with display: none + }) + ``` + +--- + +## 3. UI Synchronization & State Management + +Native clients should observe updates from the headless engine reactively: +* **iOS**: Implement `ObservableObject` and dispatch events to SwiftUI views via `@Published` properties on `DispatchQueue.main.async`. +* **Android**: Use a Kotlin `StateFlow` updated via the Pigeon host API callback. Connect Compose views using `.collectAsState()`. +* **Web**: Create custom HTML5 Web Components dispatching custom bubbles events (`toggle-task`, `delete-task`) up the DOM hierarchy. + +--- + +## 4. Resource & Verification Scripts + +The resource folder contains CLI compilation check scripts: +- `resources/check_ios.sh`: Validates Swift compilation and framework linkage. +- `resources/check_android.sh`: Triggers Kotlin compile tasks to catch build errors before simulator deployment. diff --git a/skills/create-flutter-app-native-ui/examples/android/MainActivity.kt b/skills/create-flutter-app-native-ui/examples/android/MainActivity.kt new file mode 100644 index 0000000..7518e3d --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/android/MainActivity.kt @@ -0,0 +1,64 @@ +package com.example.tasks_interop + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class MainActivity : ComponentActivity(), TasksHostApi { + private lateinit var flutterEngine: FlutterEngine + private lateinit var flutterApi: TasksFlutterApi + + private val _tasks = MutableStateFlow>(emptyList()) + val tasks: StateFlow> = _tasks.asStateFlow() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + flutterEngine = FlutterEngine(this) + TasksHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, this) + flutterApi = TasksFlutterApi(flutterEngine.dartExecutor.binaryMessenger) + + flutterEngine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + + flutterApi.getTasks { result -> + if (result.isSuccess) { + _tasks.value = result.getOrNull() ?: emptyList() + } + } + + setContent { + val tasksState = tasks.collectAsState() + TasksApp( + tasks = tasksState.value, + onAddTask = { title -> + flutterApi.addTask(title) { } + }, + onToggleTask = { id -> + flutterApi.toggleTask(id) { } + }, + onDeleteTask = { id -> + flutterApi.deleteTask(id) { } + } + ) + } + } + + override fun onTasksUpdated(tasks: List) { + _tasks.value = tasks + } + + override fun onDestroy() { + super.onDestroy() + TasksHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, null) + flutterEngine.destroy() + } +} diff --git a/skills/create-flutter-app-native-ui/examples/android/TasksApp.kt b/skills/create-flutter-app-native-ui/examples/android/TasksApp.kt new file mode 100644 index 0000000..6c480d3 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/android/TasksApp.kt @@ -0,0 +1,82 @@ +package com.example.tasks_interop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TasksApp( + tasks: List, + onAddTask: (String) -> Unit, + onToggleTask: (String) -> Unit, + onDeleteTask: (String) -> Unit +) { + var newTaskTitle by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = newTaskTitle, + onValueChange = { newTaskTitle = it }, + modifier = Modifier.weight(1f), + singleLine = true + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + if (newTaskTitle.isNotBlank()) { + onAddTask(newTaskTitle.trim()) + newTaskTitle = "" + } + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + } + + LazyColumn { + items(tasks, key = { it.id }) { task -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggleTask(task.id) } + ) + Text( + text = task.title, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { onDeleteTask(task.id) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + } + } + } +} diff --git a/skills/create-flutter-app-native-ui/examples/ios/AppDelegate.swift b/skills/create-flutter-app-native-ui/examples/ios/AppDelegate.swift new file mode 100644 index 0000000..e19c282 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/ios/AppDelegate.swift @@ -0,0 +1,29 @@ +import Flutter +import UIKit +import SwiftUI + +@main +@objc class AppDelegate: FlutterAppDelegate { + var flutterEngine: FlutterEngine? + let taskStore = TaskStore() + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let engine = FlutterEngine(name: "headless-tasks-engine") + engine.run() + self.flutterEngine = engine + + GeneratedPluginRegistrant.register(with: engine) + taskStore.setup(binaryMessenger: engine.binaryMessenger) + + let window = UIWindow(frame: UIScreen.main.bounds) + let contentView = ContentView(store: taskStore) + window.rootViewController = UIHostingController(rootView: contentView) + self.window = window + window.makeKeyAndVisible() + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/skills/create-flutter-app-native-ui/examples/ios/ContentView.swift b/skills/create-flutter-app-native-ui/examples/ios/ContentView.swift new file mode 100644 index 0000000..f769136 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/ios/ContentView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var store: TaskStore + @State private var newTaskTitle: String = "" + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if store.tasks.isEmpty { + VStack(spacing: 20) { + Image(systemName: "checklist") + .font(.system(size: 80)) + .foregroundColor(.indigo.opacity(0.4)) + + Text("No Tasks Yet") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + } + .frame(maxHeight: .infinity) + } else { + List { + ForEach(store.tasks) { task in + TaskRow(task: task) { + store.toggleTask(id: task.id) + } + } + .onDelete(perform: deleteTasks) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .listStyle(.plain) + } + + Divider() + HStack(spacing: 12) { + TextField("Enter task title...", text: $newTaskTitle) + .padding(12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(10) + .onSubmit(addTask) + + Button(action: addTask) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 38)) + .foregroundColor(.indigo) + } + .disabled(newTaskTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding() + } + .navigationTitle("Tasks") + } + } + + private func addTask() { + let trimmed = newTaskTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + store.addTask(title: trimmed) + newTaskTitle = "" + } + + private func deleteTasks(at offsets: IndexSet) { + for index in offsets { + let task = store.tasks[index] + store.deleteTask(id: task.id) + } + } +} diff --git a/skills/create-flutter-app-native-ui/examples/ios/TaskRow.swift b/skills/create-flutter-app-native-ui/examples/ios/TaskRow.swift new file mode 100644 index 0000000..68aea2c --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/ios/TaskRow.swift @@ -0,0 +1,29 @@ +import SwiftUI + +extension TaskData: Identifiable {} + +struct TaskRow: View { + let task: TaskData + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 16) { + Button(action: onToggle) { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 24)) + .foregroundColor(task.isCompleted ? .indigo : .gray) + } + .buttonStyle(.plain) + + Text(task.title) + .strikethrough(task.isCompleted, color: .gray) + .foregroundColor(task.isCompleted ? .gray : .primary) + + Spacer() + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(Color(.systemBackground)) + .cornerRadius(12) + } +} diff --git a/skills/create-flutter-app-native-ui/examples/ios/TaskStore.swift b/skills/create-flutter-app-native-ui/examples/ios/TaskStore.swift new file mode 100644 index 0000000..bc28911 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/ios/TaskStore.swift @@ -0,0 +1,37 @@ +import Foundation +import Flutter + +class TaskStore: NSObject, ObservableObject, TasksHostApi { + @Published var tasks: [TaskData] = [] + private var flutterApi: TasksFlutterApi? + + func setup(binaryMessenger: FlutterBinaryMessenger) { + TasksHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: self) + self.flutterApi = TasksFlutterApi(binaryMessenger: binaryMessenger) + self.flutterApi?.getTasks { [weak self] result in + if case .success(let list) = result { + DispatchQueue.main.async { + self?.tasks = list + } + } + } + } + + func onTasksUpdated(tasks: [TaskData]) throws { + DispatchQueue.main.async { + self.tasks = tasks + } + } + + func addTask(title: String) { + flutterApi?.addTask(title: title) { _ in } + } + + func toggleTask(id: String) { + flutterApi?.toggleTask(id: id) { _ in } + } + + func deleteTask(id: String) { + flutterApi?.deleteTask(id: id) { _ in } + } +} diff --git a/skills/create-flutter-app-native-ui/examples/pigeons/tasks_api.dart b/skills/create-flutter-app-native-ui/examples/pigeons/tasks_api.dart new file mode 100644 index 0000000..7bd3088 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/pigeons/tasks_api.dart @@ -0,0 +1,33 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/tasks_api.g.dart', + swiftOut: 'ios/Runner/TasksApi.g.swift', + kotlinOut: 'android/app/src/main/kotlin/com/example/tasks_interop/TasksApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.example.tasks_interop'), +)) + +class TaskData { + final String id; + final String title; + final bool isCompleted; + + TaskData({ + required this.id, + required this.title, + required this.isCompleted, + }); +} + +@HostApi() +abstract class TasksHostApi { + void onTasksUpdated(List tasks); +} + +@FlutterApi() +abstract class TasksFlutterApi { + List getTasks(); + void addTask(String title); + void toggleTask(String id); + void deleteTask(String id); +} diff --git a/skills/create-flutter-app-native-ui/examples/web/app.js b/skills/create-flutter-app-native-ui/examples/web/app.js new file mode 100644 index 0000000..0ba0f41 --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/web/app.js @@ -0,0 +1,98 @@ +class TaskItem extends HTMLElement { + constructor() { + super(); + this._id = ''; + this._title = ''; + this._completed = false; + } + + connectedCallback() { + this.render(); + } + + static get observedAttributes() { + return ['task-id', 'title', 'completed']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + if (name === 'task-id') this._id = newValue; + if (name === 'title') this._title = newValue; + if (name === 'completed') this._completed = newValue === 'true'; + this.render(); + } + + render() { + this.innerHTML = ` +
+ + ${this._title} + +
+ `; + + this.querySelector('input').addEventListener('change', () => { + this.dispatchEvent(new CustomEvent('toggle-task', { + bubbles: true, + detail: { id: this._id } + })); + }); + + this.querySelector('.delete-btn').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('delete-task', { + bubbles: true, + detail: { id: this._id } + })); + }); + } +} +customElements.define('task-item', TaskItem); + +class TaskApp extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
+
+ + +
+
+
+ `; + + this.querySelector('#task-form').addEventListener('submit', (e) => { + e.preventDefault(); + const val = this.querySelector('input').value.trim(); + if (val && window.addTaskOnWeb) { + window.addTaskOnWeb(val); + this.querySelector('input').value = ''; + } + }); + + this.addEventListener('toggle-task', (e) => { + if (window.toggleTaskOnWeb) window.toggleTaskOnWeb(e.detail.id); + }); + + this.addEventListener('delete-task', (e) => { + if (window.deleteTaskOnWeb) window.deleteTaskOnWeb(e.detail.id); + }); + } + + setTasks(tasks) { + const list = this.querySelector('#task-list'); + list.innerHTML = ''; + tasks.forEach(t => { + const item = document.createElement('task-item'); + item.setAttribute('task-id', t.id); + item.setAttribute('title', t.title); + item.setAttribute('completed', t.isCompleted ? 'true' : 'false'); + list.appendChild(item); + }); + } +} +customElements.define('task-app', TaskApp); + +window.onTasksUpdatedOnWeb = function(tasks) { + const app = document.querySelector('task-app'); + if (app) app.setTasks(tasks); +}; diff --git a/skills/create-flutter-app-native-ui/examples/web/flutter_bootstrap.js b/skills/create-flutter-app-native-ui/examples/web/flutter_bootstrap.js new file mode 100644 index 0000000..9393bcc --- /dev/null +++ b/skills/create-flutter-app-native-ui/examples/web/flutter_bootstrap.js @@ -0,0 +1,12 @@ +{{flutter_js}} +{{flutter_build_config}} + +_flutter.loader.load({ + onEntrypointLoaded: function(engineInitializer) { + engineInitializer.initializeEngine({ + hostElement: document.getElementById('flutter-host') + }).then(function(appRunner) { + appRunner.runApp(); + }); + } +}); diff --git a/skills/create-flutter-app-native-ui/resources/check_android.sh b/skills/create-flutter-app-native-ui/resources/check_android.sh new file mode 100644 index 0000000..e3c09c4 --- /dev/null +++ b/skills/create-flutter-app-native-ui/resources/check_android.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +echo -e "${YELLOW}Starting Android Kotlin compilation check...${NC}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" +ANDROID_DIR="$PROJECT_ROOT/android" + +if [ ! -d "$ANDROID_DIR" ]; then + echo -e "${RED}Error: android directory not found at $ANDROID_DIR${NC}" + exit 1 +fi + +echo "Running gradle compileDebugKotlin..." +set +e +"$ANDROID_DIR/gradlew" -p "$ANDROID_DIR" compileDebugKotlin --quiet + +RESULT=$? +set -e + +if [ $RESULT -eq 0 ]; then + echo -e "${GREEN}✓ Android Compilation Succeeded! No errors found.${NC}" + exit 0 +else + echo -e "${RED}✗ Android compilation failed with exit code $RESULT.${NC}" + exit $RESULT +fi diff --git a/skills/create-flutter-app-native-ui/resources/check_ios.sh b/skills/create-flutter-app-native-ui/resources/check_ios.sh new file mode 100644 index 0000000..4992e40 --- /dev/null +++ b/skills/create-flutter-app-native-ui/resources/check_ios.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +echo -e "${YELLOW}Starting iOS compilation check...${NC}" + +if [ -d "/Applications/Xcode-16.2.app" ]; then + echo "Using Xcode 16.2 toolchain..." + export DEVELOPER_DIR="/Applications/Xcode-16.2.app/Contents/Developer" +else + echo "Using system default Xcode toolchain..." +fi + +echo "Running xcodebuild..." +set +e +xcodebuild \ + -workspace ios/Runner.xcworkspace \ + -scheme Runner \ + -configuration Debug \ + -sdk iphonesimulator \ + build \ + -quiet + +RESULT=$? +set -e + +if [ $RESULT -eq 0 ]; then + echo -e "${GREEN}✓ iOS Compilation Succeeded! No errors found.${NC}" + exit 0 +else + echo -e "${YELLOW}Xcode build finished with code $RESULT.${NC}" + echo "If the only failing step in the output is 'CompileAssetCatalog', your Swift and interop codebase compiles successfully." + exit $RESULT +fi