17 files changed
+1209
-395
ios/Runner.xcodeproj/project.pbxproj
+48
-48
@@ -10,13 +10,13 @@
10
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
11
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
12
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
13
- 4D35725BDD0DD5019A931DB2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */; };
13
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
14
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
15
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
16
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
17
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
19
- AD5D2C9F4682DFDBC0E7E5C1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */; };
18
+ C4C0F84E5F9C916D8FE832A1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */; };
19
+ FAEA42D7984B92E2DBCE269D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */; };
20
/* End PBXBuildFile section */
21
22
/* Begin PBXContainerItemProxy section */
@@ -43,18 +43,20 @@
43
/* End PBXCopyFilesBuildPhase section */
44
45
/* Begin PBXFileReference section */
46
+ 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
47
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
48
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
48
- 2E58D4AE73D0962B3323FB68 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
49
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
50
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
51
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
52
+ 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
53
+ 73AFEF529F048553B062B4E3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
54
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
55
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
56
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
57
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
58
7CC868A42FD2D68B0007F1A7 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
57
- 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
59
+ 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
60
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
61
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
62
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -62,12 +64,10 @@
64
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
65
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
66
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
65
- 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
66
- A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
67
- B01A7AB948A43BF5CF62AC78 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
68
- D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
69
- EB56E4A51F0E4D7E0193C01F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
70
- F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
67
+ 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
68
+ C6D853C36CD61AA682DF6E81 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
69
+ F3270ED4CE365DCA44881F78 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
70
+ FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
71
/* End PBXFileReference section */
72
73
/* Begin PBXFrameworksBuildPhase section */
@@ -75,7 +75,7 @@
75
isa = PBXFrameworksBuildPhase;
76
buildActionMask = 2147483647;
77
files = (
78
- AD5D2C9F4682DFDBC0E7E5C1 /* Pods_Runner.framework in Frameworks */,
78
+ FAEA42D7984B92E2DBCE269D /* Pods_Runner.framework in Frameworks */,
79
);
80
runOnlyForDeploymentPostprocessing = 0;
81
};
@@ -83,18 +83,18 @@
83
isa = PBXFrameworksBuildPhase;
84
buildActionMask = 2147483647;
85
files = (
86
- 4D35725BDD0DD5019A931DB2 /* Pods_RunnerTests.framework in Frameworks */,
86
+ C4C0F84E5F9C916D8FE832A1 /* Pods_RunnerTests.framework in Frameworks */,
87
);
88
runOnlyForDeploymentPostprocessing = 0;
89
};
90
/* End PBXFrameworksBuildPhase section */
91
92
/* Begin PBXGroup section */
93
- 1149248C99B08ECDAC23FF9D /* Frameworks */ = {
93
+ 0A7EDC9E473CF51F03159619 /* Frameworks */ = {
94
isa = PBXGroup;
95
children = (
96
- 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */,
97
- A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */,
96
+ FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */,
97
+ 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */,
98
);
99
name = Frameworks;
100
sourceTree = "<group>";
@@ -126,7 +126,7 @@
126
97C146EF1CF9000F007C117D /* Products */,
127
331C8082294A63A400263BE5 /* RunnerTests */,
128
B5F0F4932A555741EAC185FD /* Pods */,
129
- 1149248C99B08ECDAC23FF9D /* Frameworks */,
129
+ 0A7EDC9E473CF51F03159619 /* Frameworks */,
130
);
131
sourceTree = "<group>";
132
};
@@ -159,12 +159,12 @@
159
B5F0F4932A555741EAC185FD /* Pods */ = {
160
isa = PBXGroup;
161
children = (
162
- EB56E4A51F0E4D7E0193C01F /* Pods-Runner.debug.xcconfig */,
163
- 2E58D4AE73D0962B3323FB68 /* Pods-Runner.release.xcconfig */,
164
- B01A7AB948A43BF5CF62AC78 /* Pods-Runner.profile.xcconfig */,
165
- F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */,
166
- D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */,
167
- 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */,
162
+ F3270ED4CE365DCA44881F78 /* Pods-Runner.debug.xcconfig */,
163
+ C6D853C36CD61AA682DF6E81 /* Pods-Runner.release.xcconfig */,
164
+ 73AFEF529F048553B062B4E3 /* Pods-Runner.profile.xcconfig */,
165
+ 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */,
166
+ 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */,
167
+ 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */,
168
);
169
path = Pods;
170
sourceTree = "<group>";
@@ -176,7 +176,7 @@
176
isa = PBXNativeTarget;
177
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
178
buildPhases = (
179
- D2249D2FD0465B2C76420590 /* [CP] Check Pods Manifest.lock */,
179
+ DDFE1FA6C8EABD4B904564F6 /* [CP] Check Pods Manifest.lock */,
180
331C807D294A63A400263BE5 /* Sources */,
181
331C807F294A63A400263BE5 /* Resources */,
182
9AA24F4969F6FB2D761614D7 /* Frameworks */,
@@ -195,15 +195,15 @@
195
isa = PBXNativeTarget;
196
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
197
buildPhases = (
198
- FE21A8B6035F35A6E6A38E92 /* [CP] Check Pods Manifest.lock */,
198
+ CAD44E4886D762F8D9220F47 /* [CP] Check Pods Manifest.lock */,
199
9740EEB61CF901F6004384FC /* Run Script */,
200
97C146EA1CF9000F007C117D /* Sources */,
201
97C146EB1CF9000F007C117D /* Frameworks */,
202
97C146EC1CF9000F007C117D /* Resources */,
203
9705A1C41CF9048500538489 /* Embed Frameworks */,
204
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
205
- 1E56B4EBA88B451F7B385BA9 /* [CP] Embed Pods Frameworks */,
206
- D0F9B4752B25C3F455EAAB34 /* [CP] Copy Pods Resources */,
205
+ 231076B88C0FA832CC283DB1 /* [CP] Embed Pods Frameworks */,
206
+ 94ED4B90A2C8AEA5F8005A0F /* [CP] Copy Pods Resources */,
207
);
208
buildRules = (
209
);
@@ -275,7 +275,7 @@
275
/* End PBXResourcesBuildPhase section */
276
277
/* Begin PBXShellScriptBuildPhase section */
278
- 1E56B4EBA88B451F7B385BA9 /* [CP] Embed Pods Frameworks */ = {
278
+ 231076B88C0FA832CC283DB1 /* [CP] Embed Pods Frameworks */ = {
279
isa = PBXShellScriptBuildPhase;
280
buildActionMask = 2147483647;
281
files = (
@@ -308,39 +308,39 @@
308
shellPath = /bin/sh;
309
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
310
};
311
- 9740EEB61CF901F6004384FC /* Run Script */ = {
311
+ 94ED4B90A2C8AEA5F8005A0F /* [CP] Copy Pods Resources */ = {
312
isa = PBXShellScriptBuildPhase;
313
- alwaysOutOfDate = 1;
313
buildActionMask = 2147483647;
314
files = (
315
);
317
- inputPaths = (
316
+ inputFileListPaths = (
317
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
318
);
319
- name = "Run Script";
320
- outputPaths = (
319
+ name = "[CP] Copy Pods Resources";
320
+ outputFileListPaths = (
321
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
322
);
323
runOnlyForDeploymentPostprocessing = 0;
324
shellPath = /bin/sh;
324
- shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
325
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
326
+ showEnvVarsInLog = 0;
327
};
326
- D0F9B4752B25C3F455EAAB34 /* [CP] Copy Pods Resources */ = {
328
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
329
isa = PBXShellScriptBuildPhase;
330
+ alwaysOutOfDate = 1;
331
buildActionMask = 2147483647;
332
files = (
333
);
331
- inputFileListPaths = (
332
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
334
+ inputPaths = (
335
);
334
- name = "[CP] Copy Pods Resources";
335
- outputFileListPaths = (
336
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
336
+ name = "Run Script";
337
+ outputPaths = (
338
);
339
runOnlyForDeploymentPostprocessing = 0;
340
shellPath = /bin/sh;
340
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
341
- showEnvVarsInLog = 0;
341
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
342
};
343
- D2249D2FD0465B2C76420590 /* [CP] Check Pods Manifest.lock */ = {
343
+ CAD44E4886D762F8D9220F47 /* [CP] Check Pods Manifest.lock */ = {
344
isa = PBXShellScriptBuildPhase;
345
buildActionMask = 2147483647;
346
files = (
@@ -355,14 +355,14 @@
355
outputFileListPaths = (
356
);
357
outputPaths = (
358
- "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
358
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
359
);
360
runOnlyForDeploymentPostprocessing = 0;
361
shellPath = /bin/sh;
362
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
363
showEnvVarsInLog = 0;
364
};
365
- FE21A8B6035F35A6E6A38E92 /* [CP] Check Pods Manifest.lock */ = {
365
+ DDFE1FA6C8EABD4B904564F6 /* [CP] Check Pods Manifest.lock */ = {
366
isa = PBXShellScriptBuildPhase;
367
buildActionMask = 2147483647;
368
files = (
@@ -377,7 +377,7 @@
377
outputFileListPaths = (
378
);
379
outputPaths = (
380
- "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
380
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
381
);
382
runOnlyForDeploymentPostprocessing = 0;
383
shellPath = /bin/sh;
@@ -512,7 +512,7 @@
512
};
513
331C8088294A63A400263BE5 /* Debug */ = {
514
isa = XCBuildConfiguration;
515
- baseConfigurationReference = F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */;
515
+ baseConfigurationReference = 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */;
516
buildSettings = {
517
BUNDLE_LOADER = "$(TEST_HOST)";
518
CODE_SIGN_STYLE = Automatic;
@@ -530,7 +530,7 @@
530
};
531
331C8089294A63A400263BE5 /* Release */ = {
532
isa = XCBuildConfiguration;
533
- baseConfigurationReference = D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */;
533
+ baseConfigurationReference = 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */;
534
buildSettings = {
535
BUNDLE_LOADER = "$(TEST_HOST)";
536
CODE_SIGN_STYLE = Automatic;
@@ -546,7 +546,7 @@
546
};
547
331C808A294A63A400263BE5 /* Profile */ = {
548
isa = XCBuildConfiguration;
549
- baseConfigurationReference = 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */;
549
+ baseConfigurationReference = 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */;
550
buildSettings = {
551
BUNDLE_LOADER = "$(TEST_HOST)";
552
CODE_SIGN_STYLE = Automatic;
lib/core/services/chat_background_service.dart
+7
-2
@@ -1,3 +1,4 @@
1
+import 'dart:io';
2
import 'package:flutter/services.dart';
3
import 'package:wakelock_plus/wakelock_plus.dart';
4
import '../logger/app_logger.dart';
@@ -10,7 +11,9 @@ class ChatBackgroundService {
11
if (_isActive) return;
12
try {
13
Log.info('Starting background chat service');
13
- await _channel.invokeMethod('startForeground');
14
+ if (Platform.isAndroid) {
15
+ await _channel.invokeMethod('startForeground');
16
+ }
17
await WakelockPlus.enable();
18
_isActive = true;
19
} catch (e) {
@@ -22,7 +25,9 @@ class ChatBackgroundService {
25
if (!_isActive) return;
26
try {
27
Log.info('Stopping background chat service');
25
- await _channel.invokeMethod('stopForeground');
28
+ if (Platform.isAndroid) {
29
+ await _channel.invokeMethod('stopForeground');
30
+ }
31
await WakelockPlus.disable();
32
_isActive = false;
33
} catch (e) {
lib/features/chat/data/chat_service.dart
+29
-2
@@ -7,6 +7,7 @@ import '../../servers/data/models/server.dart';
7
import 'models/message.dart';
8
import 'models/chat_parameters.dart';
9
import 'models/mcp_integration.dart';
10
+import 'tools/tool_definition.dart';
11
import '../../../core/models/enums.dart';
12
import '../../../core/logger/app_logger.dart';
13
import '../../on_device/data/on_device_gemma_service.dart';
@@ -23,6 +24,7 @@ abstract class ChatService {
24
required List<Message> messages,
25
required ChatParameters params,
26
List<McpIntegration>? integrations,
27
+ List<ToolDefinition>? tools,
28
String? previousResponseId,
29
});
30
@@ -151,6 +153,7 @@ class LMStudioChatService implements ChatService {
153
required List<Message> messages,
154
required ChatParameters params,
155
List<McpIntegration>? integrations,
156
+ List<ToolDefinition>? tools,
157
String? previousResponseId,
158
}) async* {
159
_cancelToken = CancelToken();
@@ -457,10 +460,11 @@ class OpenAICompatibleChatService implements ChatService {
460
required List<Message> messages,
461
required ChatParameters params,
462
List<McpIntegration>? integrations,
463
+ List<ToolDefinition>? tools,
464
String? previousResponseId,
465
}) async* {
466
_cancelToken = CancelToken();
463
- final toolAdapter = OllamaToolAdapter();
467
+ final toolAdapter = OpenAiToolAdapter();
468
469
final apiMessages = messages
470
.map((m) => {'role': _roleToString(m.role), 'content': m.content})
@@ -483,6 +487,13 @@ class OpenAICompatibleChatService implements ChatService {
487
'stream': true,
488
};
489
490
+ if (tools != null && tools.isNotEmpty) {
491
+ final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
492
+ if (toolsPayload.containsKey('tools')) {
493
+ body['tools'] = toolsPayload['tools'];
494
+ }
495
+ }
496
+
497
Log.debug(
498
'OpenAICompatible: Sending request to ${server.chatEndpoint} with model: $modelId',
499
);
@@ -661,10 +672,11 @@ class OllamaChatService implements ChatService {
672
required List<Message> messages,
673
required ChatParameters params,
674
List<McpIntegration>? integrations,
675
+ List<ToolDefinition>? tools,
676
String? previousResponseId,
677
}) async* {
678
_cancelToken = CancelToken();
667
- final toolAdapter = OpenAiToolAdapter();
679
+ final toolAdapter = OllamaToolAdapter();
680
681
final body = {
682
'model': modelId,
@@ -679,6 +691,13 @@ class OllamaChatService implements ChatService {
691
},
692
};
693
694
+ if (tools != null && tools.isNotEmpty) {
695
+ final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
696
+ if (toolsPayload.containsKey('tools')) {
697
+ body['tools'] = toolsPayload['tools'];
698
+ }
699
+ }
700
+
701
try {
702
final response = await _dio.post<ResponseBody>(
703
server.chatEndpoint,
@@ -768,6 +787,7 @@ class OpenRouterChatService implements ChatService {
787
required List<Message> messages,
788
required ChatParameters params,
789
List<McpIntegration>? integrations,
790
+ List<ToolDefinition>? tools,
791
String? previousResponseId,
792
}) async* {
793
_cancelToken = CancelToken();
@@ -784,6 +804,13 @@ class OpenRouterChatService implements ChatService {
804
'stream': true,
805
};
806
807
+ if (tools != null && tools.isNotEmpty) {
808
+ final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
809
+ if (toolsPayload.containsKey('tools')) {
810
+ body['tools'] = toolsPayload['tools'];
811
+ }
812
+ }
813
+
814
Log.debug(
815
'OpenRouter: Sending request to ${server.chatEndpoint} with model: $modelId',
816
);
lib/features/chat/data/mcp_client.dart
+265
-61
@@ -1,6 +1,8 @@
1
import 'dart:async';
2
+import 'dart:convert';
3
4
import 'package:dio/dio.dart';
5
+import 'package:localmind/core/logger/app_logger.dart';
6
7
class McpClient {
8
final String serverUrl;
@@ -9,37 +11,246 @@ class McpClient {
11
bool _initialized = false;
12
McpCapabilities? _capabilities;
13
14
+ bool _useSse = false;
15
+ StreamSubscription? _sseSubscription;
16
+ String? _postUrl;
17
+ final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
18
+ int _requestIdCounter = 1;
19
+
20
McpClient({required this.serverUrl, this.headers, Dio? dio})
21
: _dio = dio ?? Dio();
22
23
bool get isInitialized => _initialized;
24
McpCapabilities? get capabilities => _capabilities;
25
26
+ Future<void> _connectSse() async {
27
+ try {
28
+ final response = await _dio.get<ResponseBody>(
29
+ serverUrl,
30
+ options: Options(
31
+ headers: {
32
+ 'Accept': 'text/event-stream',
33
+ 'Cache-Control': 'no-cache',
34
+ ...?headers,
35
+ },
36
+ responseType: ResponseType.stream,
37
+ ),
38
+ );
39
+
40
+ final contentType = response.headers.value('content-type') ?? '';
41
+ if (!contentType.contains('text/event-stream') && !serverUrl.contains('sse')) {
42
+ // Not an SSE stream endpoint, fallback to direct HTTP POST
43
+ return;
44
+ }
45
+
46
+ _useSse = true;
47
+ final handshakeCompleter = Completer<void>();
48
+
49
+ final stream = response.data!.stream.cast<List<int>>().transform(utf8.decoder);
50
+ String buffer = '';
51
+ String currentEvent = 'message';
52
+ String currentData = '';
53
+
54
+ _sseSubscription = stream.listen(
55
+ (chunk) {
56
+ buffer += chunk;
57
+ final lines = buffer.split('\n');
58
+ buffer = lines.removeLast();
59
+
60
+ for (final line in lines) {
61
+ final trimmed = line.trim();
62
+ if (trimmed.isEmpty) {
63
+ if (currentData.isNotEmpty) {
64
+ _handleSseMessage(currentEvent, currentData, handshakeCompleter);
65
+ currentEvent = 'message';
66
+ currentData = '';
67
+ }
68
+ continue;
69
+ }
70
+
71
+ if (trimmed.startsWith('event:')) {
72
+ currentEvent = trimmed.substring(6).trim();
73
+ } else if (trimmed.startsWith('data:')) {
74
+ final val = trimmed.substring(5).trim();
75
+ if (currentData.isNotEmpty) {
76
+ currentData += '\n$val';
77
+ } else {
78
+ currentData = val;
79
+ }
80
+ }
81
+ }
82
+ },
83
+ onError: (e) {
84
+ if (!handshakeCompleter.isCompleted) {
85
+ handshakeCompleter.completeError(e);
86
+ }
87
+ _handleSseDisconnect(e);
88
+ },
89
+ onDone: () {
90
+ if (!handshakeCompleter.isCompleted) {
91
+ handshakeCompleter.completeError(McpException('SSE stream closed before handshake completed'));
92
+ }
93
+ _handleSseDisconnect(null);
94
+ },
95
+ );
96
+
97
+ // Wait for the endpoint event or timeout
98
+ await handshakeCompleter.future.timeout(
99
+ const Duration(seconds: 10),
100
+ onTimeout: () {
101
+ throw McpException('Handshake timeout waiting for SSE endpoint event');
102
+ },
103
+ );
104
+ } catch (e) {
105
+ await _closeSse();
106
+ _useSse = false;
107
+ rethrow;
108
+ }
109
+ }
110
+
111
+ void _handleSseMessage(String event, String data, Completer<void> initCompleter) {
112
+ if (event == 'endpoint') {
113
+ try {
114
+ final baseUri = Uri.parse(serverUrl);
115
+ final resolvedUri = baseUri.resolve(data);
116
+ _postUrl = resolvedUri.toString();
117
+ Log.debug('MCP SSE Handshake completed. Message endpoint: $_postUrl');
118
+ if (!initCompleter.isCompleted) {
119
+ initCompleter.complete();
120
+ }
121
+ } catch (e) {
122
+ if (!initCompleter.isCompleted) {
123
+ initCompleter.completeError(e);
124
+ }
125
+ }
126
+ } else {
127
+ try {
128
+ final json = jsonDecode(data);
129
+ if (json is Map<String, dynamic>) {
130
+ final id = json['id'];
131
+ if (id != null) {
132
+ final key = id.toString();
133
+ final completer = _pendingRequests.remove(key);
134
+ if (completer != null && !completer.isCompleted) {
135
+ completer.complete(json);
136
+ }
137
+ }
138
+ }
139
+ } catch (_) {}
140
+ }
141
+ }
142
+
143
+ void _handleSseDisconnect(dynamic error) {
144
+ final ex = McpException(error != null ? 'SSE disconnected: $error' : 'SSE connection closed');
145
+ for (final completer in _pendingRequests.values) {
146
+ if (!completer.isCompleted) {
147
+ completer.completeError(ex);
148
+ }
149
+ }
150
+ _pendingRequests.clear();
151
+ _initialized = false;
152
+ }
153
+
154
+ Future<void> _closeSse() async {
155
+ await _sseSubscription?.cancel();
156
+ _sseSubscription = null;
157
+ _postUrl = null;
158
+ }
159
+
160
+ Future<Map<String, dynamic>> _sendJsonRpc(String method, Map<String, dynamic> params) async {
161
+ final id = _requestIdCounter++;
162
+ final payload = {
163
+ 'jsonrpc': '2.0',
164
+ 'id': id,
165
+ 'method': method,
166
+ 'params': params,
167
+ };
168
+
169
+ if (_useSse) {
170
+ if (_postUrl == null) {
171
+ throw McpException('SSE transport initialized but POST endpoint is missing');
172
+ }
173
+
174
+ final completer = Completer<Map<String, dynamic>>();
175
+ _pendingRequests[id.toString()] = completer;
176
+
177
+ try {
178
+ await _dio.post(
179
+ _postUrl!,
180
+ data: payload,
181
+ options: Options(
182
+ headers: {'Content-Type': 'application/json', ...?headers},
183
+ ),
184
+ );
185
+ } catch (e) {
186
+ _pendingRequests.remove(id.toString());
187
+ throw McpException('Failed to post message over SSE transport: $e');
188
+ }
189
+
190
+ final responseJson = await completer.future.timeout(
191
+ const Duration(seconds: 30),
192
+ onTimeout: () {
193
+ _pendingRequests.remove(id.toString());
194
+ throw McpException('Timeout waiting for JSON-RPC response over SSE transport');
195
+ },
196
+ );
197
+
198
+ if (responseJson.containsKey('error')) {
199
+ final error = responseJson['error'];
200
+ final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
201
+ throw McpException('Server returned error: $message');
202
+ }
203
+
204
+ return responseJson;
205
+ } else {
206
+ try {
207
+ final response = await _dio.post(
208
+ serverUrl,
209
+ data: payload,
210
+ options: Options(
211
+ headers: {'Content-Type': 'application/json', ...?headers},
212
+ ),
213
+ );
214
+
215
+ final responseData = response.data;
216
+ if (responseData is! Map<String, dynamic>) {
217
+ throw McpException('Invalid JSON-RPC response payload format');
218
+ }
219
+
220
+ if (responseData.containsKey('error')) {
221
+ final error = responseData['error'];
222
+ final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
223
+ throw McpException('Server returned error: $message');
224
+ }
225
+
226
+ return responseData;
227
+ } catch (e) {
228
+ throw McpException('Failed to send JSON-RPC request over HTTP: $e');
229
+ }
230
+ }
231
+ }
232
+
233
Future<McpCapabilities> initialize() async {
234
if (_initialized && _capabilities != null) {
235
return _capabilities!;
236
}
237
238
try {
24
- final response = await _dio.post(
25
- serverUrl,
26
- data: {
27
- 'jsonrpc': '2.0',
28
- 'id': 1,
29
- 'method': 'initialize',
30
- 'params': {
31
- 'protocolVersion': '2024-11-05',
32
- 'capabilities': {},
33
- 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
34
- },
35
- },
36
- options: Options(
37
- headers: {'Content-Type': 'application/json', ...?headers},
38
- ),
39
- );
239
+ try {
240
+ await _connectSse();
241
+ } catch (e) {
242
+ Log.debug('Failed to connect via SSE: $e. Falling back to direct HTTP POST.');
243
+ _useSse = false;
244
+ }
245
+
246
+ final response = await _sendJsonRpc('initialize', {
247
+ 'protocolVersion': '2024-11-05',
248
+ 'capabilities': {},
249
+ 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
250
+ });
251
252
_capabilities = McpCapabilities.fromJson(
42
- response.data['result']['capabilities'] as Map<String, dynamic>,
253
+ response['result']['capabilities'] as Map<String, dynamic>,
254
);
255
_initialized = true;
256
@@ -53,15 +264,31 @@ class McpClient {
264
265
Future<void> _sendInitializedNotification() async {
266
try {
56
- await _dio.post(
57
- serverUrl,
58
- data: {
59
- 'jsonrpc': '2.0',
60
- 'method': 'notifications/initialized',
61
- 'params': {},
62
- },
63
- options: Options(headers: {'Content-Type': 'application/json'}),
64
- );
267
+ final payload = {
268
+ 'jsonrpc': '2.0',
269
+ 'method': 'notifications/initialized',
270
+ 'params': {},
271
+ };
272
+
273
+ if (_useSse) {
274
+ if (_postUrl != null) {
275
+ await _dio.post(
276
+ _postUrl!,
277
+ data: payload,
278
+ options: Options(
279
+ headers: {'Content-Type': 'application/json', ...?headers},
280
+ ),
281
+ );
282
+ }
283
+ } else {
284
+ await _dio.post(
285
+ serverUrl,
286
+ data: payload,
287
+ options: Options(
288
+ headers: {'Content-Type': 'application/json', ...?headers},
289
+ ),
290
+ );
291
+ }
292
} catch (_) {}
293
}
294
@@ -71,12 +298,9 @@ class McpClient {
298
}
299
300
try {
74
- final response = await _dio.post(
75
- serverUrl,
76
- data: {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list', 'params': {}},
77
- );
301
+ final response = await _sendJsonRpc('tools/list', {});
302
79
- final tools = response.data['result']['tools'] as List?;
303
+ final tools = response['result']['tools'] as List?;
304
if (tools == null) return [];
305
306
return tools
@@ -93,17 +317,12 @@ class McpClient {
317
}
318
319
try {
96
- final response = await _dio.post(
97
- serverUrl,
98
- data: {
99
- 'jsonrpc': '2.0',
100
- 'id': 3,
101
- 'method': 'tools/call',
102
- 'params': {'name': name, 'arguments': arguments},
103
- },
104
- );
320
+ final response = await _sendJsonRpc('tools/call', {
321
+ 'name': name,
322
+ 'arguments': arguments,
323
+ });
324
106
- final result = response.data['result'];
325
+ final result = response['result'];
326
if (result == null) {
327
throw McpException('Tool call returned no result');
328
}
@@ -126,17 +345,9 @@ class McpClient {
345
}
346
347
try {
129
- final response = await _dio.post(
130
- serverUrl,
131
- data: {
132
- 'jsonrpc': '2.0',
133
- 'id': 4,
134
- 'method': 'resources/list',
135
- 'params': {},
136
- },
137
- );
348
+ final response = await _sendJsonRpc('resources/list', {});
349
139
- final resources = response.data['result']['resources'] as List?;
350
+ final resources = response['result']['resources'] as List?;
351
if (resources == null) return [];
352
353
return resources
@@ -153,17 +364,9 @@ class McpClient {
364
}
365
366
try {
156
- final response = await _dio.post(
157
- serverUrl,
158
- data: {
159
- 'jsonrpc': '2.0',
160
- 'id': 5,
161
- 'method': 'resources/read',
162
- 'params': {'uri': uri},
163
- },
164
- );
367
+ final response = await _sendJsonRpc('resources/read', {'uri': uri});
368
166
- final contents = response.data['result']['contents'] as List?;
369
+ final contents = response['result']['contents'] as List?;
370
if (contents == null || contents.isEmpty) {
371
return '';
372
}
@@ -175,6 +378,7 @@ class McpClient {
378
}
379
380
Future<void> close() async {
381
+ await _closeSse();
382
_initialized = false;
383
_capabilities = null;
384
}
lib/features/chat/data/models/mcp_integration.dart
-6
@@ -71,26 +71,22 @@ class McpIntegration {
71
72
class ChatMcpConfig {
73
final List<McpIntegration> integrations;
74
- final bool autoExecuteTools;
74
final Map<String, String> activeMcpServers;
75
final bool enabled;
76
77
const ChatMcpConfig({
78
this.integrations = const [],
80
- this.autoExecuteTools = true,
79
this.activeMcpServers = const {},
80
this.enabled = true,
81
});
82
83
ChatMcpConfig copyWith({
84
List<McpIntegration>? integrations,
87
- bool? autoExecuteTools,
85
Map<String, String>? activeMcpServers,
86
bool? enabled,
87
}) {
88
return ChatMcpConfig(
89
integrations: integrations ?? this.integrations,
93
- autoExecuteTools: autoExecuteTools ?? this.autoExecuteTools,
90
activeMcpServers: activeMcpServers ?? this.activeMcpServers,
91
enabled: enabled ?? this.enabled,
92
);
@@ -99,7 +95,6 @@ class ChatMcpConfig {
95
Map<String, dynamic> toJson() {
96
return {
97
'integrations': integrations.map((i) => i.toJson()).toList(),
102
- 'autoExecuteTools': autoExecuteTools,
98
'activeMcpServers': activeMcpServers,
99
};
100
}
@@ -111,7 +106,6 @@ class ChatMcpConfig {
106
?.map((i) => McpIntegration.fromJson(i as Map<String, dynamic>))
107
.toList() ??
108
[],
114
- autoExecuteTools: json['autoExecuteTools'] as bool? ?? true,
109
activeMcpServers:
110
(json['activeMcpServers'] as Map?)?.cast<String, String>() ?? {},
111
enabled: json['enabled'] as bool? ?? true,
lib/features/chat/providers/chat_mcp_providers.dart
+23
-3
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2
import '../../../core/providers/app_providers.dart';
3
import '../data/models/mcp_integration.dart';
4
import '../../conversations/providers/conversation_providers.dart';
5
+import 'tooling_providers.dart';
6
7
export '../data/models/mcp_integration.dart';
8
@@ -15,6 +16,17 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
16
17
void setConfig(ChatMcpConfig config) {
18
state = config;
19
+ final manager = ref.read(mcpServerManagerProvider);
20
+ manager.clear();
21
+ for (final integration in config.integrations) {
22
+ if (integration.serverLabel != null && integration.serverUrl != null) {
23
+ manager.addServer(
24
+ integration.serverLabel!,
25
+ integration.serverUrl!,
26
+ headers: integration.headers,
27
+ ).catchError((_) {});
28
+ }
29
+ }
30
}
31
32
void setEnabled(bool enabled) {
@@ -39,6 +51,13 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
51
}
52
: state.activeMcpServers,
53
);
54
+ if (integration.serverLabel != null && integration.serverUrl != null) {
55
+ ref.read(mcpServerManagerProvider).addServer(
56
+ integration.serverLabel!,
57
+ integration.serverUrl!,
58
+ headers: integration.headers,
59
+ ).catchError((_) {});
60
+ }
61
}
62
63
void removeIntegration(int index) {
@@ -53,11 +72,11 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
72
integrations: newIntegrations,
73
activeMcpServers: newServers,
74
);
75
+ if (integration.serverLabel != null) {
76
+ ref.read(mcpServerManagerProvider).removeServer(integration.serverLabel!);
77
+ }
78
}
79
58
- void toggleAutoExecute() {
59
- state = state.copyWith(autoExecuteTools: !state.autoExecuteTools);
60
- }
80
81
void toggleEnabled() {
82
state = state.copyWith(enabled: !state.enabled);
@@ -66,6 +85,7 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
85
void clearAll() {
86
final settings = ref.read(settingsProvider);
87
state = ChatMcpConfig(enabled: settings.newChatMcpEnabled);
88
+ ref.read(mcpServerManagerProvider).clear();
89
}
90
}
91
lib/features/chat/providers/chat_providers.dart
+24
-19
@@ -26,6 +26,7 @@ import '../data/chat_service.dart';
26
import '../data/models/chat_parameters.dart';
27
import '../data/models/message.dart' hide ToolCallData;
28
import '../data/smart_reply_service.dart';
29
+import '../data/tools/tool_definition.dart';
30
import '../data/tools/tool_execution_loop.dart';
31
import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
32
import '../../../core/providers/chat_background_service_provider.dart';
@@ -734,6 +735,11 @@ class ChatNotifier extends Notifier<ChatState> {
735
? mcpConfig.integrations
736
: null;
737
738
+ final registry = ref.read(toolRegistryProvider);
739
+ final tools = mcpConfig.enabled
740
+ ? await registry.listTools()
741
+ : const <ToolDefinition>[];
742
+
743
final collectedToolCalls = <ToolCallData>[];
744
745
bool stateNeedsUpdate = false;
@@ -761,6 +767,7 @@ class ChatNotifier extends Notifier<ChatState> {
767
messages: messagesForApi,
768
params: chatParams,
769
integrations: integrations,
770
+ tools: tools,
771
)
772
.listen(
773
(response) async {
@@ -923,25 +930,23 @@ class ChatNotifier extends Notifier<ChatState> {
930
final loop = ToolExecutionLoop(
931
adapter: adapter,
932
registry: registry,
926
- onRequestApproval: mcpConfig.autoExecuteTools
927
- ? null
928
- : (call) async {
929
- final completer = Completer<bool>();
930
- state = state.copyWith(
931
- pendingToolApproval: PendingToolApproval(
932
- toolCall: call,
933
- completer: completer,
934
- ),
935
- );
936
-
937
- final result = await completer.future;
938
-
939
- state = state.copyWith(
940
- clearPendingApproval: true,
941
- );
942
-
943
- return result;
944
- },
933
+ onRequestApproval: (call) async {
934
+ final completer = Completer<bool>();
935
+ state = state.copyWith(
936
+ pendingToolApproval: PendingToolApproval(
937
+ toolCall: call,
938
+ completer: completer,
939
+ ),
940
+ );
941
+
942
+ final result = await completer.future;
943
+
944
+ state = state.copyWith(
945
+ clearPendingApproval: true,
946
+ );
947
+
948
+ return result;
949
+ },
950
);
951
952
final loopResult = await loop.run(
lib/features/chat/views/components/chat_bubble.dart
+445
-135
@@ -1,4 +1,5 @@
1
import 'dart:async';
2
+import 'dart:convert';
3
import 'dart:io';
4
import 'package:flutter/material.dart';
5
import 'package:gpt_markdown/gpt_markdown.dart';
@@ -691,75 +692,306 @@ class _ToolBubble extends StatelessWidget {
692
}
693
}
694
695
+class _AnimatedRunningIcon extends StatefulWidget {
696
+ const _AnimatedRunningIcon({required this.color});
697
+ final Color color;
698
+
699
+ @override
700
+ State<_AnimatedRunningIcon> createState() => _AnimatedRunningIconState();
701
+}
702
+
703
+class _AnimatedRunningIconState extends State<_AnimatedRunningIcon>
704
+ with SingleTickerProviderStateMixin {
705
+ late AnimationController _controller;
706
+
707
+ @override
708
+ void initState() {
709
+ super.initState();
710
+ _controller = AnimationController(
711
+ vsync: this,
712
+ duration: const Duration(seconds: 2),
713
+ )..repeat();
714
+ }
715
+
716
+ @override
717
+ void dispose() {
718
+ _controller.dispose();
719
+ super.dispose();
720
+ }
721
+
722
+ @override
723
+ Widget build(BuildContext context) {
724
+ return RotationTransition(
725
+ turns: _controller,
726
+ child: Icon(
727
+ Icons.cached_rounded,
728
+ size: 14,
729
+ color: widget.color,
730
+ ),
731
+ );
732
+ }
733
+}
734
+
735
+class _GroupedToolCall {
736
+ final String baseId;
737
+ final String toolName;
738
+ final ToolProviderType providerType;
739
+ final String? providerRef;
740
+ final Map<String, dynamic>? arguments;
741
+ final ToolEventStatus status;
742
+ final String? result;
743
+ final String? error;
744
+ final int? durationMs;
745
+ final DateTime timestamp;
746
+
747
+ const _GroupedToolCall({
748
+ required this.baseId,
749
+ required this.toolName,
750
+ required this.providerType,
751
+ this.providerRef,
752
+ this.arguments,
753
+ required this.status,
754
+ this.result,
755
+ this.error,
756
+ this.durationMs,
757
+ required this.timestamp,
758
+ });
759
+}
760
+
761
class _ToolTimeline extends StatelessWidget {
762
const _ToolTimeline({required this.events});
763
final List<ToolEvent> events;
764
765
+ List<_GroupedToolCall> _groupEvents(List<ToolEvent> events) {
766
+ final Map<String, _GroupedToolCall> groups = {};
767
+ final List<String> orderedBaseIds = [];
768
+
769
+ for (final event in events) {
770
+ final dotIndex = event.eventId.lastIndexOf('.');
771
+ final baseId = dotIndex != -1 ? event.eventId.substring(0, dotIndex) : event.eventId;
772
+
773
+ if (!orderedBaseIds.contains(baseId)) {
774
+ orderedBaseIds.add(baseId);
775
+ }
776
+
777
+ final existing = groups[baseId];
778
+ if (existing == null) {
779
+ groups[baseId] = _GroupedToolCall(
780
+ baseId: baseId,
781
+ toolName: event.toolName,
782
+ providerType: event.providerType,
783
+ providerRef: event.providerRef,
784
+ arguments: event.arguments,
785
+ status: event.status,
786
+ result: event.result,
787
+ error: event.error,
788
+ durationMs: event.durationMs,
789
+ timestamp: event.timestamp,
790
+ );
791
+ } else {
792
+ groups[baseId] = _GroupedToolCall(
793
+ baseId: baseId,
794
+ toolName: event.toolName,
795
+ providerType: event.providerType,
796
+ providerRef: event.providerRef,
797
+ arguments: event.arguments ?? existing.arguments,
798
+ status: event.status,
799
+ result: event.result ?? existing.result,
800
+ error: event.error ?? existing.error,
801
+ durationMs: event.durationMs ?? existing.durationMs,
802
+ timestamp: event.timestamp,
803
+ );
804
+ }
805
+ }
806
+
807
+ return orderedBaseIds.map((id) => groups[id]!).toList();
808
+ }
809
+
810
+ String _formatArgs(Map<String, dynamic> args) {
811
+ if (args.isEmpty) return '';
812
+ try {
813
+ final encoder = JsonEncoder.withIndent(' ');
814
+ return encoder.convert(args);
815
+ } catch (_) {
816
+ return args.entries.map((e) => ' ${e.key}: ${e.value}').join('\n');
817
+ }
818
+ }
819
+
820
@override
821
Widget build(BuildContext context) {
822
final theme = Theme.of(context);
823
final isDark = theme.brightness == Brightness.dark;
824
+ final groupedCalls = _groupEvents(events);
825
+
826
+ if (groupedCalls.isEmpty) {
827
+ return const SizedBox.shrink();
828
+ }
829
830
return Container(
831
+ margin: const EdgeInsets.only(top: 8, bottom: 4),
832
decoration: BoxDecoration(
705
- color: isDark ? const Color(0xFF1E293B) : const Color(0xFFF8FAFC),
706
- borderRadius: BorderRadius.circular(8),
833
+ color: isDark
834
+ ? const Color(0xFF1E1E2E).withValues(alpha: 0.6)
835
+ : const Color(0xFFF5F7FF).withValues(alpha: 0.8),
836
+ borderRadius: BorderRadius.circular(12),
837
border: Border.all(
708
- color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
838
+ color: isDark
839
+ ? const Color(0xFF313244).withValues(alpha: 0.8)
840
+ : const Color(0xFFE0E5F5),
841
),
842
+ boxShadow: [
843
+ BoxShadow(
844
+ color: Colors.black.withValues(alpha: isDark ? 0.1 : 0.02),
845
+ blurRadius: 8,
846
+ offset: const Offset(0, 4),
847
+ ),
848
+ ],
849
),
850
child: Column(
851
crossAxisAlignment: CrossAxisAlignment.start,
852
children: [
714
- Padding(
715
- padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
853
+ // Header Bar
854
+ Container(
855
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
856
+ decoration: BoxDecoration(
857
+ border: Border(
858
+ bottom: BorderSide(
859
+ color: isDark
860
+ ? const Color(0xFF313244).withValues(alpha: 0.5)
861
+ : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
862
+ ),
863
+ ),
864
+ ),
865
child: Row(
866
children: [
867
Icon(
719
- Icons.troubleshoot,
720
- size: 14,
868
+ Icons.terminal_rounded,
869
+ size: 16,
870
color: isDark
722
- ? const Color(0xFF94A3B8)
723
- : const Color(0xFF64748B),
871
+ ? const Color(0xFFBAC2DE)
872
+ : const Color(0xFF585B70),
873
),
725
- const SizedBox(width: 4),
874
+ const SizedBox(width: 8),
875
Text(
727
- 'Tools',
876
+ 'SYSTEM ACTIONS',
877
style: TextStyle(
878
fontSize: 11,
730
- fontWeight: FontWeight.w600,
879
+ fontWeight: FontWeight.bold,
880
+ letterSpacing: 0.5,
881
color: isDark
732
- ? const Color(0xFF94A3B8)
733
- : const Color(0xFF64748B),
882
+ ? const Color(0xFFBAC2DE)
883
+ : const Color(0xFF585B70),
884
+ ),
885
+ ),
886
+ const Spacer(),
887
+ Container(
888
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
889
+ decoration: BoxDecoration(
890
+ color: isDark ? const Color(0xFF313244) : const Color(0xFFE0E5F5),
891
+ borderRadius: BorderRadius.circular(6),
892
+ ),
893
+ child: Text(
894
+ '${groupedCalls.length} ${groupedCalls.length == 1 ? "action" : "actions"}',
895
+ style: TextStyle(
896
+ fontSize: 9,
897
+ fontWeight: FontWeight.bold,
898
+ color: isDark ? Colors.white70 : Colors.black54,
899
+ ),
900
),
901
),
902
],
903
),
904
),
739
- ...events.map((event) => _buildEventRow(context, event, isDark)),
905
+ const SizedBox(height: 4),
906
+ // Grouped tool calls
907
+ Padding(
908
+ padding: const EdgeInsets.symmetric(vertical: 4),
909
+ child: ListView.separated(
910
+ shrinkWrap: true,
911
+ physics: const NeverScrollableScrollPhysics(),
912
+ itemCount: groupedCalls.length,
913
+ separatorBuilder: (context, index) => Padding(
914
+ padding: const EdgeInsets.symmetric(vertical: 6),
915
+ child: Divider(
916
+ height: 1,
917
+ thickness: 1,
918
+ color: isDark
919
+ ? const Color(0xFF313244).withValues(alpha: 0.3)
920
+ : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
921
+ ),
922
+ ),
923
+ itemBuilder: (context, index) => _ToolRowWidget(
924
+ call: groupedCalls[index],
925
+ isDark: isDark,
926
+ formatArgs: _formatArgs,
927
+ ),
928
+ ),
929
+ ),
930
+ const SizedBox(height: 4),
931
],
932
),
933
);
934
}
935
+}
936
+
937
+class _ToolRowWidget extends StatefulWidget {
938
+ const _ToolRowWidget({
939
+ required this.call,
940
+ required this.isDark,
941
+ required this.formatArgs,
942
+ });
943
+
944
+ final _GroupedToolCall call;
945
+ final bool isDark;
946
+ final String Function(Map<String, dynamic>) formatArgs;
947
+
948
+ @override
949
+ State<_ToolRowWidget> createState() => _ToolRowWidgetState();
950
+}
951
+
952
+class _ToolRowWidgetState extends State<_ToolRowWidget> {
953
+ late bool _isExpanded;
954
+
955
+ @override
956
+ void initState() {
957
+ super.initState();
958
+ _isExpanded = widget.call.status == ToolEventStatus.running ||
959
+ widget.call.status == ToolEventStatus.failed ||
960
+ widget.call.status == ToolEventStatus.requested ||
961
+ widget.call.status == ToolEventStatus.approved;
962
+ }
963
745
- Widget _buildEventRow(BuildContext context, ToolEvent event, bool isDark) {
746
- final iconData = switch (event.status) {
747
- ToolEventStatus.requested => Icons.hourglass_empty,
748
- ToolEventStatus.approved => Icons.check_circle_outline,
749
- ToolEventStatus.rejected => Icons.cancel_outlined,
750
- ToolEventStatus.running => Icons.play_circle_outline,
751
- ToolEventStatus.completed => Icons.check_circle,
752
- ToolEventStatus.failed => Icons.error_outline,
753
- };
754
- final iconColor = switch (event.status) {
964
+ @override
965
+ void didUpdateWidget(_ToolRowWidget oldWidget) {
966
+ super.didUpdateWidget(oldWidget);
967
+ if (oldWidget.call.status != widget.call.status) {
968
+ if (widget.call.status == ToolEventStatus.running ||
969
+ widget.call.status == ToolEventStatus.failed ||
970
+ widget.call.status == ToolEventStatus.requested ||
971
+ widget.call.status == ToolEventStatus.approved) {
972
+ setState(() {
973
+ _isExpanded = true;
974
+ });
975
+ }
976
+ }
977
+ }
978
+
979
+ @override
980
+ Widget build(BuildContext context) {
981
+ final call = widget.call;
982
+ final isDark = widget.isDark;
983
+ final isMcpTool = call.providerType == ToolProviderType.mcp;
984
+
985
+ final iconColor = switch (call.status) {
986
ToolEventStatus.completed => const Color(0xFF22C55E),
987
ToolEventStatus.failed ||
988
ToolEventStatus.rejected => const Color(0xFFEF4444),
758
- ToolEventStatus.running ||
759
- ToolEventStatus.requested => const Color(0xFFF59E0B),
760
- ToolEventStatus.approved => const Color(0xFF3B82F6),
989
+ ToolEventStatus.running => const Color(0xFF3B82F6),
990
+ ToolEventStatus.requested ||
991
+ ToolEventStatus.approved => const Color(0xFFF59E0B),
992
};
762
- final label = switch (event.status) {
993
+
994
+ final statusLabel = switch (call.status) {
995
ToolEventStatus.requested => 'Requested',
996
ToolEventStatus.approved => 'Approved',
997
ToolEventStatus.rejected => 'Rejected',
@@ -767,148 +999,226 @@ class _ToolTimeline extends StatelessWidget {
999
ToolEventStatus.completed => 'Done',
1000
ToolEventStatus.failed => 'Failed',
1001
};
770
- final l10n = AppLocalizations.of(context)!;
771
- final isMcpTool = event.providerType == ToolProviderType.mcp;
1002
+
1003
+ final showCodeBlock = (call.arguments != null && call.arguments!.isNotEmpty) ||
1004
+ (call.result != null && call.result!.isNotEmpty) ||
1005
+ (call.error != null && call.error!.isNotEmpty);
1006
1007
return Padding(
774
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
775
- child: Row(
1008
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
1009
+ child: Column(
1010
crossAxisAlignment: CrossAxisAlignment.start,
1011
children: [
778
- Padding(
779
- padding: const EdgeInsets.only(top: 2),
780
- child: Icon(iconData, size: 14, color: iconColor),
781
- ),
782
- const SizedBox(width: 6),
783
- Expanded(
784
- child: Column(
785
- crossAxisAlignment: CrossAxisAlignment.start,
786
- children: [
787
- Row(
788
- children: [
789
- Flexible(
790
- child: Text(
791
- event.toolName,
792
- style: TextStyle(
793
- fontSize: 12,
794
- fontWeight: FontWeight.w600,
795
- color: isDark ? Colors.white : Colors.black87,
796
- fontFamily: 'monospace',
1012
+ InkWell(
1013
+ onTap: showCodeBlock
1014
+ ? () => setState(() => _isExpanded = !_isExpanded)
1015
+ : null,
1016
+ borderRadius: BorderRadius.circular(6),
1017
+ child: Padding(
1018
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
1019
+ child: Row(
1020
+ crossAxisAlignment: CrossAxisAlignment.center,
1021
+ children: [
1022
+ if (call.status == ToolEventStatus.running)
1023
+ _AnimatedRunningIcon(color: iconColor)
1024
+ else
1025
+ Icon(
1026
+ switch (call.status) {
1027
+ ToolEventStatus.requested => Icons.hourglass_empty_rounded,
1028
+ ToolEventStatus.approved => Icons.check_circle_outline_rounded,
1029
+ ToolEventStatus.rejected => Icons.block_flipped,
1030
+ ToolEventStatus.running => Icons.cached_rounded,
1031
+ ToolEventStatus.completed => Icons.check_circle_rounded,
1032
+ ToolEventStatus.failed => Icons.error_rounded,
1033
+ },
1034
+ size: 14,
1035
+ color: iconColor,
1036
+ ),
1037
+ const SizedBox(width: 8),
1038
+ Expanded(
1039
+ child: Row(
1040
+ children: [
1041
+ Flexible(
1042
+ child: Text(
1043
+ call.toolName,
1044
+ style: TextStyle(
1045
+ fontSize: 12,
1046
+ fontWeight: FontWeight.bold,
1047
+ color: isDark ? Colors.white : Colors.black87,
1048
+ fontFamily: 'monospace',
1049
+ ),
1050
+ overflow: TextOverflow.ellipsis,
1051
+ ),
1052
),
798
- overflow: TextOverflow.ellipsis,
799
- ),
1053
+ const SizedBox(width: 6),
1054
+ if (isMcpTool) ...[
1055
+ _InlineToolBadge(
1056
+ label: 'MCP',
1057
+ backgroundColor: Colors.purple.withValues(alpha: isDark ? 0.2 : 0.1),
1058
+ textColor: isDark ? const Color(0xFFC084FC) : const Color(0xFF7E22CE),
1059
+ ),
1060
+ ] else if (call.providerType == ToolProviderType.lmStudioServer) ...[
1061
+ _InlineToolBadge(
1062
+ label: 'LM Studio',
1063
+ backgroundColor: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1),
1064
+ textColor: isDark ? const Color(0xFFF97316) : const Color(0xFFC2410C),
1065
+ ),
1066
+ ] else ...[
1067
+ _InlineToolBadge(
1068
+ label: 'LOCAL',
1069
+ backgroundColor: Colors.blue.withValues(alpha: isDark ? 0.2 : 0.1),
1070
+ textColor: isDark ? const Color(0xFF60A5FA) : const Color(0xFF1D4ED8),
1071
+ ),
1072
+ ],
1073
+ const SizedBox(width: 6),
1074
+ _InlineToolBadge(
1075
+ label: statusLabel,
1076
+ backgroundColor: iconColor.withValues(alpha: isDark ? 0.15 : 0.1),
1077
+ textColor: iconColor,
1078
+ ),
1079
+ ],
1080
),
801
- const SizedBox(width: 4),
802
- if (isMcpTool) ...[
803
- _InlineToolBadge(label: l10n.beta_label, isDark: isDark),
804
- const SizedBox(width: 4),
805
- _InlineToolBadge(
806
- label: l10n.experimental_label,
807
- isDark: isDark,
808
- ),
809
- const SizedBox(width: 4),
810
- ],
1081
+ ),
1082
+ if (call.durationMs != null) ...[
1083
+ const SizedBox(width: 8),
1084
Text(
812
- label,
1085
+ '${call.durationMs}ms',
1086
style: TextStyle(
1087
fontSize: 10,
815
- color: iconColor,
816
- fontWeight: FontWeight.w500,
1088
+ color: isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF),
1089
),
1090
),
1091
],
820
- ),
821
- if (event.arguments != null && event.arguments!.isNotEmpty)
822
- Padding(
823
- padding: const EdgeInsets.only(top: 2),
824
- child: Text(
825
- _formatArgs(event.arguments!),
826
- style: TextStyle(
827
- fontSize: 10,
828
- color: isDark
829
- ? const Color(0xFF94A3B8)
830
- : const Color(0xFF64748B),
831
- fontFamily: 'monospace',
832
- ),
833
- maxLines: 3,
834
- overflow: TextOverflow.ellipsis,
1092
+ if (showCodeBlock) ...[
1093
+ const SizedBox(width: 6),
1094
+ Icon(
1095
+ _isExpanded
1096
+ ? Icons.keyboard_arrow_up_rounded
1097
+ : Icons.keyboard_arrow_down_rounded,
1098
+ size: 16,
1099
+ color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
1100
),
1101
+ ],
1102
+ ],
1103
+ ),
1104
+ ),
1105
+ ),
1106
+ if (showCodeBlock && _isExpanded)
1107
+ Padding(
1108
+ padding: const EdgeInsets.only(top: 6, left: 22),
1109
+ child: Container(
1110
+ width: double.infinity,
1111
+ padding: const EdgeInsets.all(8),
1112
+ decoration: BoxDecoration(
1113
+ color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
1114
+ borderRadius: BorderRadius.circular(8),
1115
+ border: Border.all(
1116
+ color: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
1117
),
837
- if (event.result != null && event.result!.isNotEmpty)
838
- Padding(
839
- padding: const EdgeInsets.only(top: 2),
840
- child: Text(
841
- '→ ${event.result}',
842
- style: TextStyle(
843
- fontSize: 10,
844
- color: isDark
845
- ? const Color(0xFF86EFAC)
846
- : const Color(0xFF166534),
847
- fontFamily: 'monospace',
1118
+ ),
1119
+ child: Column(
1120
+ crossAxisAlignment: CrossAxisAlignment.start,
1121
+ children: [
1122
+ if (call.arguments != null && call.arguments!.isNotEmpty) ...[
1123
+ Text(
1124
+ 'Arguments:',
1125
+ style: TextStyle(
1126
+ fontSize: 9,
1127
+ fontWeight: FontWeight.bold,
1128
+ color: isDark ? const Color(0xFF38BDF8) : const Color(0xFF0284C7),
1129
+ fontFamily: 'monospace',
1130
+ ),
1131
),
849
- maxLines: 2,
850
- overflow: TextOverflow.ellipsis,
851
- ),
852
- ),
853
- if (event.error != null && event.error!.isNotEmpty)
854
- Padding(
855
- padding: const EdgeInsets.only(top: 2),
856
- child: Text(
857
- '✕ ${event.error}',
858
- style: TextStyle(
859
- fontSize: 10,
860
- color: const Color(0xFFEF4444),
861
- fontFamily: 'monospace',
1132
+ const SizedBox(height: 2),
1133
+ Text(
1134
+ widget.formatArgs(call.arguments!),
1135
+ style: TextStyle(
1136
+ fontSize: 10.5,
1137
+ color: isDark ? const Color(0xFFCBD5E1) : const Color(0xFF475569),
1138
+ fontFamily: 'monospace',
1139
+ height: 1.3,
1140
+ ),
1141
),
863
- maxLines: 2,
864
- overflow: TextOverflow.ellipsis,
865
- ),
866
- ),
867
- if (event.durationMs != null)
868
- Padding(
869
- padding: const EdgeInsets.only(top: 1),
870
- child: Text(
871
- '${event.durationMs}ms',
872
- style: TextStyle(
873
- fontSize: 9,
874
- color: isDark
875
- ? const Color(0xFF64748B)
876
- : const Color(0xFF94A3B8),
1142
+ ],
1143
+ if (call.result != null && call.result!.isNotEmpty) ...[
1144
+ if (call.arguments != null && call.arguments!.isNotEmpty)
1145
+ const SizedBox(height: 8),
1146
+ Text(
1147
+ 'Output:',
1148
+ style: TextStyle(
1149
+ fontSize: 9,
1150
+ fontWeight: FontWeight.bold,
1151
+ color: isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A),
1152
+ fontFamily: 'monospace',
1153
+ ),
1154
),
878
- ),
879
- ),
880
- ],
1155
+ const SizedBox(height: 2),
1156
+ Text(
1157
+ call.result!,
1158
+ style: TextStyle(
1159
+ fontSize: 10.5,
1160
+ color: isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A),
1161
+ fontFamily: 'monospace',
1162
+ height: 1.3,
1163
+ ),
1164
+ ),
1165
+ ],
1166
+ if (call.error != null && call.error!.isNotEmpty) ...[
1167
+ if (call.arguments != null && call.arguments!.isNotEmpty)
1168
+ const SizedBox(height: 8),
1169
+ Text(
1170
+ 'Error:',
1171
+ style: TextStyle(
1172
+ fontSize: 9,
1173
+ fontWeight: FontWeight.bold,
1174
+ color: isDark ? const Color(0xFFF87171) : const Color(0xFFDC2626),
1175
+ fontFamily: 'monospace',
1176
+ ),
1177
+ ),
1178
+ const SizedBox(height: 2),
1179
+ Text(
1180
+ call.error!,
1181
+ style: TextStyle(
1182
+ fontSize: 10.5,
1183
+ color: isDark ? const Color(0xFFFCA5A5) : const Color(0xFF991B1B),
1184
+ fontFamily: 'monospace',
1185
+ height: 1.3,
1186
+ ),
1187
+ ),
1188
+ ],
1189
+ ],
1190
+ ),
1191
+ ),
1192
),
882
- ),
1193
],
1194
),
1195
);
1196
}
887
-
888
- String _formatArgs(Map<String, dynamic> args) {
889
- return args.entries.map((e) => '${e.key}: ${e.value}').join(', ');
890
- }
1197
}
1198
1199
class _InlineToolBadge extends StatelessWidget {
894
- const _InlineToolBadge({required this.label, required this.isDark});
1200
+ const _InlineToolBadge({
1201
+ required this.label,
1202
+ required this.backgroundColor,
1203
+ required this.textColor,
1204
+ });
1205
1206
final String label;
897
- final bool isDark;
1207
+ final Color backgroundColor;
1208
+ final Color textColor;
1209
1210
@override
1211
Widget build(BuildContext context) {
1212
return Container(
1213
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
1214
decoration: BoxDecoration(
904
- color: Colors.amber.withValues(alpha: isDark ? 0.18 : 0.14),
905
- borderRadius: BorderRadius.circular(999),
906
- border: Border.all(color: Colors.amber.withValues(alpha: 0.45)),
1215
+ color: backgroundColor,
1216
+ borderRadius: BorderRadius.circular(4),
1217
),
1218
child: Text(
1219
label.toUpperCase(),
910
- style: const TextStyle(
911
- color: Color(0xFFB45309),
1220
+ style: TextStyle(
1221
+ color: textColor,
1222
fontSize: 8,
1223
fontWeight: FontWeight.w700,
1224
letterSpacing: 0.35,
lib/features/chat/views/components/chat_settings_sheet.dart
-18
@@ -151,8 +151,6 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
151
),
152
const SizedBox(width: 8),
153
Text(l10n.mcp_tab),
154
- const SizedBox(width: 6),
155
- _McpBadge(label: l10n.beta_label, isDark: isDark),
154
],
155
),
156
),
@@ -294,28 +292,12 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
292
crossAxisAlignment: WrapCrossAlignment.center,
293
children: [
294
Text(l10n.mcp_enable_chat),
297
- _McpBadge(label: l10n.beta_label, isDark: isDark),
295
_McpBadge(label: l10n.experimental_label, isDark: isDark),
296
],
297
),
298
),
299
const SizedBox(height: 16),
300
if (mcpConfig.enabled && isGloballyEnabled) ...[
304
- ShadSwitch(
305
- value: mcpConfig.autoExecuteTools,
306
- onChanged: (v) =>
307
- ref.read(chatMcpConfigProvider.notifier).toggleAutoExecute(),
308
- label: Wrap(
309
- spacing: 6,
310
- runSpacing: 4,
311
- crossAxisAlignment: WrapCrossAlignment.center,
312
- children: [
313
- Text(l10n.auto_execute_tools),
314
- _McpBadge(label: l10n.experimental_label, isDark: isDark),
315
- ],
316
- ),
317
- ),
318
- const SizedBox(height: 24),
301
Text(l10n.add_ephemeral_mcp, style: theme.textTheme.list),
302
const SizedBox(height: 12),
303
Row(
lib/features/chat/views/components/reasoning_widget.dart
+165
-94
@@ -1,4 +1,5 @@
1
import 'package:flutter/material.dart';
2
+import 'package:gpt_markdown/gpt_markdown.dart';
3
import 'package:localmind/l10n/app_localizations.dart';
4
5
class ReasoningWidget extends StatefulWidget {
@@ -15,34 +16,54 @@ class ReasoningWidget extends StatefulWidget {
16
State<ReasoningWidget> createState() => _ReasoningWidgetState();
17
}
18
18
-class _ReasoningWidgetState extends State<ReasoningWidget> {
19
+class _ReasoningWidgetState extends State<ReasoningWidget> with SingleTickerProviderStateMixin {
20
late bool _isExpanded;
21
+ late AnimationController _animationController;
22
+ late Animation<double> _expandAnimation;
23
24
@override
25
void initState() {
26
super.initState();
27
_isExpanded = widget.isStreaming;
28
+ _animationController = AnimationController(
29
+ vsync: this,
30
+ duration: const Duration(milliseconds: 250),
31
+ );
32
+ _expandAnimation = CurvedAnimation(
33
+ parent: _animationController,
34
+ curve: Curves.fastOutSlowIn,
35
+ );
36
+ if (_isExpanded) {
37
+ _animationController.value = 1.0;
38
+ }
39
+ }
40
+
41
+ @override
42
+ void dispose() {
43
+ _animationController.dispose();
44
+ super.dispose();
45
}
46
47
@override
48
void didUpdateWidget(ReasoningWidget oldWidget) {
49
super.didUpdateWidget(oldWidget);
50
if (widget.isStreaming && !oldWidget.isStreaming) {
31
- _isExpanded = true;
51
+ setState(() {
52
+ _isExpanded = true;
53
+ _animationController.forward();
54
+ });
55
}
56
}
57
35
- String _getLastLines(String content, int maxLines) {
36
- var lineCount = 0;
37
- for (var i = content.length - 1; i >= 0; i--) {
38
- if (content.codeUnitAt(i) == 10) {
39
- lineCount++;
40
- if (lineCount >= maxLines) {
41
- return '... ${content.substring(i + 1).trim()}';
42
- }
58
+ void _toggleExpand() {
59
+ setState(() {
60
+ _isExpanded = !_isExpanded;
61
+ if (_isExpanded) {
62
+ _animationController.forward();
63
+ } else {
64
+ _animationController.reverse();
65
}
44
- }
45
- return content.trim();
66
+ });
67
}
68
69
@override
@@ -56,98 +77,148 @@ class _ReasoningWidgetState extends State<ReasoningWidget> {
77
return const SizedBox.shrink();
78
}
79
59
- final previewText = hasContent
60
- ? _getLastLines(widget.reasoningContent!, 4)
61
- : '';
62
- final visibleReasoningText = widget.isStreaming
63
- ? previewText
64
- : (_isExpanded ? widget.reasoningContent! : previewText);
65
-
66
- return GestureDetector(
67
- onTap: () => setState(() => _isExpanded = !_isExpanded),
68
- child: AnimatedContainer(
69
- duration: const Duration(milliseconds: 200),
70
- margin: const EdgeInsets.only(bottom: 8),
71
- padding: const EdgeInsets.all(12),
72
- decoration: BoxDecoration(
80
+ return Container(
81
+ margin: const EdgeInsets.only(bottom: 12, top: 4),
82
+ decoration: BoxDecoration(
83
+ color: isDark
84
+ ? const Color(0xFF1E1E2E).withValues(alpha: 0.6)
85
+ : const Color(0xFFF5F7FF).withValues(alpha: 0.8),
86
+ borderRadius: BorderRadius.circular(12),
87
+ border: Border.all(
88
color: isDark
74
- ? const Color(0xFF1F2937).withValues(alpha: 0.5)
75
- : const Color(0xFFEEF2FF).withValues(alpha: 0.5),
76
- borderRadius: BorderRadius.circular(8),
77
- border: Border.all(
78
- color: isDark ? const Color(0xFF374151) : const Color(0xFFE0E7FF),
79
- ),
89
+ ? const Color(0xFF313244).withValues(alpha: 0.8)
90
+ : const Color(0xFFE0E5F5),
91
+ width: 1.0,
92
),
81
- child: Column(
82
- crossAxisAlignment: CrossAxisAlignment.start,
83
- children: [
84
- Row(
85
- children: [
86
- if (widget.isStreaming)
87
- SizedBox(
88
- width: 12,
89
- height: 12,
90
- child: CircularProgressIndicator(
91
- strokeWidth: 2,
92
- color: theme.colorScheme.primary.withValues(alpha: 0.6),
93
- ),
94
- )
95
- else
96
- Icon(
97
- _isExpanded ? Icons.expand_less : Icons.expand_more,
98
- size: 16,
99
- color: isDark
100
- ? const Color(0xFF9CA3AF)
101
- : const Color(0xFF6B7280),
102
- ),
103
- const SizedBox(width: 8),
104
- Text(
105
- l10n.thinking,
106
- style: TextStyle(
107
- fontSize: 12,
108
- fontWeight: FontWeight.w500,
109
- color: isDark
110
- ? const Color(0xFF9CA3AF)
111
- : const Color(0xFF6B7280),
93
+ boxShadow: [
94
+ BoxShadow(
95
+ color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.03),
96
+ blurRadius: 10,
97
+ offset: const Offset(0, 4),
98
+ ),
99
+ ],
100
+ ),
101
+ child: ClipRRect(
102
+ borderRadius: BorderRadius.circular(11),
103
+ child: IntrinsicHeight(
104
+ child: Row(
105
+ crossAxisAlignment: CrossAxisAlignment.stretch,
106
+ children: [
107
+ // Accent glowing line
108
+ Container(
109
+ width: 4,
110
+ decoration: BoxDecoration(
111
+ gradient: LinearGradient(
112
+ colors: [
113
+ theme.colorScheme.primary,
114
+ theme.colorScheme.secondary,
115
+ ],
116
+ begin: Alignment.topCenter,
117
+ end: Alignment.bottomCenter,
118
),
119
),
114
- if (widget.isStreaming) ...[
115
- const SizedBox(width: 8),
116
- Text(
117
- _getThinkingIndicator(),
118
- style: TextStyle(
119
- fontSize: 11,
120
- color: isDark
121
- ? const Color(0xFF6B7280)
122
- : const Color(0xFF9CA3AF),
120
+ ),
121
+ Expanded(
122
+ child: Column(
123
+ mainAxisSize: MainAxisSize.min,
124
+ crossAxisAlignment: CrossAxisAlignment.start,
125
+ children: [
126
+ // Header Row
127
+ InkWell(
128
+ onTap: _toggleExpand,
129
+ borderRadius: const BorderRadius.only(
130
+ topRight: Radius.circular(11),
131
+ bottomRight: Radius.circular(11),
132
+ ),
133
+ child: Padding(
134
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
135
+ child: Row(
136
+ children: [
137
+ // Brain / thinking icon
138
+ Icon(
139
+ Icons.psychology_outlined,
140
+ size: 18,
141
+ color: isDark
142
+ ? const Color(0xFFBAC2DE)
143
+ : const Color(0xFF585B70),
144
+ ),
145
+ const SizedBox(width: 8),
146
+ Text(
147
+ l10n.thinking,
148
+ style: TextStyle(
149
+ fontSize: 13,
150
+ fontWeight: FontWeight.w600,
151
+ letterSpacing: 0.3,
152
+ color: isDark
153
+ ? const Color(0xFFBAC2DE)
154
+ : const Color(0xFF585B70),
155
+ ),
156
+ ),
157
+ const SizedBox(width: 8),
158
+ if (widget.isStreaming) ...[
159
+ SizedBox(
160
+ width: 10,
161
+ height: 10,
162
+ child: CircularProgressIndicator(
163
+ strokeWidth: 1.5,
164
+ color: theme.colorScheme.primary,
165
+ ),
166
+ ),
167
+ ],
168
+ const Spacer(),
169
+ // Expand Icon with rotation
170
+ RotationTransition(
171
+ turns: Tween(begin: 0.0, end: 0.5).animate(_expandAnimation),
172
+ child: Icon(
173
+ Icons.keyboard_arrow_down_rounded,
174
+ size: 18,
175
+ color: isDark
176
+ ? const Color(0xFF9399B2)
177
+ : const Color(0xFF7C7F93),
178
+ ),
179
+ ),
180
+ ],
181
+ ),
182
+ ),
183
),
124
- ),
125
- ],
126
- ],
127
- ),
128
- if (hasContent) ...[
129
- const SizedBox(height: 8),
130
- Text(
131
- visibleReasoningText,
132
- style: TextStyle(
133
- fontSize: 13,
134
- color: isDark
135
- ? const Color(0xFFD1D5DB)
136
- : const Color(0xFF4B5563),
137
- fontStyle: FontStyle.italic,
138
- height: 1.4,
184
+ // Expandable content
185
+ SizeTransition(
186
+ sizeFactor: _expandAnimation,
187
+ child: Padding(
188
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
189
+ child: Column(
190
+ crossAxisAlignment: CrossAxisAlignment.start,
191
+ children: [
192
+ GptMarkdownTheme(
193
+ gptThemeData: GptMarkdownThemeData(
194
+ brightness: isDark ? Brightness.dark : Brightness.light,
195
+ highlightColor: isDark
196
+ ? const Color(0xFF313244)
197
+ : const Color(0xFFEFF1F5),
198
+ linkColor: theme.colorScheme.primary,
199
+ ),
200
+ child: GptMarkdown(
201
+ widget.reasoningContent ?? '',
202
+ style: TextStyle(
203
+ fontSize: 13,
204
+ color: isDark
205
+ ? const Color(0xFFCDD6F4).withValues(alpha: 0.95)
206
+ : const Color(0xFF4C4F69).withValues(alpha: 0.95),
207
+ height: 1.5,
208
+ ),
209
+ ),
210
+ ),
211
+ ],
212
+ ),
213
+ ),
214
+ ),
215
+ ],
216
),
217
),
218
],
142
- ],
219
+ ),
220
),
221
),
222
);
223
}
147
-
148
- final String _thinkingText = '';
149
- String _getThinkingIndicator() {
150
- if (!widget.isStreaming) return '';
151
- return _thinkingText;
152
- }
224
}
lib/features/on_device/data/on_device_chat_service.dart
+2
@@ -9,6 +9,7 @@ import '../../chat/data/chat_service.dart';
9
import '../../chat/data/models/chat_parameters.dart';
10
import '../../chat/data/models/mcp_integration.dart';
11
import '../../chat/data/models/message.dart' hide ToolCallData;
12
+import '../../chat/data/tools/tool_definition.dart';
13
import '../../servers/data/models/server.dart';
14
import 'on_device_gemma_service.dart';
15
@@ -30,6 +31,7 @@ class OnDeviceChatService implements ChatService {
31
required List<Message> messages,
32
required ChatParameters params,
33
List<McpIntegration>? integrations,
34
+ List<ToolDefinition>? tools,
35
String? previousResponseId,
36
}) {
37
cancelStream();
lib/features/settings/views/settings_screen.dart
-2
@@ -162,7 +162,6 @@ class SettingsViews extends ConsumerWidget {
162
label: l10n.enable_mcp,
163
value: settings.mcpEnabled,
164
badges: [
165
- _FeatureBadge(label: l10n.beta_label),
165
_FeatureBadge(label: l10n.experimental_label),
166
],
167
onChanged: (value) =>
@@ -172,7 +171,6 @@ class SettingsViews extends ConsumerWidget {
171
_ToggleSetting(
172
label: l10n.new_chat_mcp_default,
173
value: settings.newChatMcpEnabled,
175
- badges: [_FeatureBadge(label: l10n.beta_label)],
174
onChanged: (value) => ref
175
.read(settingsProvider.notifier)
176
.setNewChatMcpEnabled(value),
lib/features/sidebar/sidebar_widget.dart
-1
@@ -96,7 +96,6 @@ class SidebarWidget extends ConsumerWidget {
96
DrawerNavItem(
97
iconData: HugeIcons.strokeRoundedMcpServer,
98
label: 'MCP Tools',
99
- badgeText: 'Beta',
99
isSelected: isMcpTools,
100
onTap: () {
101
if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
lib/objectbox-model.json
+11
-1
@@ -111,7 +111,7 @@
111
},
112
{
113
"id": "2:196881068437544233",
114
- "lastPropertyId": "17:6197342254666211356",
114
+ "lastPropertyId": "19:5573835801528383007",
115
"name": "MessageEntity",
116
"properties": [
117
{
@@ -206,6 +206,16 @@
206
"id": "17:6197342254666211356",
207
"name": "isProcessing",
208
"type": 1
209
+ },
210
+ {
211
+ "id": "18:3862782083473953676",
212
+ "name": "toolSessionId",
213
+ "type": 9
214
+ },
215
+ {
216
+ "id": "19:5573835801528383007",
217
+ "name": "toolEventsJson",
218
+ "type": 9
219
}
220
],
221
"relations": []
lib/objectbox.g.dart
+40
-2
@@ -153,7 +153,7 @@ final _entities = <obx_int.ModelEntity>[
153
obx_int.ModelEntity(
154
id: const obx_int.IdUid(2, 196881068437544233),
155
name: 'MessageEntity',
156
- lastPropertyId: const obx_int.IdUid(17, 6197342254666211356),
156
+ lastPropertyId: const obx_int.IdUid(19, 5573835801528383007),
157
flags: 0,
158
properties: <obx_int.ModelProperty>[
159
obx_int.ModelProperty(
@@ -263,6 +263,18 @@ final _entities = <obx_int.ModelEntity>[
263
type: 1,
264
flags: 0,
265
),
266
+ obx_int.ModelProperty(
267
+ id: const obx_int.IdUid(18, 3862782083473953676),
268
+ name: 'toolSessionId',
269
+ type: 9,
270
+ flags: 0,
271
+ ),
272
+ obx_int.ModelProperty(
273
+ id: const obx_int.IdUid(19, 5573835801528383007),
274
+ name: 'toolEventsJson',
275
+ type: 9,
276
+ flags: 0,
277
+ ),
278
],
279
relations: <obx_int.ModelRelation>[],
280
backlinks: <obx_int.ModelBacklink>[],
@@ -690,7 +702,13 @@ obx_int.ModelDefinition getObjectBoxModel() {
702
final toolCallIdOffset = object.toolCallId == null
703
? null
704
: fbb.writeString(object.toolCallId!);
693
- fbb.startTable(18);
705
+ final toolSessionIdOffset = object.toolSessionId == null
706
+ ? null
707
+ : fbb.writeString(object.toolSessionId!);
708
+ final toolEventsJsonOffset = object.toolEventsJson == null
709
+ ? null
710
+ : fbb.writeString(object.toolEventsJson!);
711
+ fbb.startTable(20);
712
fbb.addInt64(0, object.internalId);
713
fbb.addOffset(1, idOffset);
714
fbb.addInt64(2, object.conversation.targetId);
@@ -708,6 +726,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
726
fbb.addOffset(14, toolCallsJsonOffset);
727
fbb.addOffset(15, toolCallIdOffset);
728
fbb.addBool(16, object.isProcessing);
729
+ fbb.addOffset(17, toolSessionIdOffset);
730
+ fbb.addOffset(18, toolEventsJsonOffset);
731
fbb.finish(fbb.endTable());
732
return object.internalId;
733
},
@@ -778,6 +798,12 @@ obx_int.ModelDefinition getObjectBoxModel() {
798
36,
799
false,
800
);
801
+ final toolSessionIdParam = const fb.StringReader(
802
+ asciiOptimization: true,
803
+ ).vTableGetNullable(buffer, rootOffset, 38);
804
+ final toolEventsJsonParam = const fb.StringReader(
805
+ asciiOptimization: true,
806
+ ).vTableGetNullable(buffer, rootOffset, 40);
807
final object = MessageEntity(
808
internalId: internalIdParam,
809
id: idParam,
@@ -795,6 +821,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
821
toolCallsJson: toolCallsJsonParam,
822
toolCallId: toolCallIdParam,
823
isProcessing: isProcessingParam,
824
+ toolSessionId: toolSessionIdParam,
825
+ toolEventsJson: toolEventsJsonParam,
826
);
827
object.conversation.targetId = const fb.Int64Reader().vTableGet(
828
buffer,
@@ -1203,6 +1231,16 @@ class MessageEntity_ {
1231
static final isProcessing = obx.QueryBooleanProperty<MessageEntity>(
1232
_entities[1].properties[16],
1233
);
1234
+
1235
+ /// See [MessageEntity.toolSessionId].
1236
+ static final toolSessionId = obx.QueryStringProperty<MessageEntity>(
1237
+ _entities[1].properties[17],
1238
+ );
1239
+
1240
+ /// See [MessageEntity.toolEventsJson].
1241
+ static final toolEventsJson = obx.QueryStringProperty<MessageEntity>(
1242
+ _entities[1].properties[18],
1243
+ );
1244
}
1245
1246
/// [PersonaEntity] entity fields to define ObjectBox queries.
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.3.6+38
19
+version: 1.3.7+39
20
21
environment:
22
sdk: ^3.11.0
test/features/chat/tools/chat_service_adapter_selection_test.dart
+149
@@ -1,9 +1,47 @@
1
+import 'dart:async';
2
+import 'dart:convert';
3
+import 'dart:typed_data';
4
+import 'package:dio/dio.dart';
5
import 'package:flutter_test/flutter_test.dart';
6
import 'package:localmind/core/models/enums.dart';
7
import 'package:localmind/features/chat/data/chat_service.dart';
8
+import 'package:localmind/features/chat/data/models/message.dart';
9
+import 'package:localmind/features/chat/data/models/chat_parameters.dart';
10
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
11
import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
12
import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
13
import 'package:localmind/features/chat/data/tools/adapters/openrouter_tool_adapter.dart';
14
+import 'package:localmind/features/servers/data/models/server.dart';
15
+
16
+class StreamInterceptor extends Interceptor {
17
+ final List<String> lines;
18
+ StreamInterceptor(this.lines);
19
+
20
+ @override
21
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
22
+ final controller = StreamController<Uint8List>();
23
+ handler.resolve(Response(
24
+ requestOptions: options,
25
+ data: ResponseBody(
26
+ controller.stream,
27
+ 200,
28
+ headers: {
29
+ Headers.contentTypeHeader: ['application/json'],
30
+ },
31
+ ),
32
+ statusCode: 200,
33
+ ));
34
+
35
+ // Emit lines asynchronously
36
+ Future.microtask(() async {
37
+ for (final line in lines) {
38
+ controller.add(Uint8List.fromList(utf8.encode('$line\n')));
39
+ await Future.delayed(const Duration(milliseconds: 1));
40
+ }
41
+ await controller.close();
42
+ });
43
+ }
44
+}
45
46
void main() {
47
group('createAdapterForServerType', () {
@@ -22,4 +60,115 @@ void main() {
60
expect(adapter, isA<OllamaToolAdapter>());
61
});
62
});
63
+
64
+ group('ChatService implementation tool call adapter integration', () {
65
+ test('OpenAICompatibleChatService uses OpenAiToolAdapter and parses OpenAI stream tool calls', () async {
66
+ final lines = [
67
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_openai_1", "type": "function", "function": {"name": "calc.add", "arguments": ""}}]}}]}',
68
+ 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"a\\":1,\\"b\\":2}"}}]}}]}',
69
+ 'data: [DONE]'
70
+ ];
71
+
72
+ final dio = Dio()..interceptors.add(StreamInterceptor(lines));
73
+ final service = OpenAICompatibleChatService(dio);
74
+
75
+ final server = Server(
76
+ id: 'test-openai',
77
+ name: 'Test OpenAI',
78
+ type: ServerType.openAICompatible,
79
+ host: 'localhost',
80
+ port: 8080,
81
+ createdAt: DateTime.now(),
82
+ lastConnectedAt: DateTime.now(),
83
+ );
84
+
85
+ final responseStream = service.sendMessage(
86
+ server: server,
87
+ modelId: 'test-model',
88
+ messages: [
89
+ Message(
90
+ id: 'msg_1',
91
+ conversationId: 'conv_1',
92
+ role: MessageRole.user,
93
+ content: 'hello',
94
+ createdAt: DateTime.now(),
95
+ )
96
+ ],
97
+ params: ChatParameters.defaults(),
98
+ tools: [
99
+ const ToolDefinition(
100
+ name: 'calc.add',
101
+ description: 'Add two numbers',
102
+ inputSchema: {'type': 'object'},
103
+ providerType: ToolProviderType.builtIn,
104
+ )
105
+ ],
106
+ );
107
+
108
+ final responses = await responseStream.toList();
109
+ final toolCalls = responses
110
+ .where((r) => r.type == ChatResponseType.toolCall)
111
+ .map((r) => r.toolCall)
112
+ .toList();
113
+
114
+ expect(toolCalls, isNotEmpty);
115
+ expect(toolCalls.first!.tool, 'calc.add');
116
+ expect(toolCalls.first!.arguments['a'], 1);
117
+ expect(toolCalls.first!.arguments['b'], 2);
118
+ });
119
+
120
+ test('OllamaChatService uses OllamaToolAdapter and parses Ollama stream tool calls', () async {
121
+ final lines = [
122
+ '{"message": {"tool_calls": [{"id": "call_ollama_1", "function": {"name": "calc.multiply", "arguments": "{\\"a\\":5,\\"b\\":6}"}}]}}',
123
+ '{"done": true}'
124
+ ];
125
+
126
+ final dio = Dio()..interceptors.add(StreamInterceptor(lines));
127
+ final service = OllamaChatService(dio);
128
+
129
+ final server = Server(
130
+ id: 'test-ollama',
131
+ name: 'Test Ollama',
132
+ type: ServerType.ollama,
133
+ host: 'localhost',
134
+ port: 11434,
135
+ createdAt: DateTime.now(),
136
+ lastConnectedAt: DateTime.now(),
137
+ );
138
+
139
+ final responseStream = service.sendMessage(
140
+ server: server,
141
+ modelId: 'test-model',
142
+ messages: [
143
+ Message(
144
+ id: 'msg_2',
145
+ conversationId: 'conv_2',
146
+ role: MessageRole.user,
147
+ content: 'hello',
148
+ createdAt: DateTime.now(),
149
+ )
150
+ ],
151
+ params: ChatParameters.defaults(),
152
+ tools: [
153
+ const ToolDefinition(
154
+ name: 'calc.multiply',
155
+ description: 'Multiply two numbers',
156
+ inputSchema: {'type': 'object'},
157
+ providerType: ToolProviderType.builtIn,
158
+ )
159
+ ],
160
+ );
161
+
162
+ final responses = await responseStream.toList();
163
+ final toolCalls = responses
164
+ .where((r) => r.type == ChatResponseType.toolCall)
165
+ .map((r) => r.toolCall)
166
+ .toList();
167
+
168
+ expect(toolCalls, isNotEmpty);
169
+ expect(toolCalls.first!.tool, 'calc.multiply');
170
+ expect(toolCalls.first!.arguments['a'], 5);
171
+ expect(toolCalls.first!.arguments['b'], 6);
172
+ });
173
+ });
174
}