Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
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
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8
GIT binary patch
literal 1418
zcmV;51$Fv~P)q
zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+
zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq
z^={4hPQv)y=I|4n+?>7Fim=dxt1
z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT
zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf`
zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_>
z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3
zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF
z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a
z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE
z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(
zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;?
zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-<
z{s<&cCV_1`^TD^ia9!*mQDq&
zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw
zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv
zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF
z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC
YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..903c6d1
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Web Frontend
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ web_frontend
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/controllers/ControlPanelController.dart b/lib/controllers/ControlPanelController.dart
new file mode 100644
index 0000000..72c5ff6
--- /dev/null
+++ b/lib/controllers/ControlPanelController.dart
@@ -0,0 +1,373 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:web_frontend/models/DeviceInfo.dart';
+import 'package:web_frontend/models/LogType.dart';
+import 'package:web_frontend/models/UserInfo.dart';
+import 'package:web_frontend/services/WebSocketService.dart';
+
+class ControlPanelController extends GetxController {
+ final WebSocketService webSocketService = Get.find();
+
+ // 用户相关
+ final users = [].obs;
+ final selectedUser = Rx(null);
+
+ // 设备相关
+ final devices = [].obs;
+ final searchKeyword = ''.obs;
+
+ // 信号强度过滤
+ final rssiFilterValue = (-70).obs; // 默认-70dBm
+
+ // 日志相关
+ final connectionLogs = [].obs;
+ final deviceLogs = [].obs;
+ final allLogs = [].obs;
+
+ final autoScroll = true.obs;
+ final ScrollController logScrollController = ScrollController();
+
+ // 命令输入
+ final commandController = TextEditingController();
+
+ // 日志过滤器
+ final showAllLogs = true.obs;
+ final showConnectionLogs = true.obs;
+ final showDeviceLogs = true.obs;
+
+ // 过滤后的设备列表(包含名称搜索和信号强度过滤)
+ List get filteredDevices {
+ List result = devices.toList();
+
+ // 名称/ID 模糊查询
+ if (searchKeyword.value.isNotEmpty) {
+ result = result
+ .where((device) =>
+ device.name
+ .toLowerCase()
+ .contains(searchKeyword.value.toLowerCase()) ||
+ device.id
+ .toLowerCase()
+ .contains(searchKeyword.value.toLowerCase()))
+ .toList();
+ }
+
+ // 信号强度过滤(只显示信号强度大于等于设定值的设备)
+ result =
+ result.where((device) => device.rssi >= rssiFilterValue.value).toList();
+
+ // 按信号强度排序(从强到弱)
+ result.sort((a, b) => b.rssi.compareTo(a.rssi));
+
+ return result;
+ }
+
+ // 获取当前显示的日志
+ List get displayLogs {
+ if (showAllLogs.value) {
+ return allLogs.toList();
+ }
+
+ final logs = [];
+ if (showConnectionLogs.value) {
+ logs.addAll(connectionLogs);
+ }
+ if (showDeviceLogs.value) {
+ logs.addAll(deviceLogs);
+ }
+ logs.sort((a, b) => a.time.compareTo(b.time));
+ return logs;
+ }
+
+ @override
+ void onInit() {
+ super.onInit();
+ _initWebSocketHandlers();
+ }
+
+ @override
+ void onClose() {
+ commandController.dispose();
+ logScrollController.dispose();
+ super.onClose();
+ }
+
+ void _initWebSocketHandlers() {
+ webSocketService.registerMessageHandler(_handleWebSocketMessage);
+ }
+
+ // void _handleWebSocketMessage(Map data) {
+ // final type = data['type'] as String?;
+
+ // switch (type) {
+ // case 'user_list':
+ // _updateUserList(data['users']);
+ // break;
+
+ // case 'device_selected':
+ // _addConnectionLog('已连接到设备: ${data['targetUuid']}', LogType.success);
+ // break;
+
+ // case 'device_message':
+ // _handleDeviceMessage(data['data']);
+ // break;
+
+ // case 'error':
+ // _addConnectionLog('错误: ${data['message']}', LogType.error);
+ // break;
+
+ // default:
+ // if (type != null) {
+ // _addConnectionLog('收到消息类型: $type', LogType.info);
+ // }
+ // }
+ // }
+ void _handleWebSocketMessage(Map data) {
+ final type = data['type'] as String?;
+
+ switch (type) {
+ case 'user_list':
+ _updateUserList(data['users']);
+ break;
+
+ case 'device_selected':
+ _addConnectionLog('已连接到设备: ${data['targetUuid']}', LogType.success);
+ // 设备连接成功后,自动触发扫描
+ _addConnectionLog('设备已就绪,自动开始扫描...', LogType.info);
+ webSocketService.startScan();
+ _addDeviceLog('自动发送扫描命令', LogType.info);
+ break;
+
+ case 'device_message':
+ _handleDeviceMessage(data['data']);
+ break;
+
+ case 'error':
+ _addConnectionLog('错误: ${data['message']}', LogType.error);
+ break;
+
+ default:
+ if (type != null) {
+ _addConnectionLog('收到消息类型: $type', LogType.info);
+ }
+ }
+ }
+
+ void _updateUserList(List usersData) {
+ users.value = usersData.map((u) => UserInfo.fromJson(u)).toList();
+
+ if (selectedUser.value != null &&
+ !users.any((u) => u.uuid == selectedUser.value!.uuid)) {
+ selectedUser.value = null;
+ devices.clear();
+ }
+
+ _addConnectionLog('设备列表更新: ${users.length} 个在线设备', LogType.info);
+ }
+
+ void _handleDeviceMessage(Map data) {
+ final deviceDataType = data['type'] as String?;
+
+ switch (deviceDataType) {
+ case 'device_list':
+ _updateDeviceList(data['devices']);
+ break;
+
+ case 'bluetooth_data':
+ _addDeviceLog('收到蓝牙数据: ${data['data']}', LogType.device);
+ break;
+
+ case 'bluetooth_status':
+ final isConnected = data['isConnected'] as bool? ?? false;
+ _addDeviceLog('蓝牙状态: ${isConnected ? "已连接" : "未连接"}',
+ isConnected ? LogType.success : LogType.warning);
+ break;
+
+ case 'scan_start':
+ _addDeviceLog('开始扫描蓝牙设备...', LogType.info);
+ break;
+
+ case 'scan_stop':
+ _addDeviceLog('停止扫描', LogType.info);
+ break;
+
+ default:
+ _addDeviceLog('设备消息: $data', LogType.device);
+ }
+ }
+
+ void _updateDeviceList(List devicesData) {
+ devices.value = devicesData.map((d) => DeviceInfo.fromJson(d)).toList();
+ // _addDeviceLog('发现 ${devices.length} 个蓝牙设备', LogType.success);
+ }
+
+ // 添加连接日志(WebSocket相关)
+ void _addConnectionLog(String message, LogType type) {
+ final log = LogEntry(
+ message: message,
+ time: DateTime.now(),
+ type: type,
+ );
+ connectionLogs.add(log);
+ _updateAllLogs(log);
+ _scrollToBottom();
+
+ // 限制日志数量
+ if (connectionLogs.length > 500) {
+ connectionLogs.removeAt(0);
+ }
+ }
+
+ // 添加设备日志
+ void _addDeviceLog(String message, LogType type) {
+ final log = LogEntry(
+ message: message,
+ time: DateTime.now(),
+ type: type,
+ deviceId: selectedUser.value?.uuid,
+ );
+ deviceLogs.add(log);
+ _updateAllLogs(log);
+ _scrollToBottom();
+
+ if (deviceLogs.length > 500) {
+ deviceLogs.removeAt(0);
+ }
+ }
+
+ // 添加日志(供外部调用)
+ void addLog(String message, LogType type) {
+ _addConnectionLog(message, type);
+ }
+
+ void _updateAllLogs(LogEntry log) {
+ allLogs.add(log);
+ if (allLogs.length > 1000) {
+ allLogs.removeAt(0);
+ }
+ }
+
+ void _scrollToBottom() {
+ if (autoScroll.value) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (logScrollController.hasClients) {
+ logScrollController.animateTo(
+ logScrollController.position.maxScrollExtent,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOut,
+ );
+ }
+ });
+ }
+ }
+
+ // ==================== 公共方法 ====================
+
+ // 更新信号强度过滤值
+ void updateRssiFilter(int value) {
+ rssiFilterValue.value = value;
+ _addConnectionLog('信号强度过滤设置为: ${value} dBm', LogType.info);
+ }
+
+ // 重置信号强度过滤
+ void resetRssiFilter() {
+ rssiFilterValue.value = -100;
+ _addConnectionLog('信号强度过滤已重置', LogType.info);
+ }
+
+ // 选择设备并建立连接
+ void selectUser(UserInfo? user) {
+ if (user == null) return;
+
+ selectedUser.value = user;
+ devices.clear();
+
+ // 通过 WebSocket 选择设备,建立连接
+ webSocketService.selectDevice(user.uuid);
+ _addConnectionLog('正在连接设备: ${user.deviceModel}', LogType.info);
+ }
+
+ // 发送自定义数据
+ void sendCustomData() {
+ if (selectedUser.value == null) {
+ _addConnectionLog('请先选择设备', LogType.warning);
+ return;
+ }
+
+ final command = commandController.text.trim();
+ if (command.isEmpty) {
+ _addConnectionLog('请输入命令或数据', LogType.warning);
+ return;
+ }
+
+ webSocketService.sendDataToDevice(command);
+ _addDeviceLog('发送数据: $command', LogType.info);
+ commandController.clear();
+ }
+
+ // 开始扫描蓝牙设备
+ void sendScanCommand() {
+ if (selectedUser.value == null) {
+ _addConnectionLog('请先选择设备', LogType.warning);
+ return;
+ }
+ webSocketService.startScan();
+ _addDeviceLog('发送扫描命令', LogType.info);
+ }
+
+ // 停止扫描
+ void sendStopScanCommand() {
+ if (selectedUser.value == null) {
+ _addConnectionLog('请先选择设备', LogType.warning);
+ return;
+ }
+ webSocketService.stopScan();
+ _addDeviceLog('发送停止扫描命令', LogType.info);
+ }
+
+ // 连接蓝牙设备
+ void connectToDevice(DeviceInfo device) {
+ if (selectedUser.value == null) {
+ _addConnectionLog('请先选择设备', LogType.warning);
+ return;
+ }
+ webSocketService.connectToDevice(device.id);
+ _addDeviceLog('连接蓝牙设备: ${device.name}', LogType.info);
+ }
+
+ // 断开蓝牙设备
+ void disconnectDevice() {
+ if (selectedUser.value == null) {
+ _addConnectionLog('请先选择设备', LogType.warning);
+ return;
+ }
+ webSocketService.disconnectDevice();
+ _addDeviceLog('断开蓝牙设备连接', LogType.info);
+ }
+
+ // 清空日志
+ void clearLogs() {
+ allLogs.clear();
+ connectionLogs.clear();
+ deviceLogs.clear();
+ _addConnectionLog('日志已清空', LogType.info);
+ }
+
+ // 切换自动滚动
+ void toggleAutoScroll(bool value) {
+ autoScroll.value = value;
+ }
+
+ // 切换日志过滤器
+ void toggleShowAllLogs(bool value) {
+ showAllLogs.value = value;
+ }
+
+ void toggleShowConnectionLogs(bool value) {
+ showConnectionLogs.value = value;
+ }
+
+ void toggleShowDeviceLogs(bool value) {
+ showDeviceLogs.value = value;
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..433b048
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:web_frontend/services/WebSocketService.dart';
+import 'package:web_frontend/views/ControlPanelView.dart';
+
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ // 初始化服务
+ await Get.putAsync(() async => WebSocketService());
+
+ // 连接 WebSocket
+ Get.find().connect();
+
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'BLE Debug Control Panel',
+ theme: ThemeData(
+ primarySwatch: Colors.blue,
+ useMaterial3: true,
+ ),
+ home: ControlPanelView(),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/models/DeviceInfo.dart b/lib/models/DeviceInfo.dart
new file mode 100644
index 0000000..619dd38
--- /dev/null
+++ b/lib/models/DeviceInfo.dart
@@ -0,0 +1,20 @@
+// 设备信息模型
+class DeviceInfo {
+ final String name;
+ final String id;
+ final int rssi;
+
+ DeviceInfo({
+ required this.name,
+ required this.id,
+ required this.rssi,
+ });
+
+ factory DeviceInfo.fromJson(Map json) {
+ return DeviceInfo(
+ name: json['name'] ?? '',
+ id: json['id'] ?? '',
+ rssi: json['rssi'] ?? 0,
+ );
+ }
+}
diff --git a/lib/models/LogType.dart b/lib/models/LogType.dart
new file mode 100644
index 0000000..8734784
--- /dev/null
+++ b/lib/models/LogType.dart
@@ -0,0 +1,24 @@
+// 日志类型
+enum LogType {
+ info,
+ success,
+ error,
+ warning,
+ device, // 设备日志
+ connection, // 连接日志
+}
+
+// 日志条目模型
+class LogEntry {
+ final String message;
+ final DateTime time;
+ final LogType type;
+ final String? deviceId; // 可选,关联的设备ID
+
+ LogEntry({
+ required this.message,
+ required this.time,
+ required this.type,
+ this.deviceId,
+ });
+}
diff --git a/lib/models/UserInfo.dart b/lib/models/UserInfo.dart
new file mode 100644
index 0000000..bc85aff
--- /dev/null
+++ b/lib/models/UserInfo.dart
@@ -0,0 +1,22 @@
+// 用户信息模型
+class UserInfo {
+ final String uuid;
+ final String deviceModel;
+ final String connectedAt;
+
+ UserInfo({
+ required this.uuid,
+ required this.deviceModel,
+ required this.connectedAt,
+ });
+
+ factory UserInfo.fromJson(Map json) {
+ return UserInfo(
+ uuid: json['uuid'] ?? '',
+ deviceModel: json['deviceModel'] ?? '',
+ connectedAt: json['connectedAt'] ?? '',
+ );
+ }
+}
+
+
diff --git a/lib/services/WebSocketService.dart b/lib/services/WebSocketService.dart
new file mode 100644
index 0000000..4382769
--- /dev/null
+++ b/lib/services/WebSocketService.dart
@@ -0,0 +1,197 @@
+import 'dart:async';
+import 'dart:convert';
+import 'package:get/get.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+import 'package:web_socket_channel/status.dart' as status;
+
+class WebSocketService extends GetxService {
+ //webscoket地址
+ static const String WS_URL = 'ws://192.168.1.129:8089/ws';
+
+ WebSocketChannel? _channel;
+ final RxBool isConnected = false.obs;
+ final RxString connectionStatus = '未连接'.obs;
+
+ // 当前选中的设备UUID
+ final RxString selectedDeviceUuid = ''.obs;
+
+ Timer? _reconnectTimer;
+ int _reconnectAttempts = 0;
+ static const int MAX_RECONNECT_ATTEMPTS = 10;
+
+ @override
+ void onClose() {
+ disconnect();
+ _reconnectTimer?.cancel();
+ super.onClose();
+ }
+
+ Future connect() async {
+ try {
+ _channel = WebSocketChannel.connect(Uri.parse(WS_URL));
+ isConnected.value = true;
+ connectionStatus.value = '已连接';
+ _reconnectAttempts = 0;
+
+ _sendMessage({
+ 'type': 'web_register',
+ 'clientId': 'web_client_${DateTime.now().millisecondsSinceEpoch}',
+ });
+
+ _channel!.stream.listen(
+ (message) {
+ _handleMessage(message);
+ },
+ onError: (error) {
+ print('WebSocket错误: $error');
+ connectionStatus.value = '错误: $error';
+ isConnected.value = false;
+ _scheduleReconnect();
+ },
+ onDone: () {
+ print('WebSocket连接断开');
+ connectionStatus.value = '已断开';
+ isConnected.value = false;
+ _scheduleReconnect();
+ },
+ );
+
+ } catch (e) {
+ print('WebSocket连接失败: $e');
+ connectionStatus.value = '连接失败';
+ isConnected.value = false;
+ _scheduleReconnect();
+ }
+ }
+
+ void _scheduleReconnect() {
+ if (_reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+ connectionStatus.value = '重连失败,请手动刷新';
+ return;
+ }
+
+ _reconnectTimer?.cancel();
+ _reconnectTimer = Timer(Duration(seconds: 5), () {
+ _reconnectAttempts++;
+ connectionStatus.value = '重连中 (${_reconnectAttempts}/$MAX_RECONNECT_ATTEMPTS)...';
+ connect();
+ });
+ }
+
+ void disconnect() {
+ _channel?.sink.close(status.goingAway);
+ _channel = null;
+ isConnected.value = false;
+ connectionStatus.value = '已断开';
+ }
+
+ void _sendMessage(Map message) {
+ if (_channel != null && isConnected.value) {
+ _channel!.sink.add(jsonEncode(message));
+ } else {
+ print('WebSocket未连接,无法发送消息: $message');
+ }
+ }
+
+ final List)> _messageHandlers = [];
+
+ void registerMessageHandler(Function(Map) handler) {
+ _messageHandlers.add(handler);
+ }
+
+ void _handleMessage(dynamic message) {
+ try {
+ final data = jsonDecode(message);
+ for (var handler in _messageHandlers) {
+ handler(data);
+ }
+ } catch (e) {
+ print('解析消息失败: $e');
+ }
+ }
+
+ // ==================== 公开方法 ====================
+
+ // 选择要控制的设备(建立连接)
+ void selectDevice(String uuid) {
+ selectedDeviceUuid.value = uuid;
+ _sendMessage({
+ 'type': 'select_device',
+ 'targetUuid': uuid,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('选择设备: $uuid');
+ }
+
+ // 开始扫描
+ void startScan() {
+ if (selectedDeviceUuid.value.isEmpty) {
+ print('请先选择设备');
+ return;
+ }
+ _sendMessage({
+ 'type': 'scan',
+ 'targetUuid': selectedDeviceUuid.value,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('发送扫描命令');
+ }
+
+ // 停止扫描
+ void stopScan() {
+ if (selectedDeviceUuid.value.isEmpty) {
+ print('请先选择设备');
+ return;
+ }
+ _sendMessage({
+ 'type': 'stop_scan',
+ 'targetUuid': selectedDeviceUuid.value,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('发送停止扫描命令');
+ }
+
+ // 连接蓝牙设备
+ void connectToDevice(String deviceId) {
+ if (selectedDeviceUuid.value.isEmpty) {
+ print('请先选择设备');
+ return;
+ }
+ _sendMessage({
+ 'type': 'connect',
+ 'targetUuid': selectedDeviceUuid.value,
+ 'deviceId': deviceId,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('连接蓝牙设备: $deviceId');
+ }
+
+ // 断开蓝牙设备
+ void disconnectDevice() {
+ if (selectedDeviceUuid.value.isEmpty) {
+ print('请先选择设备');
+ return;
+ }
+ _sendMessage({
+ 'type': 'disconnect',
+ 'targetUuid': selectedDeviceUuid.value,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('断开蓝牙设备');
+ }
+
+ // 发送数据到设备
+ void sendDataToDevice(String data) {
+ if (selectedDeviceUuid.value.isEmpty) {
+ print('请先选择设备');
+ return;
+ }
+ _sendMessage({
+ 'type': 'send_data',
+ 'targetUuid': selectedDeviceUuid.value,
+ 'data': data,
+ 'timestamp': DateTime.now().toIso8601String(),
+ });
+ print('发送数据: $data');
+ }
+}
\ No newline at end of file
diff --git a/lib/views/ControlPanelView.dart b/lib/views/ControlPanelView.dart
new file mode 100644
index 0000000..9b55a45
--- /dev/null
+++ b/lib/views/ControlPanelView.dart
@@ -0,0 +1,1097 @@
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:web_frontend/controllers/ControlPanelController.dart';
+import 'package:web_frontend/models/LogType.dart';
+import 'package:web_frontend/models/UserInfo.dart';
+import 'package:web_frontend/services/WebSocketService.dart';
+
+class ControlPanelView extends StatelessWidget {
+ ControlPanelView({super.key});
+
+ final GlobalKey _dropdownButtonKey = GlobalKey();
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = Get.put(ControlPanelController());
+ final webSocketService = Get.find();
+
+ return Scaffold(
+ body: Row(
+ children: [
+ // 左侧边栏 - 占15%宽度
+ _buildSidebar(controller, context),
+
+ // 右侧主区域 - 占85%宽度
+ Expanded(
+ child: _buildMainArea(controller, webSocketService),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSidebar(
+ ControlPanelController controller, BuildContext context) {
+ return Container(
+ width: MediaQuery.of(context).size.width * 0.15,
+ color: Colors.grey[900],
+ child: Column(
+ children: [
+ // 用户选择区域
+ _buildUserSelection(controller, context),
+
+ // 模糊查询输入框
+ _buildSearchField(controller),
+
+ // 信号强度过滤
+ _buildRssiFilter(controller),
+
+ // 设备统计
+ _buildDeviceCount(controller),
+
+ const SizedBox(height: 8),
+
+ // 设备列表 - 使用 Expanded 让它占据剩余空间
+ Expanded(
+ child: _buildDeviceList(controller),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildUserSelection(
+ ControlPanelController controller, BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '当前用户',
+ style: TextStyle(color: Colors.white, fontSize: 12),
+ ),
+ const SizedBox(height: 8),
+ Obx(() {
+ if (controller.users.isEmpty) {
+ return Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: Colors.grey[800],
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Text(
+ '暂无在线设备',
+ style: TextStyle(color: Colors.grey, fontSize: 12),
+ ),
+ );
+ }
+
+ // 使用 InkWell 实现可点击的选择器
+ return InkWell(
+ key: _dropdownButtonKey,
+ onTap: () => _showCustomUserMenu(controller, context),
+ child: Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: Colors.grey[800],
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.grey[700]!),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ controller.selectedUser.value != null
+ ? '${controller.selectedUser.value!.deviceModel} (${_getShortUuid(controller.selectedUser.value!.uuid)})'
+ : '选择设备',
+ style: TextStyle(
+ color: controller.selectedUser.value != null
+ ? Colors.white
+ : Colors.grey,
+ fontSize: 12,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ const Icon(Icons.arrow_drop_down,
+ color: Colors.grey, size: 20),
+ ],
+ ),
+ ),
+ );
+ }),
+ // 添加连接状态提示
+ Obx(() => Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Row(
+ children: [
+ Icon(
+ controller.webSocketService.isConnected.value
+ ? Icons.wifi
+ : Icons.wifi_off,
+ size: 12,
+ color: controller.webSocketService.isConnected.value
+ ? Colors.green
+ : Colors.red,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ controller.webSocketService.connectionStatus.value,
+ style: TextStyle(
+ color: controller.webSocketService.isConnected.value
+ ? Colors.green
+ : Colors.red,
+ fontSize: 10,
+ ),
+ ),
+ ],
+ ),
+ )),
+ ],
+ ),
+ );
+ }
+
+ // 构建 PopupMenu 的菜单项
+ List> _buildPopupMenuItems(
+ ControlPanelController controller, BuildContext context) {
+ // 使用 StatefulBuilder 来实现搜索功能
+ final List> entries = [];
+
+ // 添加搜索框
+ entries.add(
+ PopupMenuItem(
+ enabled: false,
+ padding: EdgeInsets.zero,
+ child: Container(
+ width: 280,
+ padding: const EdgeInsets.all(8),
+ child: TextField(
+ autofocus: true,
+ style: const TextStyle(color: Colors.white, fontSize: 12),
+ decoration: InputDecoration(
+ hintText: '搜索设备...',
+ hintStyle: const TextStyle(color: Colors.grey, fontSize: 12),
+ prefixIcon:
+ const Icon(Icons.search, size: 18, color: Colors.grey),
+ filled: true,
+ fillColor: Colors.grey[800],
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(6),
+ borderSide: BorderSide.none,
+ ),
+ contentPadding:
+ const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ ),
+ onChanged: (value) {
+ // 这里需要通过 StatefulBuilder 来更新
+ // 由于 PopupMenuButton 不支持动态更新,我们使用另一种方式
+ },
+ ),
+ ),
+ ),
+ );
+
+ // 添加分隔线
+ entries.add(const PopupMenuDivider());
+
+ // 添加设备列表
+ final users = controller.users;
+ for (var user in users) {
+ final isSelected = controller.selectedUser.value?.uuid == user.uuid;
+ entries.add(
+ PopupMenuItem(
+ value: user,
+ padding: EdgeInsets.zero,
+ child: Container(
+ width: 280,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: isSelected ? Colors.grey[800] : null,
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.devices,
+ size: 16,
+ color: isSelected ? Colors.green : Colors.grey,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ user.deviceModel,
+ style: TextStyle(
+ color: isSelected ? Colors.green : Colors.grey[500],
+ fontSize: 12,
+ ),
+ ),
+ Text(
+ // _getShortUuid(user.uuid),
+ user.uuid,
+ style: TextStyle(
+ color: Colors.grey[500],
+ fontSize: 10,
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (isSelected)
+ const Icon(Icons.check_circle, size: 16, color: Colors.green),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ return entries;
+ }
+
+ String _getShortUuid(String uuid) {
+ if (uuid.length >= 8) {
+ return '${uuid.substring(0, 8)}...';
+ }
+ return uuid;
+ }
+
+ Widget _buildSearchField(ControlPanelController controller) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: TextField(
+ style: const TextStyle(color: Colors.white),
+ decoration: InputDecoration(
+ hintText: '模糊查询...',
+ hintStyle: const TextStyle(color: Colors.grey),
+ prefixIcon: const Icon(Icons.search, color: Colors.grey),
+ filled: true,
+ fillColor: Colors.grey[800],
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ borderSide: BorderSide.none,
+ ),
+ ),
+ onChanged: (value) => controller.searchKeyword.value = value,
+ ),
+ );
+ }
+
+ // 信号强度过滤组件
+ Widget _buildRssiFilter(ControlPanelController controller) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ '信号强度过滤',
+ style: TextStyle(color: Colors.grey, fontSize: 12),
+ ),
+ const SizedBox(height: 16),
+ LayoutBuilder(
+ builder: (context, constraints) {
+ return Obx(() {
+ final double minValue = -100;
+ final double maxValue = 65;
+ final double valueRange = maxValue - minValue;
+ final double percent =
+ (controller.rssiFilterValue.value - minValue) / valueRange;
+ final double thumbPosition = percent * constraints.maxWidth;
+
+ return Column(
+ children: [
+ // 滑块区域
+ GestureDetector(
+ onHorizontalDragUpdate: (details) {
+ final newPercent = (thumbPosition + details.delta.dx) /
+ constraints.maxWidth;
+ final newPercentClamped = newPercent.clamp(0.0, 1.0);
+ final newValue =
+ minValue + (newPercentClamped * valueRange);
+ controller.updateRssiFilter(newValue.round());
+ },
+ onTapDown: (details) {
+ final localX = details.localPosition.dx;
+ final newPercent =
+ (localX / constraints.maxWidth).clamp(0.0, 1.0);
+ final newValue = minValue + (newPercent * valueRange);
+ controller.updateRssiFilter(newValue.round());
+ },
+ child: Container(
+ height: 40,
+ child: Stack(
+ alignment: Alignment.centerLeft,
+ children: [
+ // 背景线(灰色)
+ Container(
+ height: 3,
+ width: double.infinity,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(1.5),
+ color: Colors.grey[600],
+ ),
+ ),
+ // 左侧绿色线(从起点到圆点位置)
+ Container(
+ width: thumbPosition,
+ height: 3,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(1.5),
+ color: Colors.green,
+ ),
+ ),
+ // 白色实心圆
+ Positioned(
+ left: thumbPosition - 12,
+ child: Container(
+ width: 24,
+ height: 24,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.white,
+ border: Border.all(
+ color: Colors.green,
+ width: 2,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.2),
+ blurRadius: 4,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ // 强度值显示和范围标签
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text(
+ '-100 dBm',
+ style: TextStyle(color: Colors.grey, fontSize: 10),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.green.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Colors.green,
+ width: 1,
+ ),
+ ),
+ child: Text(
+ '${controller.rssiFilterValue.value} dBm',
+ style: const TextStyle(
+ color: Colors.green,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const Text(
+ '65 dBm',
+ style: TextStyle(color: Colors.grey, fontSize: 10),
+ ),
+ ],
+ ),
+ ],
+ );
+ });
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Color _getRssiColor(int rssi) {
+ if (rssi >= -50) return Colors.green;
+ if (rssi >= -70) return Colors.lightGreen;
+ if (rssi >= -80) return Colors.yellow.shade700;
+ if (rssi >= -90) return Colors.orange;
+ return Colors.red;
+ }
+
+ Widget _buildDeviceCount(ControlPanelController controller) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Obx(() => Text(
+ '匹配出 ${controller.filteredDevices.length} 个设备',
+ style: const TextStyle(color: Colors.grey, fontSize: 12),
+ )),
+ ),
+ );
+ }
+
+ Widget _buildDeviceList(ControlPanelController controller) {
+ return Obx(() {
+ if (controller.selectedUser.value == null) {
+ return const Center(
+ child: Text(
+ '请先选择设备',
+ style: TextStyle(color: Colors.grey),
+ ),
+ );
+ }
+
+ if (controller.filteredDevices.isEmpty) {
+ return const Center(
+ child: Text(
+ '暂无设备,请点击"扫描"按钮',
+ style: TextStyle(color: Colors.grey),
+ ),
+ );
+ }
+
+ return ListView.builder(
+ itemCount: controller.filteredDevices.length,
+ itemBuilder: (context, index) {
+ final device = controller.filteredDevices[index];
+ return ListTile(
+ title: Text(
+ device.name.isEmpty ? '未知设备' : device.name,
+ style: const TextStyle(color: Colors.white, fontSize: 13),
+ ),
+ subtitle: Text(
+ device.id,
+ style: const TextStyle(color: Colors.grey, fontSize: 10),
+ ),
+ trailing: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: _getRssiColor(device.rssi),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ '${device.rssi} dBm',
+ style: const TextStyle(color: Colors.white, fontSize: 10),
+ ),
+ ),
+ onTap: () => controller.connectToDevice(device),
+ );
+ },
+ );
+ });
+ }
+
+ Widget _buildMainArea(
+ ControlPanelController controller, WebSocketService webSocketService) {
+ return Column(
+ children: [
+ _buildMenuBar(webSocketService),
+ _buildToolBar(controller),
+ _buildLogFilterBar(controller),
+ Expanded(
+ child: _buildLogArea(controller),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildMenuBar(WebSocketService webSocketService) {
+ return Container(
+ height: 48,
+ color: Colors.grey[100],
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Row(
+ children: [
+ const Text(
+ '指令和日志',
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
+ ),
+ const Spacer(),
+ Obx(() => Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: webSocketService.isConnected.value
+ ? Colors.green.withOpacity(0.2)
+ : Colors.red.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: webSocketService.isConnected.value
+ ? Colors.green
+ : Colors.red,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ webSocketService.connectionStatus.value,
+ style: TextStyle(
+ color: webSocketService.isConnected.value
+ ? Colors.green
+ : Colors.red,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ )),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildToolBar(ControlPanelController controller) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
+ ),
+ child: Row(
+ children: [
+ // 扫描按钮
+ ElevatedButton.icon(
+ onPressed: () {
+ if (controller.selectedUser.value == null) {
+ controller.addLog('请先选择设备', LogType.warning);
+ return;
+ }
+ controller.sendScanCommand();
+ },
+ icon: const Icon(Icons.bluetooth_searching, size: 18),
+ label: const Text('扫描'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blue,
+ foregroundColor: Colors.white,
+ ),
+ ),
+ const SizedBox(width: 8),
+
+ // 停止扫描按钮
+ OutlinedButton.icon(
+ onPressed: () {
+ if (controller.selectedUser.value == null) {
+ controller.addLog('请先选择设备', LogType.warning);
+ return;
+ }
+ controller.sendStopScanCommand();
+ },
+ icon: const Icon(Icons.stop, size: 18),
+ label: const Text('停止扫描'),
+ ),
+ const SizedBox(width: 8),
+
+ // 断开设备按钮
+ OutlinedButton.icon(
+ onPressed: () {
+ if (controller.selectedUser.value == null) {
+ controller.addLog('请先选择设备', LogType.warning);
+ return;
+ }
+ controller.disconnectDevice();
+ },
+ icon: const Icon(Icons.bluetooth_disabled, size: 18),
+ label: const Text('断开设备'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.red,
+ ),
+ ),
+
+ const Spacer(),
+
+ // 命令输入框和执行按钮
+ SizedBox(
+ width: 300,
+ child: TextField(
+ controller: controller.commandController,
+ decoration: InputDecoration(
+ hintText: '输入命令或数据...',
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 8,
+ ),
+ suffixIcon: IconButton(
+ icon: const Icon(Icons.send),
+ onPressed: () {
+ if (controller.selectedUser.value == null) {
+ controller.addLog('请先选择设备', LogType.warning);
+ return;
+ }
+ controller.sendCustomData();
+ },
+ tooltip: '执行',
+ ),
+ ),
+ onSubmitted: (_) {
+ if (controller.selectedUser.value == null) {
+ controller.addLog('请先选择设备', LogType.warning);
+ return;
+ }
+ controller.sendCustomData();
+ },
+ ),
+ ),
+
+ const SizedBox(width: 16),
+
+ // 自动滚动开关
+ Row(
+ children: [
+ const Text('自动滚动'),
+ const SizedBox(width: 8),
+ Obx(() => Switch(
+ value: controller.autoScroll.value,
+ onChanged: controller.toggleAutoScroll,
+ )),
+ ],
+ ),
+
+ const SizedBox(width: 16),
+
+ // 清空日志按钮
+ ElevatedButton(
+ onPressed: controller.clearLogs,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ ),
+ child: const Text('清空日志'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildLogFilterBar(ControlPanelController controller) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ color: Colors.grey[100],
+ border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
+ ),
+ child: Row(
+ children: [
+ const Text('日志过滤器:', style: TextStyle(fontSize: 12)),
+ const SizedBox(width: 16),
+ Obx(() => FilterChip(
+ label: const Text('全部'),
+ selected: controller.showAllLogs.value,
+ onSelected: controller.toggleShowAllLogs,
+ backgroundColor: Colors.white,
+ selectedColor: Colors.blue.shade100,
+ )),
+ const SizedBox(width: 8),
+ Obx(() => FilterChip(
+ label: const Text('连接日志'),
+ selected: !controller.showAllLogs.value &&
+ controller.showConnectionLogs.value,
+ onSelected: (selected) {
+ if (!controller.showAllLogs.value) {
+ controller.toggleShowConnectionLogs(selected);
+ }
+ },
+ backgroundColor: Colors.white,
+ selectedColor: Colors.green.shade100,
+ )),
+ const SizedBox(width: 8),
+ Obx(() => FilterChip(
+ label: const Text('设备日志'),
+ selected: !controller.showAllLogs.value &&
+ controller.showDeviceLogs.value,
+ onSelected: (selected) {
+ if (!controller.showAllLogs.value) {
+ controller.toggleShowDeviceLogs(selected);
+ }
+ },
+ backgroundColor: Colors.white,
+ selectedColor: Colors.orange.shade100,
+ )),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildLogArea(ControlPanelController controller) {
+ return Container(
+ color: Colors.grey[50],
+ child: Obx(() {
+ final logs = controller.displayLogs;
+
+ if (logs.isEmpty) {
+ return const Center(
+ child: Text('暂无日志'),
+ );
+ }
+
+ return ListView.builder(
+ controller: controller.logScrollController,
+ padding: const EdgeInsets.all(16),
+ itemCount: logs.length,
+ itemBuilder: (context, index) {
+ final log = logs[index];
+ return _buildLogItem(log);
+ },
+ );
+ }),
+ );
+ }
+
+ Widget _buildLogItem(LogEntry log) {
+ Color textColor;
+ String prefix;
+ IconData? icon;
+
+ switch (log.type) {
+ case LogType.success:
+ textColor = Colors.green;
+ prefix = '[SUCCESS]';
+ icon = Icons.check_circle_outline;
+ break;
+ case LogType.error:
+ textColor = Colors.red;
+ prefix = '[ERROR]';
+ icon = Icons.error_outline;
+ break;
+ case LogType.warning:
+ textColor = Colors.orange;
+ prefix = '[WARNING]';
+ icon = Icons.warning_amber_outlined;
+ break;
+ case LogType.device:
+ textColor = Colors.purple;
+ prefix = '[DEVICE]';
+ icon = Icons.devices;
+ break;
+ case LogType.connection:
+ textColor = Colors.blue;
+ prefix = '[CONN]';
+ icon = Icons.wifi;
+ break;
+ default:
+ textColor = Colors.black87;
+ prefix = '[INFO]';
+ icon = Icons.info_outline;
+ }
+
+ final timeStr =
+ '${log.time.hour.toString().padLeft(2, '0')}:${log.time.minute.toString().padLeft(2, '0')}:${log.time.second.toString().padLeft(2, '0')}';
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (icon != null) ...[
+ Icon(icon, size: 14, color: textColor),
+ const SizedBox(width: 8),
+ ],
+ SizedBox(
+ width: 80,
+ child: Text(
+ timeStr,
+ style: const TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ SizedBox(
+ width: 80,
+ child: Text(
+ prefix,
+ style: TextStyle(
+ color: textColor,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ log.message,
+ style: TextStyle(
+ color: textColor,
+ fontSize: 12,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showCustomUserMenu(
+ ControlPanelController controller, BuildContext context) {
+ // 获取按钮的位置和大小
+ final RenderBox renderBox =
+ _dropdownButtonKey.currentContext!.findRenderObject() as RenderBox;
+ final Offset offset = renderBox.localToGlobal(Offset.zero);
+ final Size buttonSize = renderBox.size;
+
+ // 搜索控制器
+ final searchController = TextEditingController();
+
+ showGeneralDialog(
+ context: context,
+ barrierDismissible: true,
+ barrierLabel: "Dismiss",
+ barrierColor: Colors.black.withOpacity(0.5),
+ transitionDuration: const Duration(milliseconds: 200),
+ pageBuilder: (context, animation, secondaryAnimation) {
+ // 使用 StatefulBuilder 来管理弹窗内部状态
+ return StatefulBuilder(
+ builder: (dialogContext, setDialogState) {
+ // 初始化过滤用户列表
+ List filteredUsers = controller.users.toList();
+
+ void updateFilter(String value) {
+ setDialogState(() {
+ if (value.isEmpty) {
+ filteredUsers = controller.users.toList();
+ } else {
+ filteredUsers = controller.users
+ .where((user) =>
+ user.deviceModel
+ .toLowerCase()
+ .contains(value.toLowerCase()) ||
+ user.uuid.toLowerCase().contains(value.toLowerCase()))
+ .toList();
+ }
+ });
+ }
+
+ return Stack(
+ children: [
+ // 点击背景关闭
+ Positioned.fill(
+ child: GestureDetector(
+ onTap: () => Navigator.of(dialogContext).pop(),
+ child: Container(
+ color: Colors.transparent,
+ ),
+ ),
+ ),
+ // 菜单内容 - 宽度与按钮相同
+ Positioned(
+ left: offset.dx,
+ top: offset.dy + buttonSize.height + 2,
+ width: buttonSize.width,
+ child: Material(
+ elevation: 8,
+ borderRadius: BorderRadius.circular(8),
+ color: Colors.grey[850],
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 搜索框
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(
+ color: Colors.grey[700]!,
+ width: 1,
+ ),
+ ),
+ ),
+ child: TextField(
+ controller: searchController,
+ autofocus: true,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ ),
+ decoration: InputDecoration(
+ hintText: '搜索设备名称或UUID...',
+ hintStyle: const TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ ),
+ prefixIcon: const Icon(
+ Icons.search,
+ size: 18,
+ color: Colors.grey,
+ ),
+ filled: true,
+ fillColor: Colors.grey[800],
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(6),
+ borderSide: BorderSide.none,
+ ),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 8, vertical: 8),
+ suffixIcon: searchController.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear, size: 16),
+ color: Colors.grey,
+ onPressed: () {
+ searchController.clear();
+ updateFilter('');
+ },
+ )
+ : null,
+ ),
+ onChanged: (value) {
+ updateFilter(value);
+ },
+ ),
+ ),
+ // 用户列表
+ Flexible(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: 300,
+ minHeight: 100,
+ ),
+ child: Builder(
+ builder: (context) {
+ // 重新获取过滤后的列表
+ List currentFilteredUsers;
+ if (searchController.text.isEmpty) {
+ currentFilteredUsers =
+ controller.users.toList();
+ } else {
+ currentFilteredUsers = controller.users
+ .where((user) =>
+ user.deviceModel
+ .toLowerCase()
+ .contains(searchController.text
+ .toLowerCase()) ||
+ user.uuid.toLowerCase().contains(
+ searchController.text
+ .toLowerCase()))
+ .toList();
+ }
+
+ if (currentFilteredUsers.isEmpty) {
+ return const Center(
+ child: Padding(
+ padding: EdgeInsets.all(16),
+ child: Text(
+ '未找到匹配的设备',
+ style: TextStyle(
+ color: Colors.grey,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ );
+ }
+
+ return ListView.builder(
+ shrinkWrap: true,
+ itemCount: currentFilteredUsers.length,
+ itemBuilder: (context, index) {
+ final user = currentFilteredUsers[index];
+ final isSelected =
+ controller.selectedUser.value?.uuid ==
+ user.uuid;
+
+ return InkWell(
+ onTap: () {
+ controller.selectUser(user);
+ Navigator.of(dialogContext).pop();
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: isSelected
+ ? Colors.grey[800]
+ : Colors.transparent,
+ border: isSelected
+ ? null
+ : Border(
+ bottom: BorderSide(
+ color: Colors.grey[800]!,
+ width: 0.5,
+ ),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.devices,
+ size: 16,
+ color: isSelected
+ ? Colors.green
+ : Colors.grey[500],
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children: [
+ Text(
+ user.deviceModel,
+ style: TextStyle(
+ color: isSelected
+ ? Colors.green
+ : Colors.white,
+ fontSize: 12,
+ ),
+ ),
+ Text(
+ user.uuid,
+ style: TextStyle(
+ color: Colors.grey[500],
+ fontSize: 10,
+ ),
+ maxLines: 1,
+ overflow:
+ TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ if (isSelected)
+ const Icon(
+ Icons.check_circle,
+ size: 16,
+ color: Colors.green,
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/linux/.gitignore b/linux/.gitignore
new file mode 100644
index 0000000..d3896c9
--- /dev/null
+++ b/linux/.gitignore
@@ -0,0 +1 @@
+flutter/ephemeral
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
new file mode 100644
index 0000000..8dd3cd6
--- /dev/null
+++ b/linux/CMakeLists.txt
@@ -0,0 +1,145 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.10)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "web_frontend")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.example.web_frontend")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Define the application target. To change its name, change BINARY_NAME above,
+# not the value here, or `flutter run` will no longer work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt
new file mode 100644
index 0000000..d5bd016
--- /dev/null
+++ b/linux/flutter/CMakeLists.txt
@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..e71a16d
--- /dev/null
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,11 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+}
diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..e0f0a47
--- /dev/null
+++ b/linux/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..2e1de87
--- /dev/null
+++ b/linux/flutter/generated_plugins.cmake
@@ -0,0 +1,23 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/linux/main.cc b/linux/main.cc
new file mode 100644
index 0000000..e7c5c54
--- /dev/null
+++ b/linux/main.cc
@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/linux/my_application.cc b/linux/my_application.cc
new file mode 100644
index 0000000..94cf1ac
--- /dev/null
+++ b/linux/my_application.cc
@@ -0,0 +1,124 @@
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
+ gtk_header_bar_set_title(header_bar, "web_frontend");
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
+ gtk_window_set_title(window, "web_frontend");
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+ gtk_widget_show(GTK_WIDGET(window));
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ //MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ //MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID,
+ "flags", G_APPLICATION_NON_UNIQUE,
+ nullptr));
+}
diff --git a/linux/my_application.h b/linux/my_application.h
new file mode 100644
index 0000000..72271d5
--- /dev/null
+++ b/linux/my_application.h
@@ -0,0 +1,18 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/macos/.gitignore b/macos/.gitignore
new file mode 100644
index 0000000..746adbb
--- /dev/null
+++ b/macos/.gitignore
@@ -0,0 +1,7 @@
+# Flutter-related
+**/Flutter/ephemeral/
+**/Pods/
+
+# Xcode-related
+**/dgph
+**/xcuserdata/
diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig
new file mode 100644
index 0000000..c2efd0b
--- /dev/null
+++ b/macos/Flutter/Flutter-Debug.xcconfig
@@ -0,0 +1 @@
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig
new file mode 100644
index 0000000..c2efd0b
--- /dev/null
+++ b/macos/Flutter/Flutter-Release.xcconfig
@@ -0,0 +1 @@
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
new file mode 100644
index 0000000..e777c67
--- /dev/null
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -0,0 +1,12 @@
+//
+// Generated file. Do not edit.
+//
+
+import FlutterMacOS
+import Foundation
+
+import path_provider_foundation
+
+func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
+}
diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..83fbb66
--- /dev/null
+++ b/macos/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,705 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXAggregateTarget section */
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
+ isa = PBXAggregateTarget;
+ buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
+ buildPhases = (
+ 33CC111E2044C6BF0003C045 /* ShellScript */,
+ );
+ dependencies = (
+ );
+ name = "Flutter Assemble";
+ productName = FLX;
+ };
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC10EC2044A3C60003C045;
+ remoteInfo = Runner;
+ };
+ 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC111A2044C6BA0003C045;
+ remoteInfo = FLX;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 33CC110E2044A8840003C045 /* Bundle Framework */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Bundle Framework";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; };
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; };
+ 33CC10ED2044A3C60003C045 /* web_frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "web_frontend.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; };
+ 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; };
+ 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; };
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; };
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; };
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; };
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; };
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; };
+ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; };
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 331C80D2294CF70F00263BE5 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EA2044A3C60003C045 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C80D6294CF71000263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 33BA886A226E78AF003329D5 /* Configs */ = {
+ isa = PBXGroup;
+ children = (
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
+ );
+ path = Configs;
+ sourceTree = "";
+ };
+ 33CC10E42044A3C60003C045 = {
+ isa = PBXGroup;
+ children = (
+ 33FAB671232836740065AC1E /* Runner */,
+ 33CEB47122A05771004F2AC0 /* Flutter */,
+ 331C80D6294CF71000263BE5 /* RunnerTests */,
+ 33CC10EE2044A3C60003C045 /* Products */,
+ D73912EC22F37F3D000D13A0 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 33CC10EE2044A3C60003C045 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10ED2044A3C60003C045 /* web_frontend.app */,
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 33CC11242044D66E0003C045 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */,
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */,
+ 33CC10F72044A3C60003C045 /* Info.plist */,
+ );
+ name = Resources;
+ path = ..;
+ sourceTree = "";
+ };
+ 33CEB47122A05771004F2AC0 /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
+ );
+ path = Flutter;
+ sourceTree = "";
+ };
+ 33FAB671232836740065AC1E /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */,
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */,
+ 33E51914231749380026EE4D /* Release.entitlements */,
+ 33CC11242044D66E0003C045 /* Resources */,
+ 33BA886A226E78AF003329D5 /* Configs */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ D73912EC22F37F3D000D13A0 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C80D4294CF70F00263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C80D1294CF70F00263BE5 /* Sources */,
+ 331C80D2294CF70F00263BE5 /* Frameworks */,
+ 331C80D3294CF70F00263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 33CC10EC2044A3C60003C045 /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 33CC10E92044A3C60003C045 /* Sources */,
+ 33CC10EA2044A3C60003C045 /* Frameworks */,
+ 33CC10EB2044A3C60003C045 /* Resources */,
+ 33CC110E2044A8840003C045 /* Bundle Framework */,
+ 3399D490228B24CF009A79C7 /* ShellScript */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */,
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 33CC10ED2044A3C60003C045 /* web_frontend.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 33CC10E52044A3C60003C045 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastSwiftUpdateCheck = 0920;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C80D4294CF70F00263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 33CC10EC2044A3C60003C045;
+ };
+ 33CC10EC2044A3C60003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ LastSwiftMigration = 1100;
+ ProvisioningStyle = Automatic;
+ SystemCapabilities = {
+ com.apple.Sandbox = {
+ enabled = 1;
+ };
+ };
+ };
+ 33CC111A2044C6BA0003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ ProvisioningStyle = Manual;
+ };
+ };
+ };
+ buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 33CC10E42044A3C60003C045;
+ productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 33CC10EC2044A3C60003C045 /* Runner */,
+ 331C80D4294CF70F00263BE5 /* RunnerTests */,
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C80D3294CF70F00263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EB2044A3C60003C045 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3399D490228B24CF009A79C7 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
+ };
+ 33CC111E2044C6BF0003C045 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ Flutter/ephemeral/FlutterInputs.xcfilelist,
+ );
+ inputPaths = (
+ Flutter/ephemeral/tripwire,
+ );
+ outputFileListPaths = (
+ Flutter/ephemeral/FlutterOutputs.xcfilelist,
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C80D1294CF70F00263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10E92044A3C60003C045 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC10EC2044A3C60003C045 /* Runner */;
+ targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
+ };
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
+ targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 33CC10F52044A3C60003C045 /* Base */,
+ );
+ name = MainMenu.xib;
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 331C80DB294CF71000263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.webFrontend.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web_frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web_frontend";
+ };
+ name = Debug;
+ };
+ 331C80DC294CF71000263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.webFrontend.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web_frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web_frontend";
+ };
+ name = Release;
+ };
+ 331C80DD294CF71000263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.webFrontend.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/web_frontend.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/web_frontend";
+ };
+ name = Profile;
+ };
+ 338D0CE9231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Profile;
+ };
+ 338D0CEA231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Profile;
+ };
+ 338D0CEB231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Profile;
+ };
+ 33CC10F92044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 33CC10FA2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ 33CC10FC2044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 33CC10FD2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 33CC111C2044C6BA0003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Debug;
+ };
+ 33CC111D2044C6BA0003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C80DB294CF71000263BE5 /* Debug */,
+ 331C80DC294CF71000263BE5 /* Release */,
+ 331C80DD294CF71000263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10F92044A3C60003C045 /* Debug */,
+ 33CC10FA2044A3C60003C045 /* Release */,
+ 338D0CE9231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10FC2044A3C60003C045 /* Debug */,
+ 33CC10FD2044A3C60003C045 /* Release */,
+ 338D0CEA231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC111C2044C6BA0003C045 /* Debug */,
+ 33CC111D2044C6BA0003C045 /* Release */,
+ 338D0CEB231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 33CC10E52044A3C60003C045 /* Project object */;
+}
diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..4ccc2bf
--- /dev/null
+++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/macos/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift
new file mode 100644
index 0000000..8e02df2
--- /dev/null
+++ b/macos/Runner/AppDelegate.swift
@@ -0,0 +1,9 @@
+import Cocoa
+import FlutterMacOS
+
+@main
+class AppDelegate: FlutterAppDelegate {
+ override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return true
+ }
+}
diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..a2ec33f
--- /dev/null
+++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_16.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_64.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_128.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_1024.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
new file mode 100644
index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5
GIT binary patch
literal 102994
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