Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
737 changes: 543 additions & 194 deletions lib/flutter_skill.dart

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions lib/src/cli/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -728,11 +728,17 @@ class FlutterMcpServer {
final actions =
(args['actions'] ?? args['commands'] ?? []) as List<dynamic>;
final stopOnFailure = args['stop_on_failure'] ?? true;
// A step often triggers a route/tab transition; give the next step's
// target time to be built (default 250 ms, tunable per batch).
final stepDelayMs = (args['step_delay_ms'] as num?)?.toInt() ?? 250;

final results = <Map<String, dynamic>>[];
var allSuccess = true;

for (var i = 0; i < actions.length; i++) {
if (i > 0 && stepDelayMs > 0) {
await Future.delayed(Duration(milliseconds: stepDelayMs));
}
final action = actions[i] as Map<String, dynamic>;
final actionName =
(action['action'] ?? action['tool'] ?? action['name']) as String;
Expand Down
10 changes: 8 additions & 2 deletions lib/src/cli/tool_handlers/bf_batch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@ extension _BfBatch on FlutterMcpServer {
final fc = _asFlutterClient(client!, 'tap_at');
final x = (args['x'] as num).toDouble();
final y = (args['y'] as num).toDouble();
await fc.tapAt(x, y);
return {"success": true, "action": "tap_at", "x": x, "y": y};
final tapped = await fc.tapAt(x, y);
return {
"success": true,
"action": "tap_at",
"x": x,
"y": y,
if (tapped['warning'] != null) "warning": tapped['warning'],
};

case 'long_press_at':
if (client is BridgeDriver) {
Expand Down
8 changes: 7 additions & 1 deletion lib/src/cli/tool_handlers/bf_inspection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,13 @@ extension _BfInspection on FlutterMcpServer {
"error": "key or element parameter required"
};
}
return await fc.getWidgetProperties(wpKey);
final props = await fc.getWidgetProperties(wpKey);
return props ??
{
"success": false,
"error": "No mounted widget with key '$wpKey' "
"(is it on the current screen / built?)",
};
case 'get_text_content':
if (client is BridgeDriver) {
final text = await client.getText();
Expand Down
87 changes: 75 additions & 12 deletions lib/src/cli/tool_handlers/bf_interaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ extension _BfInteraction on FlutterMcpServer {
};
}
final fc = _asFlutterClient(client!, 'tap (coordinates)');
await fc.tapAt(x.toDouble(), y.toDouble());
final tapped = await fc.tapAt(x.toDouble(), y.toDouble());
return {
"success": true,
"method": "coordinates",
"message": "Tapped at ($x, $y)",
"position": {"x": x, "y": y},
if (tapped['warning'] != null) "warning": tapped['warning'],
};
}

Expand All @@ -53,6 +54,7 @@ extension _BfInteraction on FlutterMcpServer {
"method": args['key'] != null ? "key" : "text",
"message": "Tapped",
if (result['position'] != null) "position": result['position'],
if (result['warning'] != null) "warning": result['warning'],
};

case 'fill':
Expand Down Expand Up @@ -136,6 +138,19 @@ extension _BfInteraction on FlutterMcpServer {
return success ? "Double tapped" : "Double tap failed";
case 'swipe':
final distance = (args['distance'] ?? 300).toDouble();
if (client is FlutterSkillClient) {
final swiped = await client.swipeWithDetails(
direction: args['direction'],
distance: distance,
key: args['key']);
return {
"success": swiped['success'] == true,
"message": swiped['success'] == true
? "Swiped ${args['direction']}"
: "Swipe failed",
if (swiped['warning'] != null) "warning": swiped['warning'],
};
}
final success = await client!.swipe(
direction: args['direction'], distance: distance, key: args['key']);
return success ? "Swiped ${args['direction']}" : "Swipe failed";
Expand All @@ -156,20 +171,31 @@ extension _BfInteraction on FlutterMcpServer {
return result['success'] == true ? "Dragged" : "Drag failed";
}
final fc = _asFlutterClient(client!, 'drag');
// Support coordinate-based drag via swipeCoordinates for FlutterClient
if (args['start_x'] != null) {
// Clients may send numbers as strings; accept both.
double? num_(Object? v) =>
v is num ? v.toDouble() : double.tryParse(v?.toString() ?? '');
final holdMs = num_(args['hold_ms'])?.toInt() ?? 0;
// Coordinate-based drag shares the swipe engine (with hold)
if (num_(args['start_x']) != null) {
final result = await fc.swipeCoordinates(
(args['start_x'] as num).toDouble(),
(args['start_y'] as num).toDouble(),
(args['end_x'] as num).toDouble(),
(args['end_y'] as num).toDouble(),
num_(args['start_x'])!,
num_(args['start_y']) ?? 0,
num_(args['end_x']) ?? 0,
num_(args['end_y']) ?? 0,
duration: num_(args['duration_ms'])?.toInt() ?? 400,
holdMs: holdMs,
);
return result;
}
final success = await fc.drag(
final dragged = await fc.dragWithDetails(
fromKey: args['from_key'] as String? ?? '',
toKey: args['to_key'] as String? ?? '');
return success ? "Dragged" : "Drag failed";
toKey: args['to_key'] as String? ?? '',
holdMs: holdMs);
return {
"success": dragged['success'] == true,
"message": dragged['success'] == true ? "Dragged" : "Drag failed",
if (dragged['warning'] != null) "warning": dragged['warning'],
};

// State & Validation
case 'get_text_value':
Expand All @@ -185,7 +211,42 @@ extension _BfInteraction on FlutterMcpServer {
.callMethod('get_checkbox_state', {'key': args['key']});
}
final fc = _asFlutterClient(client!, 'get_checkbox_state');
return await fc.getCheckboxState(args['key']);
final checked = await fc.getCheckboxState(args['key']);
return checked == null
? {"success": false, "error": "No Checkbox/Switch with that key"}
: {"success": true, "checked": checked};
case 'set_checkbox':
if (client is BridgeDriver) {
return await client.callMethod(
'set_checkbox', {'key': args['key'], 'checked': args['checked']});
}
return await _asFlutterClient(client!, 'set_checkbox')
.setCheckbox(args['key'] as String, args['checked'] == true);
case 'type_text':
if (client is BridgeDriver) {
return await client.callMethod('type_text', {'text': args['text']});
}
return await _asFlutterClient(client!, 'type_text')
.typeText(args['text'] as String? ?? '');
case 'focus':
if (client is BridgeDriver) {
return await client.callMethod('focus', {'key': args['key']});
}
return await _asFlutterClient(client!, 'focus')
.focus(args['key'] as String? ?? '');
case 'blur':
if (client is BridgeDriver) {
return await client.callMethod('blur', {'key': args['key']});
}
return await _asFlutterClient(client!, 'blur')
.blur(key: args['key'] as String?);
case 'hover':
if (client is BridgeDriver) {
return await client.callMethod('hover',
{'key': args['key'], 'text': args['text'], 'ref': args['ref']});
}
return await _asFlutterClient(client!, 'hover')
.hover(key: args['key'] as String?, text: args['text'] as String?);
case 'get_slider_value':
if (client is BridgeDriver) {
return await client
Expand Down Expand Up @@ -231,7 +292,9 @@ extension _BfInteraction on FlutterMcpServer {
}
// Flutter VM Service — use the registered pressKey extension
if (client is FlutterSkillClient) {
final result = await client.pressKey(key);
final modifiers =
(args['modifiers'] as List?)?.map((m) => m.toString()).toList();
final result = await client.pressKey(key, modifiers: modifiers);
if (result['success'] == true) {
return {"success": true, "message": "Key pressed: $key"};
}
Expand Down
3 changes: 2 additions & 1 deletion lib/src/cli/tool_handlers/bf_navigation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ extension _BfNavigation on FlutterMcpServer {
return {"route": route};
}
final fc = _asFlutterClient(client!, 'get_current_route');
return await fc.getCurrentRoute();
// Always a map: a bare null would read as "tool not handled".
return await fc.getCurrentRouteInfo();
case 'go_back':
final count = (args['count'] as num?)?.toInt() ?? 1;
var steps = 0;
Expand Down
35 changes: 33 additions & 2 deletions lib/src/cli/tool_handlers/bf_screenshot.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
part of '../server.dart';

extension _BfScreenshot on FlutterMcpServer {
/// Image vs logical geometry so an agent can convert pixels it sees in the
/// PNG back to the logical coordinates that tap_at/swipe/drag expect.
Map<String, dynamic> _screenshotGeometry(Map<String, dynamic> info) {
final imageWidth = info['imageWidth'] as num?;
final logicalWidth = info['logicalWidth'] as num?;
if (imageWidth == null || logicalWidth == null || logicalWidth == 0) {
return const {};
}
final scale = imageWidth / logicalWidth;
return {
"image_size": {"width": imageWidth, "height": info['imageHeight']},
"logical_size": {"width": logicalWidth, "height": info['logicalHeight']},
"device_pixel_ratio": info['devicePixelRatio'],
"scale": scale,
"coordinates_hint": "Tools take LOGICAL coordinates. To tap what you "
"see at image pixel (px, py): x = px / scale, y = py / scale "
"(scale = ${scale.toStringAsFixed(3)}). Prefer inspect() centers.",
};
}

Future<dynamic> _handleScreenshotTool(
String name, Map<String, dynamic> args, AppDriver? client) async {
switch (name) {
Expand All @@ -11,8 +31,17 @@ extension _BfScreenshot on FlutterMcpServer {
final saveToFile = args['save_to_file'] ??
true; // Save to file by default to avoid token overflow

var imageBase64 =
await client!.takeScreenshot(quality: quality, maxWidth: maxWidth);
Map<String, dynamic> geometry = const {};
String? imageBase64;
if (client is FlutterSkillClient) {
final info = await client.takeScreenshotWithInfo(
quality: quality, maxWidth: maxWidth);
imageBase64 = info['image'] as String?;
geometry = _screenshotGeometry(info);
} else {
imageBase64 = await client!
.takeScreenshot(quality: quality, maxWidth: maxWidth);
}

// Fallback to native screenshot if bridge returns null
// (e.g. React Native returns _needs_native flag)
Expand Down Expand Up @@ -56,6 +85,7 @@ extension _BfScreenshot on FlutterMcpServer {
"quality": quality,
"max_width": maxWidth,
"format": "png",
...geometry,
"message": "Screenshot saved to ${file.path}"
};
} else {
Expand All @@ -64,6 +94,7 @@ extension _BfScreenshot on FlutterMcpServer {
"image": imageBase64,
"quality": quality,
"max_width": maxWidth,
...geometry,
"warning":
"Returning base64 data. Consider using save_to_file=true for large images."
};
Expand Down
3 changes: 2 additions & 1 deletion lib/src/cli/tool_handlers/bridge_flutter_handlers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extension _BridgeFlutterHandlers on FlutterMcpServer {
return await (_client as BridgeDriver).callTool(toolName, toolParams);
}

throw Exception("Unknown tool: $name");
throw Exception("Tool '$name' is not implemented for the "
"${client.runtimeType} driver (or returned no result)");
}
}
4 changes: 2 additions & 2 deletions lib/src/cli/tool_handlers/dev_tool_handlers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension _DevToolHandlers on FlutterMcpServer {
if (name == 'hot_reload') {
final client = _getClient(args);
_requireConnection(client);
if (client is FlutterSkillClient) return await client.hotReload();
await client!.hotReload();
return "Hot reload triggered";
}
Expand All @@ -25,8 +26,7 @@ extension _DevToolHandlers on FlutterMcpServer {
final client = _getClient(args);
_requireConnection(client);
final fc = _asFlutterClient(client!, 'hot_restart');
await fc.hotRestart();
return "Hot restart triggered";
return await fc.hotRestart();
}

if (name == 'enable_test_indicators') {
Expand Down
16 changes: 13 additions & 3 deletions lib/src/cli/tool_handlers/flutter_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -250,21 +250,31 @@ extension _FlutterHelpers on FlutterMcpServer {
);

if (found) {
// Being built (cache extent) is not being visible: bring it into
// the viewport before reporting success.
await client.scrollTo(key: key, text: text);
return {
"success": true,
"found": true,
"scrolls_needed": i,
};
}

// Scroll
// `direction` is the scroll direction ("down" reveals content further
// down); a swipe is the finger's motion, which is the opposite way.
const fingerFor = {
'down': 'up',
'up': 'down',
'right': 'left',
'left': 'right',
};
await client.swipe(
direction: direction,
direction: fingerFor[direction] ?? direction,
distance: 300,
key: scrollableKey,
);

// Wait for scroll animation
// Let the fling settle before looking again
await Future.delayed(const Duration(milliseconds: 300));
}

Expand Down
17 changes: 15 additions & 2 deletions lib/src/cli/tool_handlers/tool_definitions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1724,14 +1724,27 @@ Option 3: Tap a TextField first, then enter_text(text: "value") without key/ref
},
{
"name": "drag",
"description": "Drag from one element to another",
"description": "Drag from one element (or point) to another. "
"Set hold_ms (e.g. 400) for LongPressDraggable / long-press-to-"
"select lists so the drag is recognised before the pointer moves.",
"inputSchema": {
"type": "object",
"properties": {
"from_key": {"type": "string", "description": "Source element key"},
"to_key": {"type": "string", "description": "Target element key"},
"start_x": {"type": "number", "description": "Start X (logical)"},
"start_y": {"type": "number", "description": "Start Y (logical)"},
"end_x": {"type": "number", "description": "End X (logical)"},
"end_y": {"type": "number", "description": "End Y (logical)"},
"hold_ms": {
"type": "integer",
"description": "Press-and-hold before moving, in ms (default 0)"
},
"duration_ms": {
"type": "integer",
"description": "Travel time in ms (default 400)"
},
},
"required": ["from_key", "to_key"],
},
},

Expand Down
Loading
Loading