feat: Add create-flutter-app-native-ui skill
Update README / update-readme (push) Successful in 7s

This commit is contained in:
2026-06-27 20:47:13 -07:00
parent fb890a9204
commit 5cc939e120
12 changed files with 619 additions and 0 deletions
@@ -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.
@@ -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<List<TaskData>>(emptyList())
val tasks: StateFlow<List<TaskData>> = _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<TaskData>) {
_tasks.value = tasks
}
override fun onDestroy() {
super.onDestroy()
TasksHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, null)
flutterEngine.destroy()
}
}
@@ -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<TaskData>,
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")
}
}
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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 }
}
}
@@ -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<TaskData> tasks);
}
@FlutterApi()
abstract class TasksFlutterApi {
List<TaskData> getTasks();
void addTask(String title);
void toggleTask(String id);
void deleteTask(String id);
}
@@ -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 = `
<div>
<input type="checkbox" ${this._completed ? 'checked' : ''}>
<span>${this._title}</span>
<button class="delete-btn">Delete</button>
</div>
`;
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 = `
<div>
<form id="task-form">
<input type="text" placeholder="Add a new task..." required>
<button type="submit">Add</button>
</form>
<div id="task-list"></div>
</div>
`;
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);
};
@@ -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();
});
}
});
@@ -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
@@ -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